markwork 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- markwork/__init__.py +48 -0
- markwork/engine/__init__.py +65 -0
- markwork/engine/_abs_module.py +44 -0
- markwork/engine/_add_alias_site.py +31 -0
- markwork/engine/_bound_anywhere.py +42 -0
- markwork/engine/_builtin_links.py +67 -0
- markwork/engine/_css_class.py +32 -0
- markwork/engine/_detect_package.py +47 -0
- markwork/engine/_generate.py +35 -0
- markwork/engine/_highlight.py +71 -0
- markwork/engine/_is_stdlib.py +36 -0
- markwork/engine/_line_starts.py +35 -0
- markwork/engine/_merge_tokens.py +36 -0
- markwork/engine/_module_definitions.py +42 -0
- markwork/engine/_nested_bound.py +53 -0
- markwork/engine/_parse_init.py +52 -0
- markwork/engine/_pydocs.py +10 -0
- markwork/engine/_source_docs.py +620 -0
- markwork/engine/_title.py +26 -0
- markwork/engine/_token_html.py +58 -0
- markwork/engine/_top_level_def_lines.py +34 -0
- markwork/engine/_top_level_defs.py +33 -0
- markwork/etc/markwork.css +42 -0
- markwork-0.1.0.dev0.dist-info/METADATA +259 -0
- markwork-0.1.0.dev0.dist-info/RECORD +28 -0
- markwork-0.1.0.dev0.dist-info/WHEEL +5 -0
- markwork-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- markwork-0.1.0.dev0.dist-info/top_level.txt +1 -0
markwork/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""markwork is a Sphinx extension that renders a project's Python source
|
|
2
|
+
verbatim and makes every name a link to where it is defined: a project
|
|
3
|
+
symbol or a same-file name jumps to its definition, a standard-library
|
|
4
|
+
member or a builtin links to docs.python.org. It replaces autodoc, not
|
|
5
|
+
Sphinx.
|
|
6
|
+
|
|
7
|
+
A project enables it with a single line in conf.py:
|
|
8
|
+
|
|
9
|
+
extensions = ["markwork"]
|
|
10
|
+
|
|
11
|
+
The generator then runs at 'builder-inited', writing one page per source
|
|
12
|
+
file into the docs source tree before Sphinx reads it. Three configuration
|
|
13
|
+
values steer it: markwork_package names the package to document (or it is
|
|
14
|
+
auto-detected when a single package sits under the source root),
|
|
15
|
+
markwork_src_root and markwork_tests_root give the source and test roots
|
|
16
|
+
relative to the project, which is taken to be the parent of the docs
|
|
17
|
+
directory.
|
|
18
|
+
"""
|
|
19
|
+
# Apache-2.0 license
|
|
20
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import engine
|
|
25
|
+
from .engine import generate
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0-dev0"
|
|
28
|
+
|
|
29
|
+
__all__ = ["setup", "generate", "engine"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def setup(app: object) -> dict:
|
|
33
|
+
"""Register markwork as a Sphinx extension: declare its configuration
|
|
34
|
+
values, run the generator before Sphinx reads sources, and ship the
|
|
35
|
+
stylesheet that styles the go-to-definition anchors and the line-jump
|
|
36
|
+
highlight, so a project manages no CSS paths of its own."""
|
|
37
|
+
app.add_config_value("markwork_package", None, "env")
|
|
38
|
+
app.add_config_value("markwork_src_root", "src", "env")
|
|
39
|
+
app.add_config_value("markwork_tests_root", "tests", "env")
|
|
40
|
+
app.connect("builder-inited", generate)
|
|
41
|
+
static = str(Path(__file__).parent / "etc")
|
|
42
|
+
app.config.html_static_path.append(static)
|
|
43
|
+
app.add_css_file("markwork.css")
|
|
44
|
+
return {
|
|
45
|
+
"version": __version__,
|
|
46
|
+
"parallel_read_safe": False,
|
|
47
|
+
"parallel_write_safe": True,
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The engine behind markwork: it reads a project's source as text,
|
|
2
|
+
highlights it with Pygments, resolves every name to where it is defined,
|
|
3
|
+
and writes one navigable page per file.
|
|
4
|
+
|
|
5
|
+
The layer is built without 're', by design and as the tool's signature:
|
|
6
|
+
the AST, the Pygments token stream, and plain string methods do all the
|
|
7
|
+
work. Each leaf helper is a standalone function with no markwork state;
|
|
8
|
+
the stateful 'SourceDocs' class owns the registries and paths and drives
|
|
9
|
+
a whole run. Every symbol is re-exported here, so the engine is a public
|
|
10
|
+
subpackage: 'generate' is the one the 'setup(app)' entry point wires to
|
|
11
|
+
Sphinx, and the rest is open for reuse and is what markwork renders when
|
|
12
|
+
it documents itself.
|
|
13
|
+
"""
|
|
14
|
+
# Apache-2.0 license
|
|
15
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
16
|
+
|
|
17
|
+
# Constants
|
|
18
|
+
from ._pydocs import PYDOCS
|
|
19
|
+
# Pure rendering helpers
|
|
20
|
+
from ._title import title
|
|
21
|
+
from ._line_starts import lineStarts
|
|
22
|
+
from ._css_class import cssClass
|
|
23
|
+
from ._token_html import tokenHtml
|
|
24
|
+
from ._merge_tokens import mergeTokens
|
|
25
|
+
from ._highlight import highlightWithLinks
|
|
26
|
+
# Pure AST helpers
|
|
27
|
+
from ._top_level_defs import topLevelDefs
|
|
28
|
+
from ._top_level_def_lines import topLevelDefLines
|
|
29
|
+
from ._parse_init import parseInit
|
|
30
|
+
from ._module_definitions import moduleDefinitions
|
|
31
|
+
from ._nested_bound import nestedBound
|
|
32
|
+
from ._bound_anywhere import boundAnywhere
|
|
33
|
+
from ._add_alias_site import addAliasSite
|
|
34
|
+
from ._abs_module import absModule
|
|
35
|
+
# Standard-library and builtin link helpers
|
|
36
|
+
from ._is_stdlib import isStdlib
|
|
37
|
+
from ._builtin_links import buildBuiltinLinks, BUILTIN_LINKS
|
|
38
|
+
# Package resolution and the run driver
|
|
39
|
+
from ._detect_package import detectPackage
|
|
40
|
+
from ._source_docs import SourceDocs
|
|
41
|
+
from ._generate import generate
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"PYDOCS",
|
|
45
|
+
"title",
|
|
46
|
+
"lineStarts",
|
|
47
|
+
"cssClass",
|
|
48
|
+
"tokenHtml",
|
|
49
|
+
"mergeTokens",
|
|
50
|
+
"highlightWithLinks",
|
|
51
|
+
"topLevelDefs",
|
|
52
|
+
"topLevelDefLines",
|
|
53
|
+
"parseInit",
|
|
54
|
+
"moduleDefinitions",
|
|
55
|
+
"nestedBound",
|
|
56
|
+
"boundAnywhere",
|
|
57
|
+
"addAliasSite",
|
|
58
|
+
"absModule",
|
|
59
|
+
"isStdlib",
|
|
60
|
+
"buildBuiltinLinks",
|
|
61
|
+
"BUILTIN_LINKS",
|
|
62
|
+
"detectPackage",
|
|
63
|
+
"SourceDocs",
|
|
64
|
+
"generate",
|
|
65
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'absModule' function resolves an import target to an absolute dotted
|
|
3
|
+
module.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def absModule(srcRoot: Path, pyfile: Path, level: int, module) -> str:
|
|
12
|
+
"""Resolve an import target to an absolute dotted module.
|
|
13
|
+
|
|
14
|
+
Relative imports (level >= 1) are resolved against the file's package. A
|
|
15
|
+
relative import in a file outside the source root (for example a test)
|
|
16
|
+
cannot name a project symbol, so it resolves to the empty string.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
srcRoot : Path
|
|
21
|
+
The source root the package lives under.
|
|
22
|
+
pyfile : Path
|
|
23
|
+
The file containing the import.
|
|
24
|
+
level : int
|
|
25
|
+
The import's relative level: 0 for an absolute import.
|
|
26
|
+
module : str or None
|
|
27
|
+
The dotted module named by the import, if any.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
str
|
|
32
|
+
The absolute dotted module, or the empty string when it cannot be
|
|
33
|
+
resolved.
|
|
34
|
+
"""
|
|
35
|
+
if level == 0:
|
|
36
|
+
return module or ""
|
|
37
|
+
try:
|
|
38
|
+
pkgParts = pyfile.parent.relative_to(srcRoot).parts
|
|
39
|
+
except ValueError:
|
|
40
|
+
return ""
|
|
41
|
+
base = list(pkgParts[:len(pkgParts) - (level - 1)])
|
|
42
|
+
if module:
|
|
43
|
+
base += module.split(".")
|
|
44
|
+
return ".".join(base)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'addAliasSite' function records the source offset of an import alias'
|
|
3
|
+
name token.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def addAliasSite(
|
|
12
|
+
sites: list, alias: ast.alias, starts: list, local: str
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Record the source offset of an import alias' name token.
|
|
15
|
+
|
|
16
|
+
This makes the name as it appears in the 'import' line itself a link.
|
|
17
|
+
The position attributes on ast.alias are guaranteed from Python 3.10,
|
|
18
|
+
markwork's floor, so no presence guard is needed.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
sites : list
|
|
23
|
+
The list of '(offset, local)' pairs to append to.
|
|
24
|
+
alias : ast.alias
|
|
25
|
+
The import alias node.
|
|
26
|
+
starts : list of int
|
|
27
|
+
The per-line start offsets, as from 'lineStarts'.
|
|
28
|
+
local : str
|
|
29
|
+
The local name the alias binds.
|
|
30
|
+
"""
|
|
31
|
+
sites.append((starts[alias.lineno - 1] + alias.col_offset, local))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'boundAnywhere' function collects every name bound anywhere in a file.
|
|
3
|
+
"""
|
|
4
|
+
# Apache-2.0 license
|
|
5
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def boundAnywhere(tree: ast.AST) -> set:
|
|
11
|
+
"""Return every name bound anywhere in the file, at any scope.
|
|
12
|
+
|
|
13
|
+
This covers assignments and deletions, parameters, class and function
|
|
14
|
+
names, and import targets. A builtin whose name appears here is shadowed
|
|
15
|
+
by local code, so its uses are left unlinked.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
tree : ast.AST
|
|
20
|
+
The parsed module.
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
set of str
|
|
25
|
+
Every bound name found anywhere in the tree.
|
|
26
|
+
"""
|
|
27
|
+
names = set()
|
|
28
|
+
for node in ast.walk(tree):
|
|
29
|
+
if isinstance(node, ast.Name) and isinstance(
|
|
30
|
+
node.ctx, (ast.Store, ast.Del)
|
|
31
|
+
):
|
|
32
|
+
names.add(node.id)
|
|
33
|
+
elif isinstance(node, ast.arg):
|
|
34
|
+
names.add(node.arg)
|
|
35
|
+
elif isinstance(
|
|
36
|
+
node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
37
|
+
):
|
|
38
|
+
names.add(node.name)
|
|
39
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
40
|
+
for alias in node.names:
|
|
41
|
+
names.add(alias.asname or alias.name.split(".")[0])
|
|
42
|
+
return names
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'buildBuiltinLinks' function maps every public builtin to its page on
|
|
3
|
+
docs.python.org.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
import builtins
|
|
9
|
+
|
|
10
|
+
from ._pydocs import PYDOCS
|
|
11
|
+
|
|
12
|
+
_CONSTANTS = frozenset(
|
|
13
|
+
{"True", "False", "None", "NotImplemented", "Ellipsis"}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# The container types are listed on the built-in functions page under a
|
|
17
|
+
# 'func-<name>' anchor rather than a bare '<name>' (that bare anchor lives
|
|
18
|
+
# on the standard types page instead). Every other builtin function or
|
|
19
|
+
# type uses its plain '<name>' anchor there.
|
|
20
|
+
_FUNC_ANCHOR = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"bytearray", "bytes", "dict", "frozenset", "list", "memoryview",
|
|
23
|
+
"range", "set", "str", "tuple"
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Builtins added by the site module: documented in their own section of
|
|
28
|
+
# the constants page rather than on the functions page.
|
|
29
|
+
_SITE_CONSTANTS = frozenset(
|
|
30
|
+
{"copyright", "credits", "exit", "license", "quit"}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def buildBuiltinLinks() -> dict:
|
|
35
|
+
"""Map every public builtin name to its page on docs.python.org.
|
|
36
|
+
|
|
37
|
+
Exceptions go to the exceptions page; the True/None family and the
|
|
38
|
+
site-module builtins (quit, exit, ...) go to the constants page; the
|
|
39
|
+
container types go to their 'func-<name>' anchor on the built-in
|
|
40
|
+
functions page; and every other function or type goes to its plain
|
|
41
|
+
anchor there.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
dict
|
|
46
|
+
A mapping of builtin name to its documentation URL.
|
|
47
|
+
"""
|
|
48
|
+
links = {}
|
|
49
|
+
for name in dir(builtins):
|
|
50
|
+
if name.startswith("_"):
|
|
51
|
+
continue
|
|
52
|
+
if name in _CONSTANTS or name in _SITE_CONSTANTS:
|
|
53
|
+
links[name] = "%slibrary/constants.html#%s" % (PYDOCS, name)
|
|
54
|
+
continue
|
|
55
|
+
member = getattr(builtins, name)
|
|
56
|
+
if isinstance(member, type) and issubclass(member, BaseException):
|
|
57
|
+
links[name] = "%slibrary/exceptions.html#%s" % (PYDOCS, name)
|
|
58
|
+
elif name in _FUNC_ANCHOR:
|
|
59
|
+
links[name] = "%slibrary/functions.html#func-%s" % (PYDOCS, name)
|
|
60
|
+
else:
|
|
61
|
+
links[name] = "%slibrary/functions.html#%s" % (PYDOCS, name)
|
|
62
|
+
return links
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Computed once at import: the link table the resolver consults for every
|
|
66
|
+
# unshadowed builtin use.
|
|
67
|
+
BUILTIN_LINKS = buildBuiltinLinks()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'cssClass' function resolves a Pygments token type to its short CSS
|
|
3
|
+
class.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
from pygments.token import STANDARD_TYPES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cssClass(ttype: object) -> str:
|
|
12
|
+
"""Return the short Pygments CSS class for a token type.
|
|
13
|
+
|
|
14
|
+
When the exact type is not in the standard table, the lookup walks up to
|
|
15
|
+
a parent type. The root token maps to the empty string, meaning the
|
|
16
|
+
token gets no wrapping span.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
ttype : object
|
|
21
|
+
A Pygments token type.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
str
|
|
26
|
+
The CSS class, for example 'n', 'k' or 's2', or the empty string
|
|
27
|
+
for the root token.
|
|
28
|
+
"""
|
|
29
|
+
node = ttype
|
|
30
|
+
while node not in STANDARD_TYPES:
|
|
31
|
+
node = node.parent
|
|
32
|
+
return STANDARD_TYPES[node]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'detectPackage' function resolves the package directory to document.
|
|
3
|
+
"""
|
|
4
|
+
# Apache-2.0 license
|
|
5
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def detectPackage(srcRoot: Path, name) -> Path:
|
|
11
|
+
"""Resolve the package directory under the source root.
|
|
12
|
+
|
|
13
|
+
A configured name wins; otherwise the source root must hold exactly one
|
|
14
|
+
importable package and that one is used. Anything else is ambiguous and
|
|
15
|
+
raises, so a misconfigured project fails loudly rather than documenting
|
|
16
|
+
the wrong tree.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
srcRoot : Path
|
|
21
|
+
The source root to search.
|
|
22
|
+
name : str or None
|
|
23
|
+
The configured package name, or None to auto-detect.
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
Path
|
|
28
|
+
The resolved package directory.
|
|
29
|
+
|
|
30
|
+
Raises
|
|
31
|
+
------
|
|
32
|
+
RuntimeError
|
|
33
|
+
When no name is configured and the source root does not hold
|
|
34
|
+
exactly one importable package.
|
|
35
|
+
"""
|
|
36
|
+
if name is not None:
|
|
37
|
+
return srcRoot / name
|
|
38
|
+
candidates = sorted(
|
|
39
|
+
child for child in srcRoot.iterdir()
|
|
40
|
+
if child.is_dir() and (child / "__init__.py").exists()
|
|
41
|
+
)
|
|
42
|
+
if len(candidates) == 1:
|
|
43
|
+
return candidates[0]
|
|
44
|
+
info = ("markwork could not pick a package under %s: set "
|
|
45
|
+
"markwork_package in conf.py. Found: %s")
|
|
46
|
+
names = ", ".join(child.name for child in candidates) or "none"
|
|
47
|
+
raise RuntimeError(info % (srcRoot, names))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'generate' function runs a documentation pass, resolving its paths
|
|
3
|
+
from the Sphinx application.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ._detect_package import detectPackage
|
|
11
|
+
from ._source_docs import SourceDocs
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate(app: object) -> None:
|
|
15
|
+
"""Resolve the build paths from the Sphinx application and run a
|
|
16
|
+
documentation pass.
|
|
17
|
+
|
|
18
|
+
Wired to 'builder-inited' by the extension's setup(app). The project
|
|
19
|
+
root is the parent of the docs directory; the source and tests roots and
|
|
20
|
+
the package come from the markwork_* configuration values.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
app : object
|
|
25
|
+
The Sphinx application. Its 'confdir', 'srcdir' and 'config' are
|
|
26
|
+
read.
|
|
27
|
+
"""
|
|
28
|
+
confdir = Path(app.confdir)
|
|
29
|
+
repoRoot = confdir.parent
|
|
30
|
+
config = app.config
|
|
31
|
+
srcRoot = repoRoot / config.markwork_src_root
|
|
32
|
+
testsRoot = repoRoot / config.markwork_tests_root
|
|
33
|
+
pkg = detectPackage(srcRoot, config.markwork_package)
|
|
34
|
+
out = Path(app.srcdir) / "_source"
|
|
35
|
+
SourceDocs(repoRoot, srcRoot, pkg, testsRoot, out).run()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'highlightWithLinks' function renders source to highlighted HTML with
|
|
3
|
+
go-to-definition anchors injected.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
import bisect
|
|
9
|
+
|
|
10
|
+
from pygments.lexers import PythonLexer
|
|
11
|
+
|
|
12
|
+
from ._css_class import cssClass
|
|
13
|
+
from ._line_starts import lineStarts
|
|
14
|
+
from ._merge_tokens import mergeTokens
|
|
15
|
+
from ._token_html import tokenHtml
|
|
16
|
+
|
|
17
|
+
_LEXER = PythonLexer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def highlightWithLinks(source: str, linkMap: dict) -> str:
|
|
21
|
+
"""Render source to the highlighted HTML the Pygments HtmlFormatter
|
|
22
|
+
produces, wrapping linked tokens in go-to-definition anchors.
|
|
23
|
+
|
|
24
|
+
The output is a '.highlight' block with inline line numbers, each line
|
|
25
|
+
wrapped in '<span id="line-N">' as the '#line-N' jump target and the
|
|
26
|
+
':target' highlight element. Every token whose start offset is a key of
|
|
27
|
+
'linkMap' is wrapped in an anchor. The token stream from
|
|
28
|
+
'get_tokens_unprocessed' carries each token's absolute offset, so the
|
|
29
|
+
anchors land exactly without searching the text. A token holding
|
|
30
|
+
newlines (a string or a run of blank lines) spans several line boxes and
|
|
31
|
+
is never a link target, so it is split across the lines it covers.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
source : str
|
|
36
|
+
The source text.
|
|
37
|
+
linkMap : dict
|
|
38
|
+
A mapping of absolute source offset to link target.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
str
|
|
43
|
+
The highlighted HTML fragment.
|
|
44
|
+
"""
|
|
45
|
+
starts = lineStarts(source)
|
|
46
|
+
count = len(starts) - 1 if source.endswith("\n") else len(starts)
|
|
47
|
+
buffers = [""] * (count + 1)
|
|
48
|
+
for offset, ttype, value in mergeTokens(
|
|
49
|
+
_LEXER.get_tokens_unprocessed(source)
|
|
50
|
+
):
|
|
51
|
+
css = cssClass(ttype)
|
|
52
|
+
if "\n" in value:
|
|
53
|
+
first = bisect.bisect_right(starts, offset)
|
|
54
|
+
for step, segment in enumerate(value.split("\n")):
|
|
55
|
+
line = first + step
|
|
56
|
+
if segment and line <= count:
|
|
57
|
+
buffers[line] += tokenHtml(css, segment, "")
|
|
58
|
+
continue
|
|
59
|
+
line = bisect.bisect_right(starts, offset)
|
|
60
|
+
buffers[line] += tokenHtml(css, value, linkMap.get(offset))
|
|
61
|
+
# Line numbers are right-aligned to the width of the largest, matching
|
|
62
|
+
# the Pygments HtmlFormatter so the gutter stays straight.
|
|
63
|
+
width = len(str(count))
|
|
64
|
+
parts = ['<div class="highlight"><pre><span></span>']
|
|
65
|
+
for line in range(1, count + 1):
|
|
66
|
+
parts.append(
|
|
67
|
+
'<span id="line-%d"><span class="linenos">%s</span>%s\n'
|
|
68
|
+
'</span>' % (line, str(line).rjust(width), buffers[line])
|
|
69
|
+
)
|
|
70
|
+
parts.append("</pre></div>\n")
|
|
71
|
+
return "".join(parts)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'isStdlib' function tests whether a module belongs to the standard
|
|
3
|
+
library.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
_STDLIB = sys.stdlib_module_names
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def isStdlib(module: str) -> bool:
|
|
14
|
+
"""Return whether a module belongs to the standard library.
|
|
15
|
+
|
|
16
|
+
Membership is judged by the top-level package, so a submodule such as
|
|
17
|
+
'collections.abc' resolves too.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
module : str
|
|
22
|
+
A dotted module name, or the empty string.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
bool
|
|
27
|
+
True if the top-level package is a standard-library module.
|
|
28
|
+
|
|
29
|
+
Examples
|
|
30
|
+
--------
|
|
31
|
+
>>> isStdlib('collections.abc')
|
|
32
|
+
True
|
|
33
|
+
>>> isStdlib('')
|
|
34
|
+
False
|
|
35
|
+
"""
|
|
36
|
+
return True if module and module.split(".")[0] in _STDLIB else False
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'lineStarts' function indexes where each source line begins.
|
|
3
|
+
"""
|
|
4
|
+
# Apache-2.0 license
|
|
5
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def lineStarts(source: str) -> list:
|
|
9
|
+
"""Return the character offset at which each line begins.
|
|
10
|
+
|
|
11
|
+
An AST node's '(lineno, col_offset)' is turned into an absolute offset
|
|
12
|
+
into the source as 'starts[lineno - 1] + col_offset', so the rendered
|
|
13
|
+
anchors land exactly without searching the text.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
source : str
|
|
18
|
+
The source text.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
list of int
|
|
23
|
+
The offset of the first character of each line. The first entry is
|
|
24
|
+
always 0.
|
|
25
|
+
|
|
26
|
+
Examples
|
|
27
|
+
--------
|
|
28
|
+
>>> lineStarts('ab\\ncd\\n')
|
|
29
|
+
[0, 3, 6]
|
|
30
|
+
"""
|
|
31
|
+
starts = [0]
|
|
32
|
+
for index, char in enumerate(source):
|
|
33
|
+
if char == "\n":
|
|
34
|
+
starts.append(index + 1)
|
|
35
|
+
return starts
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'mergeTokens' function coalesces consecutive tokens of identical type.
|
|
3
|
+
"""
|
|
4
|
+
# Apache-2.0 license
|
|
5
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mergeTokens(stream) -> list:
|
|
9
|
+
"""Merge consecutive tokens of identical type into one.
|
|
10
|
+
|
|
11
|
+
This is what Pygments' high-level 'get_tokens' does, so the rendered
|
|
12
|
+
spans group exactly as the stock HtmlFormatter groups them and the
|
|
13
|
+
output stays byte-identical once the anchors are stripped. The first
|
|
14
|
+
token's offset is kept, which is also where any go-to-definition link
|
|
15
|
+
for the run is anchored, since a link target always sits at a run's
|
|
16
|
+
start.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
stream : iterable of tuple
|
|
21
|
+
Tuples of '(offset, ttype, value)', as produced by
|
|
22
|
+
'get_tokens_unprocessed'.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
list of tuple
|
|
27
|
+
The merged tuples, in order.
|
|
28
|
+
"""
|
|
29
|
+
merged = []
|
|
30
|
+
for offset, ttype, value in stream:
|
|
31
|
+
if merged and merged[-1][1] == ttype:
|
|
32
|
+
prevOff, prevType, prevVal = merged[-1]
|
|
33
|
+
merged[-1] = (prevOff, prevType, prevVal + value)
|
|
34
|
+
else:
|
|
35
|
+
merged.append((offset, ttype, value))
|
|
36
|
+
return merged
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'moduleDefinitions' function maps module-scope names to their first
|
|
3
|
+
definition line.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def moduleDefinitions(tree: ast.AST) -> dict:
|
|
12
|
+
"""Return each module-scope name mapped to the line it is defined on.
|
|
13
|
+
|
|
14
|
+
Classes, functions, simple assignments and annotated assignments with a
|
|
15
|
+
value are recorded; the first definition of a name wins. This is the
|
|
16
|
+
go-to-definition target for a later use of that name within the same
|
|
17
|
+
file.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
tree : ast.AST
|
|
22
|
+
The parsed module.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
dict
|
|
27
|
+
A mapping of name to its first definition line.
|
|
28
|
+
"""
|
|
29
|
+
defs = {}
|
|
30
|
+
for node in tree.body:
|
|
31
|
+
if isinstance(
|
|
32
|
+
node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
33
|
+
):
|
|
34
|
+
defs.setdefault(node.name, node.lineno)
|
|
35
|
+
elif isinstance(node, ast.Assign):
|
|
36
|
+
for target in node.targets:
|
|
37
|
+
if isinstance(target, ast.Name):
|
|
38
|
+
defs.setdefault(target.id, node.lineno)
|
|
39
|
+
elif isinstance(node, ast.AnnAssign):
|
|
40
|
+
if isinstance(node.target, ast.Name) and node.value is not None:
|
|
41
|
+
defs.setdefault(node.target.id, node.lineno)
|
|
42
|
+
return defs
|