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.
@@ -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"
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import version
2
+
3
+ from .symbolic_snippets import SymbolicSnippetsExtension
4
+
5
+ __version__ = version("zensical-code-references")
6
+
7
+ __all__ = ["SymbolicSnippetsExtension", "__version__"]
@@ -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)