zensical-code-references 0.1.0__tar.gz
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.
- zensical_code_references-0.1.0/LICENSE +21 -0
- zensical_code_references-0.1.0/PKG-INFO +145 -0
- zensical_code_references-0.1.0/README.md +117 -0
- zensical_code_references-0.1.0/pyproject.toml +58 -0
- zensical_code_references-0.1.0/src/zensical_code_references/__init__.py +7 -0
- zensical_code_references-0.1.0/src/zensical_code_references/py.typed +0 -0
- zensical_code_references-0.1.0/src/zensical_code_references/symbolic_snippets.py +365 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jakob Guldberg Aaes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zensical-code-references
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Symbol-aware snippet references for zensical and pymdownx.snippets
|
|
5
|
+
Keywords: markdown,documentation,snippets,python,zensical
|
|
6
|
+
Author: Jakob Guldberg Aaes
|
|
7
|
+
Author-email: Jakob Guldberg Aaes <jakob1379@gmali.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Documentation
|
|
17
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: markdown>=3.10
|
|
20
|
+
Requires-Dist: pymdown-extensions>=10.17.1
|
|
21
|
+
Requires-Dist: zensical>=0.0.24
|
|
22
|
+
Requires-Python: >=3.13
|
|
23
|
+
Project-URL: Homepage, https://github.com/jakob1379/zensical-code-references
|
|
24
|
+
Project-URL: Repository, https://github.com/jakob1379/zensical-code-references
|
|
25
|
+
Project-URL: Issues, https://github.com/jakob1379/zensical-code-references/issues
|
|
26
|
+
Project-URL: Documentation, https://zensical.org/
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# zensical-code-references
|
|
30
|
+
|
|
31
|
+
PoC extension for Zensical that makes `pymdownx.snippets` symbol-aware.
|
|
32
|
+
|
|
33
|
+
## Why this exists
|
|
34
|
+
|
|
35
|
+
Raw line ranges are brittle. If code moves, references like `file.py:88:121` rot.
|
|
36
|
+
|
|
37
|
+
This extension allows snippet references by Python symbol and resolves them to
|
|
38
|
+
real line spans at build time using AST.
|
|
39
|
+
|
|
40
|
+
## Symbol reference format
|
|
41
|
+
|
|
42
|
+
`<module.path>:<symbol>(.<nested>)[:start[:end]]`
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
|
|
46
|
+
- `my_pkg.api:build_payload`
|
|
47
|
+
- `my_pkg.api:Client.send`
|
|
48
|
+
- `my_pkg.config:DEFAULT_TIMEOUT:-1:2`
|
|
49
|
+
|
|
50
|
+
Resolved output is rewritten to standard snippets format:
|
|
51
|
+
|
|
52
|
+
`path/to/file.py:start:end`
|
|
53
|
+
|
|
54
|
+
## Selector behavior
|
|
55
|
+
|
|
56
|
+
- No selector: full symbol span.
|
|
57
|
+
- Method references without selectors include class header + method body (not sibling methods like `__init__`).
|
|
58
|
+
- `:start`: from relative line `start` to symbol end.
|
|
59
|
+
- `:start:end`: relative span from symbol start.
|
|
60
|
+
- Positive values are 1-based (`1` is first line of symbol).
|
|
61
|
+
- `0` and negatives are offsets (`0` is symbol start, `-1` is one line above).
|
|
62
|
+
|
|
63
|
+
## Zensical configuration (`zensical.toml`)
|
|
64
|
+
|
|
65
|
+
```toml
|
|
66
|
+
[project.markdown_extensions.zensical_symbolic_snippets]
|
|
67
|
+
module_roots = ["src"]
|
|
68
|
+
fail_on_unresolved = true
|
|
69
|
+
|
|
70
|
+
[project.markdown_extensions.pymdownx.highlight]
|
|
71
|
+
anchor_linenums = true
|
|
72
|
+
line_spans = "__span"
|
|
73
|
+
pygments_lang_class = true
|
|
74
|
+
|
|
75
|
+
[project.markdown_extensions.pymdownx.snippets]
|
|
76
|
+
base_path = ["src"]
|
|
77
|
+
check_paths = true
|
|
78
|
+
|
|
79
|
+
[project.markdown_extensions.pymdownx.superfences]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If you define `project.markdown_extensions` explicitly, include all extensions
|
|
83
|
+
you rely on. Leaving out `pymdownx.superfences`/`pymdownx.highlight` causes
|
|
84
|
+
fenced blocks to render as plain text.
|
|
85
|
+
|
|
86
|
+
Markdown usage stays standard:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
--8<-- "my_pkg.api:Client.send"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Included proof project
|
|
93
|
+
|
|
94
|
+
This repo includes a working Zensical example that references this package's
|
|
95
|
+
own source to prove behavior:
|
|
96
|
+
|
|
97
|
+
- Config: `examples/zensical/zensical.toml`
|
|
98
|
+
- Docs page: `examples/zensical/docs/index.md`
|
|
99
|
+
|
|
100
|
+
Build it:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
uv run zensical build --config-file examples/zensical/zensical.toml
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Tiny output example (from `examples/zensical/site/index.html`):
|
|
107
|
+
|
|
108
|
+
```py
|
|
109
|
+
def parse_symbolic_reference(value: str) -> SymbolicReference | None:
|
|
110
|
+
if ":" not in value:
|
|
111
|
+
return None
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Tests
|
|
115
|
+
|
|
116
|
+
Run:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv run pytest
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The suite includes parser/resolver tests plus an E2E Zensical build test that
|
|
123
|
+
asserts resolved symbols are rendered in generated HTML.
|
|
124
|
+
|
|
125
|
+
## Release automation
|
|
126
|
+
|
|
127
|
+
GitHub Actions is configured to publish to PyPI on strict semver tags:
|
|
128
|
+
|
|
129
|
+
- CI workflow: `.github/workflows/ci.yml`
|
|
130
|
+
- Release workflow: `.github/workflows/release.yml`
|
|
131
|
+
- Trigger: push tag matching `vX.Y.Z`
|
|
132
|
+
- Guardrails: tag must be strict semver and must match `project.version` in `pyproject.toml`
|
|
133
|
+
- Gate: test matrix (`3.13`, `3.14`) must pass before publish
|
|
134
|
+
|
|
135
|
+
One-time GitHub setup for trusted publishing:
|
|
136
|
+
|
|
137
|
+
1. Create environment `pypi` in repository settings.
|
|
138
|
+
2. Configure PyPI Trusted Publisher for this repository/workflow.
|
|
139
|
+
|
|
140
|
+
Release command:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
git tag v0.1.0
|
|
144
|
+
git push upstream v0.1.0
|
|
145
|
+
```
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# zensical-code-references
|
|
2
|
+
|
|
3
|
+
PoC extension for Zensical that makes `pymdownx.snippets` symbol-aware.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
Raw line ranges are brittle. If code moves, references like `file.py:88:121` rot.
|
|
8
|
+
|
|
9
|
+
This extension allows snippet references by Python symbol and resolves them to
|
|
10
|
+
real line spans at build time using AST.
|
|
11
|
+
|
|
12
|
+
## Symbol reference format
|
|
13
|
+
|
|
14
|
+
`<module.path>:<symbol>(.<nested>)[:start[:end]]`
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
|
|
18
|
+
- `my_pkg.api:build_payload`
|
|
19
|
+
- `my_pkg.api:Client.send`
|
|
20
|
+
- `my_pkg.config:DEFAULT_TIMEOUT:-1:2`
|
|
21
|
+
|
|
22
|
+
Resolved output is rewritten to standard snippets format:
|
|
23
|
+
|
|
24
|
+
`path/to/file.py:start:end`
|
|
25
|
+
|
|
26
|
+
## Selector behavior
|
|
27
|
+
|
|
28
|
+
- No selector: full symbol span.
|
|
29
|
+
- Method references without selectors include class header + method body (not sibling methods like `__init__`).
|
|
30
|
+
- `:start`: from relative line `start` to symbol end.
|
|
31
|
+
- `:start:end`: relative span from symbol start.
|
|
32
|
+
- Positive values are 1-based (`1` is first line of symbol).
|
|
33
|
+
- `0` and negatives are offsets (`0` is symbol start, `-1` is one line above).
|
|
34
|
+
|
|
35
|
+
## Zensical configuration (`zensical.toml`)
|
|
36
|
+
|
|
37
|
+
```toml
|
|
38
|
+
[project.markdown_extensions.zensical_symbolic_snippets]
|
|
39
|
+
module_roots = ["src"]
|
|
40
|
+
fail_on_unresolved = true
|
|
41
|
+
|
|
42
|
+
[project.markdown_extensions.pymdownx.highlight]
|
|
43
|
+
anchor_linenums = true
|
|
44
|
+
line_spans = "__span"
|
|
45
|
+
pygments_lang_class = true
|
|
46
|
+
|
|
47
|
+
[project.markdown_extensions.pymdownx.snippets]
|
|
48
|
+
base_path = ["src"]
|
|
49
|
+
check_paths = true
|
|
50
|
+
|
|
51
|
+
[project.markdown_extensions.pymdownx.superfences]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If you define `project.markdown_extensions` explicitly, include all extensions
|
|
55
|
+
you rely on. Leaving out `pymdownx.superfences`/`pymdownx.highlight` causes
|
|
56
|
+
fenced blocks to render as plain text.
|
|
57
|
+
|
|
58
|
+
Markdown usage stays standard:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
--8<-- "my_pkg.api:Client.send"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Included proof project
|
|
65
|
+
|
|
66
|
+
This repo includes a working Zensical example that references this package's
|
|
67
|
+
own source to prove behavior:
|
|
68
|
+
|
|
69
|
+
- Config: `examples/zensical/zensical.toml`
|
|
70
|
+
- Docs page: `examples/zensical/docs/index.md`
|
|
71
|
+
|
|
72
|
+
Build it:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv run zensical build --config-file examples/zensical/zensical.toml
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tiny output example (from `examples/zensical/site/index.html`):
|
|
79
|
+
|
|
80
|
+
```py
|
|
81
|
+
def parse_symbolic_reference(value: str) -> SymbolicReference | None:
|
|
82
|
+
if ":" not in value:
|
|
83
|
+
return None
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Tests
|
|
87
|
+
|
|
88
|
+
Run:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv run pytest
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The suite includes parser/resolver tests plus an E2E Zensical build test that
|
|
95
|
+
asserts resolved symbols are rendered in generated HTML.
|
|
96
|
+
|
|
97
|
+
## Release automation
|
|
98
|
+
|
|
99
|
+
GitHub Actions is configured to publish to PyPI on strict semver tags:
|
|
100
|
+
|
|
101
|
+
- CI workflow: `.github/workflows/ci.yml`
|
|
102
|
+
- Release workflow: `.github/workflows/release.yml`
|
|
103
|
+
- Trigger: push tag matching `vX.Y.Z`
|
|
104
|
+
- Guardrails: tag must be strict semver and must match `project.version` in `pyproject.toml`
|
|
105
|
+
- Gate: test matrix (`3.13`, `3.14`) must pass before publish
|
|
106
|
+
|
|
107
|
+
One-time GitHub setup for trusted publishing:
|
|
108
|
+
|
|
109
|
+
1. Create environment `pypi` in repository settings.
|
|
110
|
+
2. Configure PyPI Trusted Publisher for this repository/workflow.
|
|
111
|
+
|
|
112
|
+
Release command:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
git tag v0.1.0
|
|
116
|
+
git push upstream v0.1.0
|
|
117
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zensical-code-references"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Symbol-aware snippet references for zensical and pymdownx.snippets"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Jakob Guldberg Aaes", email = "jakob1379@gmali.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
keywords = ["markdown", "documentation", "snippets", "python", "zensical"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Documentation",
|
|
21
|
+
"Topic :: Software Development :: Documentation",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"markdown>=3.10",
|
|
26
|
+
"pymdown-extensions>=10.17.1",
|
|
27
|
+
"zensical>=0.0.24",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/jakob1379/zensical-code-references"
|
|
32
|
+
Repository = "https://github.com/jakob1379/zensical-code-references"
|
|
33
|
+
Issues = "https://github.com/jakob1379/zensical-code-references/issues"
|
|
34
|
+
Documentation = "https://zensical.org/"
|
|
35
|
+
|
|
36
|
+
[project.entry-points."markdown.extensions"]
|
|
37
|
+
zensical_symbolic_snippets = "zensical_code_references.symbolic_snippets:makeExtension"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8.4.2",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["uv_build>=0.10.6,<0.11.0"]
|
|
46
|
+
build-backend = "uv_build"
|
|
47
|
+
|
|
48
|
+
[tool.pytest]
|
|
49
|
+
pythonpath = ["src"]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.skylos.whitelist.documented]
|
|
53
|
+
parse_symbolic_reference = "Public API for symbolic reference parsing; used via extension flow and tests"
|
|
54
|
+
makeExtension = "Python-Markdown extension entry point discovered dynamically via entry points"
|
|
55
|
+
run = "Python-Markdown preprocessor hook method invoked by framework runtime"
|
|
56
|
+
_transform_single_line = "Internal preprocessor flow method invoked by run"
|
|
57
|
+
_transform_block_line = "Internal preprocessor flow method invoked by run"
|
|
58
|
+
_resolve_target = "Internal preprocessor flow method invoked by transform helpers"
|
|
File without changes
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from markdown.extensions import Extension
|
|
9
|
+
from markdown.preprocessors import Preprocessor
|
|
10
|
+
|
|
11
|
+
_SINGLE_SNIPPET_RE = re.compile(
|
|
12
|
+
r'^(?P<prefix>\s*-+8<-+\s+")(?P<target>[^"]+)(?P<suffix>"\s*)$'
|
|
13
|
+
)
|
|
14
|
+
_BLOCK_FENCE_RE = re.compile(r"^\s*-+8<-+\s*$")
|
|
15
|
+
_SEGMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SymbolicSnippetError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SymbolicReference:
|
|
24
|
+
module: str
|
|
25
|
+
symbol_parts: tuple[str, ...]
|
|
26
|
+
selector_count: int
|
|
27
|
+
start: int | None
|
|
28
|
+
end: int | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ResolvedReference:
|
|
33
|
+
snippet_path: str
|
|
34
|
+
start_line: int
|
|
35
|
+
end_line: int
|
|
36
|
+
line_selector: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_symbolic_reference(value: str) -> SymbolicReference | None:
|
|
40
|
+
if ":" not in value:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
module, remainder = value.split(":", 1)
|
|
44
|
+
if not _is_dotted_name(module):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
parts = remainder.split(":")
|
|
48
|
+
if not parts:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
symbol = parts[0]
|
|
52
|
+
if not _is_dotted_name(symbol):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
selector_count = len(parts) - 1
|
|
56
|
+
if selector_count > 2:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
start: int | None = None
|
|
60
|
+
end: int | None = None
|
|
61
|
+
if selector_count >= 1:
|
|
62
|
+
start = _parse_selector(parts[1], value)
|
|
63
|
+
if selector_count == 2:
|
|
64
|
+
end = _parse_selector(parts[2], value)
|
|
65
|
+
|
|
66
|
+
return SymbolicReference(
|
|
67
|
+
module=module,
|
|
68
|
+
symbol_parts=tuple(symbol.split(".")),
|
|
69
|
+
selector_count=selector_count,
|
|
70
|
+
start=start,
|
|
71
|
+
end=end,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_dotted_name(value: str) -> bool:
|
|
76
|
+
return bool(value) and all(_SEGMENT_RE.match(part) for part in value.split("."))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_selector(value: str, raw: str) -> int | None:
|
|
80
|
+
if value == "":
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return int(value)
|
|
84
|
+
except ValueError as error:
|
|
85
|
+
raise SymbolicSnippetError(
|
|
86
|
+
f"Invalid selector in symbolic snippet '{raw}'"
|
|
87
|
+
) from error
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SymbolResolver:
|
|
91
|
+
def __init__(self, module_roots: list[str], encoding: str = "utf-8") -> None:
|
|
92
|
+
if not module_roots:
|
|
93
|
+
raise SymbolicSnippetError("module_roots must contain at least one path")
|
|
94
|
+
self._roots = [Path(root).resolve() for root in module_roots]
|
|
95
|
+
self._encoding = encoding
|
|
96
|
+
self._ast_cache: dict[Path, tuple[ast.Module, int]] = {}
|
|
97
|
+
|
|
98
|
+
def resolve(self, reference: SymbolicReference) -> ResolvedReference:
|
|
99
|
+
module_root, module_file = self._resolve_module_path(reference.module)
|
|
100
|
+
tree, line_count = self._load_ast(module_file)
|
|
101
|
+
symbol_path = self._find_symbol_path_in_body(
|
|
102
|
+
tree.body, reference.symbol_parts, reference
|
|
103
|
+
)
|
|
104
|
+
symbol_node = symbol_path[-1]
|
|
105
|
+
symbol_start, symbol_end = self._get_symbol_bounds(symbol_node)
|
|
106
|
+
start_line, end_line = self._select_line_span(
|
|
107
|
+
reference,
|
|
108
|
+
symbol_start,
|
|
109
|
+
symbol_end,
|
|
110
|
+
line_count,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
line_selector = f"{start_line}:{end_line}"
|
|
114
|
+
if (
|
|
115
|
+
reference.selector_count == 0
|
|
116
|
+
and len(symbol_path) >= 2
|
|
117
|
+
and isinstance(symbol_path[-2], ast.ClassDef)
|
|
118
|
+
and isinstance(symbol_node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
119
|
+
):
|
|
120
|
+
class_node = symbol_path[-2]
|
|
121
|
+
class_start = class_node.lineno
|
|
122
|
+
if class_node.decorator_list:
|
|
123
|
+
class_start = min(
|
|
124
|
+
class_start,
|
|
125
|
+
*(decorator.lineno for decorator in class_node.decorator_list),
|
|
126
|
+
)
|
|
127
|
+
class_header_selector = f"{class_start}:{class_node.lineno}"
|
|
128
|
+
method_selector = f"{symbol_start}:{symbol_end}"
|
|
129
|
+
line_selector = f"{class_header_selector},{method_selector}"
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
snippet_path = module_file.relative_to(module_root).as_posix()
|
|
133
|
+
except ValueError:
|
|
134
|
+
snippet_path = module_file.as_posix()
|
|
135
|
+
|
|
136
|
+
return ResolvedReference(
|
|
137
|
+
snippet_path=snippet_path,
|
|
138
|
+
start_line=start_line,
|
|
139
|
+
end_line=end_line,
|
|
140
|
+
line_selector=line_selector,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _resolve_module_path(self, module: str) -> tuple[Path, Path]:
|
|
144
|
+
module_path = Path(*module.split("."))
|
|
145
|
+
for root in self._roots:
|
|
146
|
+
file_candidate = (root / module_path).with_suffix(".py")
|
|
147
|
+
if file_candidate.is_file():
|
|
148
|
+
return root, file_candidate
|
|
149
|
+
|
|
150
|
+
package_candidate = root / module_path / "__init__.py"
|
|
151
|
+
if package_candidate.is_file():
|
|
152
|
+
return root, package_candidate
|
|
153
|
+
|
|
154
|
+
raise SymbolicSnippetError(
|
|
155
|
+
f"Could not resolve module '{module}' from module_roots"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _load_ast(self, module_file: Path) -> tuple[ast.Module, int]:
|
|
159
|
+
cached = self._ast_cache.get(module_file)
|
|
160
|
+
if cached is not None:
|
|
161
|
+
return cached
|
|
162
|
+
|
|
163
|
+
source = module_file.read_text(encoding=self._encoding)
|
|
164
|
+
tree = ast.parse(source, filename=module_file.as_posix())
|
|
165
|
+
line_count = len(source.splitlines())
|
|
166
|
+
self._ast_cache[module_file] = (tree, line_count)
|
|
167
|
+
return tree, line_count
|
|
168
|
+
|
|
169
|
+
def _find_symbol_path_in_body(
|
|
170
|
+
self,
|
|
171
|
+
body: list[ast.stmt],
|
|
172
|
+
symbol_parts: tuple[str, ...],
|
|
173
|
+
reference: SymbolicReference,
|
|
174
|
+
) -> tuple[ast.stmt, ...]:
|
|
175
|
+
symbol_name = symbol_parts[0]
|
|
176
|
+
for node in body:
|
|
177
|
+
if not self._node_matches_symbol(node, symbol_name):
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if len(symbol_parts) == 1:
|
|
181
|
+
return (node,)
|
|
182
|
+
|
|
183
|
+
nested_parts = symbol_parts[1:]
|
|
184
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
185
|
+
nested_path = self._find_symbol_path_in_body(
|
|
186
|
+
node.body, nested_parts, reference
|
|
187
|
+
)
|
|
188
|
+
return (node, *nested_path)
|
|
189
|
+
|
|
190
|
+
raise SymbolicSnippetError(
|
|
191
|
+
f"Symbol path '{'.'.join(reference.symbol_parts)}' in module '{reference.module}' is invalid"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
raise SymbolicSnippetError(
|
|
195
|
+
f"Could not resolve symbol '{'.'.join(reference.symbol_parts)}' in module '{reference.module}'"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _node_matches_symbol(self, node: ast.stmt, symbol_name: str) -> bool:
|
|
199
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
200
|
+
return node.name == symbol_name
|
|
201
|
+
if isinstance(node, ast.Assign):
|
|
202
|
+
for target in node.targets:
|
|
203
|
+
if isinstance(target, ast.Name) and target.id == symbol_name:
|
|
204
|
+
return True
|
|
205
|
+
return False
|
|
206
|
+
if isinstance(node, ast.AnnAssign):
|
|
207
|
+
return isinstance(node.target, ast.Name) and node.target.id == symbol_name
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
def _get_symbol_bounds(self, node: ast.stmt) -> tuple[int, int]:
|
|
211
|
+
start = node.lineno
|
|
212
|
+
if (
|
|
213
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
214
|
+
and node.decorator_list
|
|
215
|
+
):
|
|
216
|
+
start = min(start, *(decorator.lineno for decorator in node.decorator_list))
|
|
217
|
+
|
|
218
|
+
end = getattr(node, "end_lineno", None)
|
|
219
|
+
if end is None:
|
|
220
|
+
end = node.lineno
|
|
221
|
+
return start, end
|
|
222
|
+
|
|
223
|
+
def _select_line_span(
|
|
224
|
+
self,
|
|
225
|
+
reference: SymbolicReference,
|
|
226
|
+
symbol_start: int,
|
|
227
|
+
symbol_end: int,
|
|
228
|
+
line_count: int,
|
|
229
|
+
) -> tuple[int, int]:
|
|
230
|
+
if line_count <= 0:
|
|
231
|
+
raise SymbolicSnippetError("Resolved module file is empty")
|
|
232
|
+
|
|
233
|
+
if reference.selector_count == 0:
|
|
234
|
+
start = symbol_start
|
|
235
|
+
end = symbol_end
|
|
236
|
+
elif reference.selector_count == 1:
|
|
237
|
+
start = self._selector_to_line(symbol_start, reference.start)
|
|
238
|
+
end = symbol_end
|
|
239
|
+
else:
|
|
240
|
+
start = (
|
|
241
|
+
symbol_start
|
|
242
|
+
if reference.start is None
|
|
243
|
+
else self._selector_to_line(symbol_start, reference.start)
|
|
244
|
+
)
|
|
245
|
+
end = (
|
|
246
|
+
symbol_end
|
|
247
|
+
if reference.end is None
|
|
248
|
+
else self._selector_to_line(symbol_start, reference.end)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
start = max(1, min(line_count, start))
|
|
252
|
+
end = max(1, min(line_count, end))
|
|
253
|
+
if end < start:
|
|
254
|
+
raise SymbolicSnippetError(
|
|
255
|
+
"Snippet selector resolved to an invalid line span"
|
|
256
|
+
)
|
|
257
|
+
return start, end
|
|
258
|
+
|
|
259
|
+
def _selector_to_line(self, symbol_start: int, value: int | None) -> int:
|
|
260
|
+
if value is None:
|
|
261
|
+
return symbol_start
|
|
262
|
+
if value > 0:
|
|
263
|
+
return symbol_start + value - 1
|
|
264
|
+
return symbol_start + value
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class SymbolicSnippetPreprocessor(Preprocessor):
|
|
268
|
+
def __init__(self, md, resolver: SymbolResolver, fail_on_unresolved: bool) -> None:
|
|
269
|
+
super().__init__(md)
|
|
270
|
+
self._resolver = resolver
|
|
271
|
+
self._fail_on_unresolved = fail_on_unresolved
|
|
272
|
+
|
|
273
|
+
def run(self, lines: list[str]) -> list[str]:
|
|
274
|
+
output: list[str] = []
|
|
275
|
+
in_block = False
|
|
276
|
+
|
|
277
|
+
for line in lines:
|
|
278
|
+
if _BLOCK_FENCE_RE.match(line):
|
|
279
|
+
in_block = not in_block
|
|
280
|
+
output.append(line)
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if in_block:
|
|
284
|
+
output.append(self._transform_block_line(line))
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
output.append(self._transform_single_line(line))
|
|
288
|
+
|
|
289
|
+
return output
|
|
290
|
+
|
|
291
|
+
def _transform_single_line(self, line: str) -> str:
|
|
292
|
+
match = _SINGLE_SNIPPET_RE.match(line)
|
|
293
|
+
if not match:
|
|
294
|
+
return line
|
|
295
|
+
|
|
296
|
+
target = match.group("target")
|
|
297
|
+
resolved = self._resolve_target(target)
|
|
298
|
+
if resolved is None:
|
|
299
|
+
return line
|
|
300
|
+
|
|
301
|
+
return f"{match.group('prefix')}{resolved}{match.group('suffix')}"
|
|
302
|
+
|
|
303
|
+
def _transform_block_line(self, line: str) -> str:
|
|
304
|
+
stripped = line.strip()
|
|
305
|
+
if not stripped or stripped.startswith(";"):
|
|
306
|
+
return line
|
|
307
|
+
|
|
308
|
+
resolved = self._resolve_target(stripped)
|
|
309
|
+
if resolved is None:
|
|
310
|
+
return line
|
|
311
|
+
|
|
312
|
+
indent = line[: len(line) - len(line.lstrip())]
|
|
313
|
+
return f"{indent}{resolved}"
|
|
314
|
+
|
|
315
|
+
def _resolve_target(self, target: str) -> str | None:
|
|
316
|
+
reference = parse_symbolic_reference(target)
|
|
317
|
+
if reference is None:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
resolved = self._resolver.resolve(reference)
|
|
322
|
+
except SymbolicSnippetError:
|
|
323
|
+
if self._fail_on_unresolved:
|
|
324
|
+
raise
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
return f"{resolved.snippet_path}:{resolved.line_selector}"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class SymbolicSnippetsExtension(Extension):
|
|
331
|
+
def __init__(self, **kwargs) -> None:
|
|
332
|
+
self.config = {
|
|
333
|
+
"module_roots": [["."], "Paths where dotted modules should resolve from"],
|
|
334
|
+
"encoding": ["utf-8", "Encoding used when reading Python source modules"],
|
|
335
|
+
"fail_on_unresolved": [
|
|
336
|
+
True,
|
|
337
|
+
"Fail the build when module or symbol resolution fails",
|
|
338
|
+
],
|
|
339
|
+
}
|
|
340
|
+
super().__init__(**kwargs)
|
|
341
|
+
|
|
342
|
+
def extendMarkdown(self, md) -> None:
|
|
343
|
+
module_roots = self.getConfig("module_roots")
|
|
344
|
+
if isinstance(module_roots, str):
|
|
345
|
+
normalized_roots = [module_roots]
|
|
346
|
+
else:
|
|
347
|
+
normalized_roots = list(module_roots)
|
|
348
|
+
|
|
349
|
+
resolver = SymbolResolver(
|
|
350
|
+
module_roots=normalized_roots,
|
|
351
|
+
encoding=self.getConfig("encoding"),
|
|
352
|
+
)
|
|
353
|
+
md.preprocessors.register(
|
|
354
|
+
SymbolicSnippetPreprocessor(
|
|
355
|
+
md,
|
|
356
|
+
resolver=resolver,
|
|
357
|
+
fail_on_unresolved=self.getConfig("fail_on_unresolved"),
|
|
358
|
+
),
|
|
359
|
+
"zensical-symbolic-snippets",
|
|
360
|
+
40,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def makeExtension(**kwargs) -> SymbolicSnippetsExtension:
|
|
365
|
+
return SymbolicSnippetsExtension(**kwargs)
|