Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add links to externally hosted source code for documented symbols #188

Merged
merged 3 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions doc/extension.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <usage/extensions/linkcode>`. 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

Expand Down
54 changes: 50 additions & 4 deletions src/hawkmoth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion src/hawkmoth/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 13 additions & 6 deletions src/hawkmoth/docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,27 @@ 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])

prefix_len = _get_prefix_len(lines[1:-1])
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):
"""
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down