Skip to content

Commit b5d305b

Browse files
authored
Add Github source links to generated API documentation (#923)
Fixes #921
1 parent 83d9a13 commit b5d305b

File tree

6 files changed

+117
-2
lines changed

6 files changed

+117
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
### Other
2525
* Add REPL documentation module (#205)
2626
* Add documentation module for Basilisp interfaces (#920)
27+
* Add GitHub source links to generated API documentation (#921)
2728
* Update Sphinx documentation theme (#909)
2829
* Update documentation to directly reference Python documentation and fix many other minor issues and misspellings (#907, #919)
2930

docs/conf.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
# This file does only contain a selection of the most common options. For a
66
# full list see the documentation:
77
# http://www.sphinx-doc.org/en/master/config
8-
8+
import os
9+
import pathlib
10+
import re
11+
import sys
912

1013
# -- Project information -----------------------------------------------------
1114

@@ -173,3 +176,32 @@
173176
intersphinx_mapping = {
174177
"python": ("https://docs.python.org/3", None),
175178
}
179+
180+
# -- Options for Basilisp's custom linkcode ----------------------------------
181+
182+
_vcs_branch = os.getenv("READTHEDOCS_GIT_IDENTIFIER", "main")
183+
184+
185+
def lpy_linkcode_resolve(filename, lines):
186+
if not filename:
187+
return None
188+
189+
pth = None
190+
for path in sys.path:
191+
try:
192+
pth = pathlib.Path(filename).relative_to(path)
193+
except ValueError:
194+
continue
195+
196+
if pth is None:
197+
return None
198+
199+
line_anchor = ""
200+
if lines and (match := re.match(r"(\d+)(?::(\d+))?", lines)) is not None:
201+
start_line = match.group(1)
202+
if (end_line := match.group(2)) is None:
203+
line_anchor = f"#L{start_line}"
204+
else:
205+
line_anchor = f"#L{start_line}-L{end_line}"
206+
207+
return f"https://github.com/basilisp-lang/basilisp/blob/{_vcs_branch}/src/{pth}{line_anchor}"

src/basilisp/contrib/sphinx/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from sphinx.application import Sphinx
22

3+
from basilisp.contrib.sphinx import linkcode
34
from basilisp.contrib.sphinx.autodoc import (
45
NamespaceDocumenter,
56
ProtocolDocumenter,
@@ -38,5 +39,8 @@ def setup(app: Sphinx) -> None:
3839
app.add_autodocumenter(TypeDocumenter)
3940
app.add_autodocumenter(RecordDocumenter)
4041

42+
app.connect("doctree-read", linkcode.doctree_read)
43+
app.add_config_value("lpy_linkcode_resolve", None, "")
44+
4145

4246
__all__ = ("setup",)

src/basilisp/contrib/sphinx/autodoc.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
# Var metadata used for annotations
3838
_DOC_KW = kw.keyword("doc")
3939
_LINE_KW = kw.keyword("line")
40+
_END_LINE_KW = kw.keyword("end-line")
4041
_PRIVATE_KW = kw.keyword("private")
4142
_DYNAMIC_KW = kw.keyword("dynamic")
4243
_DEPRECATED_KW = kw.keyword("deprecated")
@@ -279,6 +280,13 @@ def add_directive_header(self, sig: str) -> None:
279280
super().add_directive_header(sig)
280281

281282
if self.object.meta is not None:
283+
if (file := self.object.meta.val_at(_FILE_KW)) is not None:
284+
self.add_line(f" :filename: {file}", sourcename)
285+
if isinstance(line := self.object.meta.val_at(_LINE_KW), int):
286+
if isinstance(end_line := self.object.meta.val_at(_END_LINE_KW), int):
287+
self.add_line(f" :lines: {line}:{end_line}", sourcename)
288+
else:
289+
self.add_line(f" :lines: {line}", sourcename)
282290
if self.object.meta.val_at(_DYNAMIC_KW):
283291
self.add_line(" :dynamic:", sourcename)
284292
if self.object.meta.val_at(_DEPRECATED_KW):

src/basilisp/contrib/sphinx/domain.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ def run(self) -> List[Node]:
131131

132132

133133
class BasilispObject(PyObject): # pylint: disable=abstract-method
134+
option_spec: OptionSpec = PyObject.option_spec.copy()
135+
option_spec.update(
136+
{
137+
"filename": directives.unchanged,
138+
"lines": directives.unchanged,
139+
}
140+
)
141+
134142
def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]:
135143
"""Subclasses should implement this themselves."""
136144
return NotImplemented
@@ -157,9 +165,14 @@ def add_target_and_index(
157165
("single", indextext, node_id, "", None)
158166
)
159167

168+
def _add_source_annotations(self, signode: desc_signature) -> None:
169+
for metadata in ("filename", "lines"):
170+
if val := self.options.get(metadata):
171+
signode[metadata] = val
172+
160173

161174
class BasilispVar(BasilispObject):
162-
option_spec: OptionSpec = PyObject.option_spec.copy()
175+
option_spec: OptionSpec = BasilispObject.option_spec.copy()
163176
option_spec.update(
164177
{
165178
"dynamic": directives.unchanged,
@@ -181,6 +194,8 @@ def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]
181194

182195
signode += addnodes.desc_name(sig, sig)
183196

197+
self._add_source_annotations(signode)
198+
184199
type_ = self.options.get("type")
185200
if type_:
186201
signode += addnodes.desc_annotation(type_, "", nodes.Text(": "), type_)
@@ -204,6 +219,8 @@ def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]
204219
if prefix:
205220
signode += addnodes.desc_annotation(prefix, prefix)
206221

222+
self._add_source_annotations(signode)
223+
207224
sig_sexp = runtime.first(reader.read_str(sig))
208225
assert isinstance(sig_sexp, IPersistentList)
209226
fn_sym = runtime.first(sig_sexp)
@@ -282,6 +299,8 @@ def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]
282299
if prefix:
283300
signode += addnodes.desc_annotation(prefix, prefix)
284301

302+
self._add_source_annotations(signode)
303+
285304
signode += addnodes.desc_name(sig, sig)
286305
return sig, prefix
287306

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from typing import Set
2+
3+
from docutils import nodes
4+
from docutils.nodes import Node
5+
from sphinx import addnodes
6+
from sphinx.application import Sphinx
7+
from sphinx.util import logging
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def doctree_read(app: Sphinx, doctree: Node) -> None:
13+
env = app.builder.env
14+
15+
resolve_link = getattr(env.config, "lpy_linkcode_resolve", None)
16+
if not callable(resolve_link):
17+
logger.warning("No Basilisp linkcode resolver!")
18+
return
19+
20+
for objnode in list(doctree.findall(addnodes.desc)):
21+
domain = objnode.get("domain")
22+
if domain != "lpy":
23+
continue
24+
25+
uris: Set[str] = set()
26+
for signode in objnode:
27+
if not isinstance(signode, addnodes.desc_signature):
28+
continue
29+
30+
info = {}
31+
for key in ("filename", "lines"):
32+
value = signode.get(key)
33+
if not value:
34+
value = ""
35+
info[key] = value
36+
37+
if not info or all(not v for v in info.values()):
38+
continue
39+
40+
if not (uri := resolve_link(info.get("filename"), info.get("lines"))):
41+
continue
42+
43+
if uri in uris:
44+
continue
45+
46+
uris.add(uri)
47+
48+
inline = nodes.inline("", "[source]", classes=["viewcode-link"])
49+
onlynode = addnodes.only(expr="html")
50+
onlynode += nodes.reference("", "", inline, internal=False, refuri=uri)
51+
signode += onlynode # pylint: disable=redefined-loop-name

0 commit comments

Comments
 (0)