diff --git a/doc/conf.py b/doc/conf.py index c3e7135f..dc69f4b0 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -61,6 +61,11 @@ hawkmoth_root = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../test/examples') +source_uri = 'https://github.com/jnikula/hawkmoth/tree/{version}/test/examples/{{source}}#L{{line}}' +source_version = f'v{version}' if len(version.split('.')) == 3 else 'master' + +hawkmoth_source_uri = source_uri.format(version=source_version) + # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output diff --git a/doc/extension.rst b/doc/extension.rst index e6ffe9cd..9aca9b1c 100644 --- a/doc/extension.rst +++ b/doc/extension.rst @@ -85,6 +85,27 @@ See also additional configuration options in the :ref:`built-in extensions You can also pass in the compiler to use, for example ``get_include_args('gcc')``. +.. py:data:: hawkmoth_source_uri + :type: str + + A template URI to source code. If set, add links to externally hosted source + code for each documented symbol, similar to the :external+sphinx:doc:`Sphinx + linkcode extension `. Defaults to ``None``. + + The template URI will be formatted using + :external+python:py:meth:`str.format`, with the following replacement fields: + + ``{source}`` + Path to source file relative to :py:data:`hawkmoth_root`. + ``{line}`` + Line number in source file. + + Example: + + .. code-block:: python + + hawkmoth_source_uri = 'https://example.org/src/{source}#L{line}' + .. py:data:: cautodoc_root :type: str diff --git a/src/hawkmoth/__init__.py b/src/hawkmoth/__init__.py index 7f979b22..bac4292c 100644 --- a/src/hawkmoth/__init__.py +++ b/src/hawkmoth/__init__.py @@ -13,6 +13,7 @@ from docutils import nodes from docutils.parsers.rst import directives from docutils.statemachine import ViewList +from sphinx import addnodes from sphinx.util.nodes import nested_parse_with_titles from sphinx.util.docutils import switch_source_input, SphinxDirective from sphinx.util import logging @@ -109,11 +110,11 @@ def __get_docstrings_for_root(self, viewlist, root): filter_names=self._get_names()): num_matches += 1 for docstr in docstrings.walk(filter_names=self._get_members()): - lineoffset = docstr.get_line() - 1 - lines = docstr.get_docstring(process_docstring=process_docstring) + lines, line_number = docstr.get_docstring(process_docstring=process_docstring) for line in lines: - viewlist.append(line, root.get_filename(), lineoffset) - lineoffset += 1 + # viewlist line numbers are 0-based + viewlist.append(line, root.get_filename(), line_number - 1) + line_number += 1 return num_matches @@ -303,6 +304,47 @@ class CppAutoClassDirective(_AutoCompoundDirective): _domain = 'cpp' _docstring_types = [docstring.ClassDocstring] +def _uri_format(env, signode): + """Generate a source URI for signode""" + uri_template = env.config.hawkmoth_source_uri + + if signode.source is None or signode.line is None: + return None + + source = os.path.relpath(signode.source, start=env.config.hawkmoth_root) + + # Note: magic +1 to take into account we've added signode ourselves and its + # not present in source + uri = uri_template.format(source=source, line=signode.line + 1) + + return uri + +def _doctree_read(app, doctree): + env = app.builder.env + + # Bail out early if not configured + uri_template = env.config.hawkmoth_source_uri + if uri_template is None: + return + + for objnode in list(doctree.findall(addnodes.desc)): + if objnode.get('domain') not in ['c', 'cpp']: + continue + + for signode in objnode: + if not isinstance(signode, addnodes.desc_signature): + continue + + uri = _uri_format(env, signode) + if not uri: + continue + + # Similar to sphinx.ext.linkcode + inline = nodes.inline('', '[source]', classes=['viewcode-link']) + onlynode = addnodes.only(expr='html') + onlynode += nodes.reference('', '', inline, internal=False, refuri=uri) + signode += onlynode + def _deprecate(conf, old, new, default=None): if conf[old]: logger = logging.getLogger(__name__) @@ -357,5 +399,9 @@ def setup(app): # Setup transformations for compatibility. app.setup_extension('hawkmoth.ext.transformations') + # Source code link + app.add_config_value('hawkmoth_source_uri', None, 'env', [str]) + app.connect('doctree-read', _doctree_read) + return dict(version=__version__, parallel_read_safe=True, parallel_write_safe=True) diff --git a/src/hawkmoth/__main__.py b/src/hawkmoth/__main__.py index 281ff652..edb83abd 100644 --- a/src/hawkmoth/__main__.py +++ b/src/hawkmoth/__main__.py @@ -55,7 +55,8 @@ def main(): for comment in comments.walk(): if args.verbose: print(f'# {comment.get_meta()}') - print('\n'.join(comment.get_docstring(process_docstring=process_docstring))) + lines, _ = comment.get_docstring(process_docstring=process_docstring) + print('\n'.join(lines)) for error in errors: print(f'{error.level.name}: {error.get_message()}', file=sys.stderr) diff --git a/src/hawkmoth/docstring.py b/src/hawkmoth/docstring.py index f70ae01f..6397db08 100644 --- a/src/hawkmoth/docstring.py +++ b/src/hawkmoth/docstring.py @@ -113,7 +113,12 @@ def _get_comment_lines(self): @staticmethod def _remove_comment_markers(lines): - """Remove comment markers and line prefixes from comment lines.""" + """Remove comment markers and line prefixes from comment lines. + + Return the number of lines removed from the beginning. + """ + line_offset = 0 + lines[0] = re.sub(r'^/\*\*[ \t]*', '', lines[0]) lines[-1] = re.sub(r'[ \t]*\*/$', '', lines[-1]) @@ -121,11 +126,14 @@ def _remove_comment_markers(lines): lines[1:-1] = [line[prefix_len:] for line in lines[1:-1]] while lines and (not lines[0] or lines[0].isspace()): + line_offset += 1 del lines[0] while lines and (not lines[-1] or lines[-1].isspace()): del lines[-1] + return line_offset + @staticmethod def _nest(lines, nest): """ @@ -143,10 +151,7 @@ def get_docstring(self, process_docstring=None): header_lines = self._get_header_lines() comment_lines = self._get_comment_lines() - # FIXME: This changes the number of lines in output. This impacts the - # error reporting via meta['line']. Adjust meta to take this into - # account. - Docstring._remove_comment_markers(comment_lines) + line_offset = Docstring._remove_comment_markers(comment_lines) if process_docstring is not None: process_docstring(comment_lines) @@ -161,6 +166,8 @@ def get_docstring(self, process_docstring=None): if header_lines[-1] != '': header_lines.append('') + line_offset -= len(header_lines) + lines = header_lines + comment_lines Docstring._nest(lines, self._nest) @@ -169,7 +176,7 @@ def get_docstring(self, process_docstring=None): if lines[-1] != '': lines.append('') - return lines + return lines, self.get_line() + line_offset def get_meta(self): return self._meta diff --git a/test/test_parser.py b/test/test_parser.py index 892f32c2..b8207d62 100755 --- a/test/test_parser.py +++ b/test/test_parser.py @@ -111,7 +111,7 @@ def get_output(self): for docstrings in root.walk(recurse=False, filter_types=_filter_types(directive), filter_names=_filter_names(directive)): for docstr in docstrings.walk(filter_names=_filter_members(directive)): - lines = docstr.get_docstring(process_docstring=process_docstring) + lines, _ = docstr.get_docstring(process_docstring=process_docstring) docs_str += '\n'.join(lines) + '\n' return docs_str, errors_str