sphinx-codelinks 1.3.0__tar.gz → 1.4.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.
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/PKG-INFO +5 -2
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/pyproject.toml +20 -4
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/analyse.py +140 -8
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/oneline_parser.py +13 -0
- sphinx_codelinks-1.4.0/src/sphinx_codelinks/analyse/preproc/__init__.py +5 -0
- sphinx_codelinks-1.4.0/src/sphinx_codelinks/analyse/preproc/compile_db.py +156 -0
- sphinx_codelinks-1.4.0/src/sphinx_codelinks/analyse/preproc/libclang_parser.py +102 -0
- sphinx_codelinks-1.4.0/src/sphinx_codelinks/analyse/preproc/loader.py +95 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/utils.py +10 -1
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/cmd.py +8 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/config.py +118 -2
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/source_discover/config.py +8 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +19 -4
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/LICENSE +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/README.md +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/__init__.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/models.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/projects.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/logger.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/needextend_write.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/source_discover/source_discover.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/debug.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/html_wrapper.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/source_tracing.py +0 -0
- {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/ub_sct.css +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sphinx-codelinks
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Fast Source Code Traceability for Sphinx-Needs
|
|
5
5
|
Author-email: team useblocks <info@useblocks.com>
|
|
6
6
|
Maintainer-email: Marco Heinemann <marco.heinemann@useblocks.com>, Jui-Wen Chen <jui-wen.chen@useblocks.com>
|
|
@@ -9,7 +9,7 @@ Description-Content-Type: text/markdown
|
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Dist: comment-parser>=1.2.4
|
|
11
11
|
Requires-Dist: ignore-python>=0.3.3
|
|
12
|
-
Requires-Dist: typer>=0.16.0
|
|
12
|
+
Requires-Dist: typer>=0.16.0,<0.26.8
|
|
13
13
|
Requires-Dist: click < 8.2
|
|
14
14
|
Requires-Dist: jsonschema
|
|
15
15
|
Requires-Dist: sphinx>=7.4,<10
|
|
@@ -26,6 +26,9 @@ Requires-Dist: tree-sitter-yaml>=0.7.1
|
|
|
26
26
|
Requires-Dist: tree-sitter-rust>=0.23.0
|
|
27
27
|
Requires-Dist: tree-sitter-go>=0.23.0
|
|
28
28
|
Requires-Dist: tree-sitter-json>=0.24.8
|
|
29
|
+
Requires-Dist: tree-sitter-bash>=0.25.1
|
|
30
|
+
Requires-Dist: libclang>=16 ; extra == "libclang"
|
|
31
|
+
Provides-Extra: libclang
|
|
29
32
|
|
|
30
33
|
# Sphinx CodeLinks
|
|
31
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sphinx-codelinks"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.4.0"
|
|
4
4
|
description = "Fast Source Code Traceability for Sphinx-Needs"
|
|
5
5
|
authors = [{ name = "team useblocks", email = "info@useblocks.com" }]
|
|
6
6
|
maintainers = [
|
|
@@ -13,7 +13,7 @@ requires-python = ">= 3.12"
|
|
|
13
13
|
dependencies = [
|
|
14
14
|
"comment-parser>=1.2.4",
|
|
15
15
|
"ignore-python>=0.3.3",
|
|
16
|
-
"typer>=0.16.0",
|
|
16
|
+
"typer>=0.16.0,<0.26.8", # 0.26.8 removed rich_utils.STYLE_METAVAR, still imported by sphinxcontrib-typer (docs)
|
|
17
17
|
"click < 8.2", # click 8.2.* produces empty errors if no args are given
|
|
18
18
|
"jsonschema",
|
|
19
19
|
"sphinx>=7.4,<10",
|
|
@@ -32,8 +32,19 @@ dependencies = [
|
|
|
32
32
|
"tree-sitter-rust>=0.23.0",
|
|
33
33
|
"tree-sitter-go>=0.23.0",
|
|
34
34
|
"tree-sitter-json>=0.24.8",
|
|
35
|
+
"tree-sitter-bash>=0.25.1",
|
|
35
36
|
]
|
|
36
37
|
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
# libclang enables the preprocessor-aware C/C++ engine (skips comments inside
|
|
40
|
+
# inactive #if/#ifdef regions). Plain tree-sitter C/C++ extraction needs none of
|
|
41
|
+
# this, so libclang stays optional. Named after the backend, not "cpp": the
|
|
42
|
+
# engine handles C and Objective-C too, and tree-sitter already covers C++.
|
|
43
|
+
# Floor 16 (not 18): the extraction snapshots are byte-identical on libclang 16,
|
|
44
|
+
# 17 and 18 (the engine only uses get_all_skipped_ranges + comment tokenization,
|
|
45
|
+
# stable since long before 16), so support older toolchains too. No upper bound.
|
|
46
|
+
libclang = ["libclang>=16"]
|
|
47
|
+
|
|
37
48
|
[build-system]
|
|
38
49
|
requires = ["flit_core >=3.4,<4"]
|
|
39
50
|
build-backend = "flit_core.buildapi"
|
|
@@ -48,13 +59,14 @@ testing = [
|
|
|
48
59
|
"moto ~= 5.0",
|
|
49
60
|
"toml>=0.10.2",
|
|
50
61
|
"furo>=2024.5.6",
|
|
62
|
+
"libclang>=18",
|
|
51
63
|
]
|
|
52
64
|
docs = [
|
|
53
65
|
"furo>=2024.5.6",
|
|
54
66
|
"myst-parser>=4.0.0",
|
|
55
67
|
"sphinx-design>=0.6.1",
|
|
56
68
|
"sphinx-code-tabs>=0.5.5",
|
|
57
|
-
"sphinxcontrib-typer>=0.5.1",
|
|
69
|
+
"sphinxcontrib-typer>=0.5.1,<0.9.1", # 0.9.1 imports typer.rich_utils.STYLE_TYPES, absent in the capped typer <0.26.8, breaking the docs build
|
|
58
70
|
"sphinxcontrib-video>=0.4.1",
|
|
59
71
|
]
|
|
60
72
|
mypy = [
|
|
@@ -106,8 +118,11 @@ force-sort-within-sections = true
|
|
|
106
118
|
"**/tests/*" = [
|
|
107
119
|
"ARG001", # unused-function-argument - fixtures
|
|
108
120
|
"ARG005", # unused-lambda-argument - monkeypatches
|
|
121
|
+
"E402", # module-import-not-at-top - pytest.importorskip guard pattern
|
|
122
|
+
"PLC0415", # import-outside-top-level - inline imports in test bodies
|
|
109
123
|
"PLR2004", # magic-value-comparison - valueable for tests
|
|
110
124
|
"S101", # assert - needed for tests
|
|
125
|
+
"SIM300", # yoda-conditions - assert order in tests is intentional
|
|
111
126
|
]
|
|
112
127
|
"src/sphinx_codelinks/sphinx_extension/debug.py" = [
|
|
113
128
|
"T201", # print - used for output
|
|
@@ -140,7 +155,8 @@ plugins = ["pydantic.mypy"]
|
|
|
140
155
|
mypy_path = "typings"
|
|
141
156
|
|
|
142
157
|
[[tool.mypy.overrides]]
|
|
143
|
-
|
|
158
|
+
# clang.cindex ships no type stubs / py.typed; the libclang engine wraps it.
|
|
159
|
+
module = ["licensing.*", "tomlkit.*", "clang.*"]
|
|
144
160
|
ignore_missing_imports = true
|
|
145
161
|
|
|
146
162
|
[[tool.mypy.overrides]]
|
|
@@ -2,7 +2,7 @@ from collections.abc import Generator
|
|
|
2
2
|
from dataclasses import dataclass
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Any, TypedDict
|
|
5
|
+
from typing import Any, TypedDict, cast
|
|
6
6
|
|
|
7
7
|
from tree_sitter import Node as TreeSitterNode
|
|
8
8
|
|
|
@@ -26,6 +26,7 @@ from sphinx_codelinks.config import (
|
|
|
26
26
|
SourceAnalyseConfig,
|
|
27
27
|
)
|
|
28
28
|
from sphinx_codelinks.logger import get_logger
|
|
29
|
+
from sphinx_codelinks.source_discover.config import CommentType
|
|
29
30
|
|
|
30
31
|
logger = get_logger(__name__)
|
|
31
32
|
|
|
@@ -78,10 +79,11 @@ class SourceAnalyse:
|
|
|
78
79
|
self.git_commit_rev: str | None = (
|
|
79
80
|
utils.get_current_rev(self.git_root) if self.git_root else None
|
|
80
81
|
)
|
|
81
|
-
self.project_path: Path =
|
|
82
|
-
self.git_root if self.git_root else self.analyse_config.src_dir
|
|
83
|
-
)
|
|
82
|
+
self.project_path: Path = self.git_root or self.analyse_config.src_dir
|
|
84
83
|
self.oneline_warnings: list[AnalyseWarning] = []
|
|
84
|
+
# Per-run memo of parsed compile_commands.json, keyed by DB path, so the
|
|
85
|
+
# database is read once per run instead of once per source file.
|
|
86
|
+
self._flags_map_cache: dict[Path, dict[Path, list[str]] | None] = {}
|
|
85
87
|
|
|
86
88
|
def get_src_strings(self) -> Generator[tuple[Path, bytes], Any, None]: # type: ignore[explicit-any]
|
|
87
89
|
"""Load source files and extract their content."""
|
|
@@ -112,6 +114,127 @@ class SourceAnalyse:
|
|
|
112
114
|
self.src_files.append(src_file)
|
|
113
115
|
self.src_comments.extend(src_comments)
|
|
114
116
|
|
|
117
|
+
def _flags_map_for(self, db_path: Path) -> dict[Path, list[str]] | None:
|
|
118
|
+
"""Return the parsed compile_commands.json for ``db_path``, memoized.
|
|
119
|
+
|
|
120
|
+
The database is read and parsed once per run instead of once per source
|
|
121
|
+
file (O(files x entries) -> O(entries)). A present-but-malformed or
|
|
122
|
+
unreadable database is warned once and cached as ``None`` so callers fall
|
|
123
|
+
back to the configured defines without re-reading or re-warning per file.
|
|
124
|
+
"""
|
|
125
|
+
from sphinx_codelinks.analyse.preproc import compile_db # noqa: PLC0415
|
|
126
|
+
|
|
127
|
+
if db_path in self._flags_map_cache:
|
|
128
|
+
return self._flags_map_cache[db_path]
|
|
129
|
+
try:
|
|
130
|
+
flags = compile_db.load_flags_map(db_path)
|
|
131
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
132
|
+
logger.warning(
|
|
133
|
+
f"codelinks: failed to read {db_path} ({exc}); "
|
|
134
|
+
f"falling back to the configured defines"
|
|
135
|
+
)
|
|
136
|
+
self._flags_map_cache[db_path] = None
|
|
137
|
+
return None
|
|
138
|
+
self._flags_map_cache[db_path] = flags
|
|
139
|
+
return flags
|
|
140
|
+
|
|
141
|
+
def _resolve_preproc_args(self, src_path: Path) -> list[str] | None:
|
|
142
|
+
from sphinx_codelinks.analyse.preproc import compile_db # noqa: PLC0415
|
|
143
|
+
|
|
144
|
+
# `run()` calls this (via create_src_objects_libclang) only when
|
|
145
|
+
# `preprocessor is not None`, but keep the guard: it narrows the type for
|
|
146
|
+
# the checker and is a cheap defense (an `assert` would trip bandit S101).
|
|
147
|
+
preproc = self.analyse_config.preprocessor
|
|
148
|
+
if preproc is None:
|
|
149
|
+
return []
|
|
150
|
+
db_path = preproc.compile_commands
|
|
151
|
+
if db_path is None:
|
|
152
|
+
db_path = compile_db.find_compile_db(src_path, self.project_path)
|
|
153
|
+
if db_path is not None and db_path.is_file():
|
|
154
|
+
flags = self._flags_map_for(db_path)
|
|
155
|
+
if flags is None:
|
|
156
|
+
# Present-but-malformed/unreadable DB (warned once in the helper):
|
|
157
|
+
# fall back to the configured defines so extraction still runs.
|
|
158
|
+
return compile_db.defines_to_args(
|
|
159
|
+
preproc.defines, preproc.includes, preproc.std
|
|
160
|
+
)
|
|
161
|
+
args = flags.get(src_path.absolute().resolve())
|
|
162
|
+
if args is not None:
|
|
163
|
+
return args
|
|
164
|
+
# Absent from the DB. compile_commands.json lists only compiled
|
|
165
|
+
# translation units, never headers — so a header here is parsed
|
|
166
|
+
# standalone with the global defines (one run = one variant). A
|
|
167
|
+
# compiled source absent from the build is skipped (spec §3.3).
|
|
168
|
+
if compile_db.is_translation_unit_source(src_path):
|
|
169
|
+
return None
|
|
170
|
+
return compile_db.defines_to_args(
|
|
171
|
+
preproc.defines, preproc.includes, preproc.std
|
|
172
|
+
)
|
|
173
|
+
# No readable DB. `find_compile_db` only ever returns a real file, so a
|
|
174
|
+
# non-None `db_path` that reaches here is an explicit `compile_commands`
|
|
175
|
+
# path that is not a readable file (typo/missing): warn and fall back
|
|
176
|
+
# rather than skip silently. A genuinely absent DB (db_path is None) just
|
|
177
|
+
# falls back to the manual defines applied globally.
|
|
178
|
+
if db_path is not None:
|
|
179
|
+
logger.warning(
|
|
180
|
+
f"codelinks: compile_commands path {db_path} is not a readable "
|
|
181
|
+
f"file; falling back to the configured defines"
|
|
182
|
+
)
|
|
183
|
+
return compile_db.defines_to_args(
|
|
184
|
+
preproc.defines, preproc.includes, preproc.std
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# @Extract traceability objects with the preprocessor-aware libclang engine, IMPL_PREPROC_1, impl, [FE_PREPROC]
|
|
188
|
+
def create_src_objects_libclang(self) -> None:
|
|
189
|
+
from sphinx_codelinks.analyse.preproc import ( # noqa: PLC0415
|
|
190
|
+
libclang_parser,
|
|
191
|
+
loader,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Resolve the exception via the loader (never a direct ``import
|
|
195
|
+
# clang.cindex``) so a missing ``libclang`` extra still surfaces the
|
|
196
|
+
# loader's friendly install hint rather than a bare ImportError.
|
|
197
|
+
translation_unit_load_error = (
|
|
198
|
+
loader.load_clang_cindex().TranslationUnitLoadError
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
for src_path in self.analyse_config.src_files:
|
|
202
|
+
if not utils.is_text_file(src_path):
|
|
203
|
+
continue
|
|
204
|
+
args = self._resolve_preproc_args(src_path)
|
|
205
|
+
if args is None:
|
|
206
|
+
logger.debug(
|
|
207
|
+
f"codelinks: skipping {src_path} — not found in compile_commands.json"
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
comments = libclang_parser.extract_active_comments(src_path, args)
|
|
212
|
+
except translation_unit_load_error:
|
|
213
|
+
# Last-resort guard. Standalone parses pin ``-x`` to match the
|
|
214
|
+
# ``-std`` (see defines_to_args), so the common case — a ``.h``/
|
|
215
|
+
# ``.c`` header handed a C++ ``-std`` — now parses as C++ and its
|
|
216
|
+
# markers extract. This only fires if libclang still cannot load
|
|
217
|
+
# the file as a translation unit at all; skip it rather than
|
|
218
|
+
# aborting the whole run. Surfaced as a ``warning`` so the silent
|
|
219
|
+
# data-loss (a file's markers dropped) is visible — accepting that
|
|
220
|
+
# this fails ``sphinx-build -W``.
|
|
221
|
+
logger.warning(
|
|
222
|
+
f"codelinks: skipping {src_path} — libclang could not load it "
|
|
223
|
+
f"as a translation unit"
|
|
224
|
+
)
|
|
225
|
+
continue
|
|
226
|
+
if not comments:
|
|
227
|
+
continue
|
|
228
|
+
# ``c`` is a LibclangComment duck-typing the tree-sitter Node
|
|
229
|
+
# interface SourceComment reads (``.text`` / ``.start_point.row``);
|
|
230
|
+
# the Node-only path (find_associated_scope) is guarded by
|
|
231
|
+
# ``is_libclang`` so it never runs on these.
|
|
232
|
+
src_comments = [SourceComment(cast("TreeSitterNode", c)) for c in comments]
|
|
233
|
+
src_file = SourceFile(src_path.absolute())
|
|
234
|
+
src_file.add_comments(src_comments)
|
|
235
|
+
self.src_files.append(src_file)
|
|
236
|
+
self.src_comments.extend(src_comments)
|
|
237
|
+
|
|
115
238
|
def extract_marker(
|
|
116
239
|
self,
|
|
117
240
|
text: str,
|
|
@@ -327,9 +450,12 @@ class SourceAnalyse:
|
|
|
327
450
|
)
|
|
328
451
|
if not filepath:
|
|
329
452
|
continue
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
453
|
+
if getattr(src_comment.node, "is_libclang", False):
|
|
454
|
+
tagged_scope: TreeSitterNode | None = None
|
|
455
|
+
else:
|
|
456
|
+
tagged_scope = utils.find_associated_scope(
|
|
457
|
+
src_comment.node, self.analyse_config.comment_type
|
|
458
|
+
)
|
|
333
459
|
if self.analyse_config.get_need_id_refs:
|
|
334
460
|
anchors = self.extract_anchors(
|
|
335
461
|
text, filepath, tagged_scope, src_comment
|
|
@@ -372,7 +498,13 @@ class SourceAnalyse:
|
|
|
372
498
|
json.dump(to_dump, f)
|
|
373
499
|
|
|
374
500
|
def run(self) -> None:
|
|
375
|
-
|
|
501
|
+
if (
|
|
502
|
+
self.analyse_config.preprocessor is not None
|
|
503
|
+
and self.analyse_config.comment_type == CommentType.cpp
|
|
504
|
+
):
|
|
505
|
+
self.create_src_objects_libclang()
|
|
506
|
+
else:
|
|
507
|
+
self.create_src_objects()
|
|
376
508
|
self.extract_marked_content()
|
|
377
509
|
self.merge_marked_content()
|
|
378
510
|
self._log_summary()
|
{sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/oneline_parser.py
RENAMED
|
@@ -43,6 +43,19 @@ def oneline_parser( # noqa: PLR0912, PLR0911 # handel warnings
|
|
|
43
43
|
# start or end sequences do not exist
|
|
44
44
|
return None
|
|
45
45
|
|
|
46
|
+
# A marker whose end sequence is the newline extends to the end of the
|
|
47
|
+
# line, so an unanchored start sequence would swallow trailing prose. Anchor
|
|
48
|
+
# such a marker to the start of the comment content: everything preceding
|
|
49
|
+
# the start sequence must be comment decoration (`//`, `#`, `*`, ...) and
|
|
50
|
+
# whitespace. A word character before it means the start sequence is part of
|
|
51
|
+
# free-form prose (e.g. `// see @author, ...`) and the line is ignored
|
|
52
|
+
# (issue #88). Explicitly-bounded markers (e.g. `[[ ... ]]`) are
|
|
53
|
+
# self-delimiting and may appear anywhere, so they are exempt.
|
|
54
|
+
if oneline_config.end_sequence == UNIX_NEWLINE and any(
|
|
55
|
+
char.isalnum() for char in oneline[:start_idx]
|
|
56
|
+
):
|
|
57
|
+
return None
|
|
58
|
+
|
|
46
59
|
# extract the string wrapped by start and end
|
|
47
60
|
start_idx = start_idx + len(oneline_config.start_sequence)
|
|
48
61
|
string = oneline[start_idx:end_idx].strip()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Discover, read, and filter compile_commands.json for libclang."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shlex
|
|
8
|
+
|
|
9
|
+
from sphinx_codelinks.config import DEFAULT_CPP_STD
|
|
10
|
+
|
|
11
|
+
_DB_NAME = "compile_commands.json"
|
|
12
|
+
_BOUNDARY_MARKERS = (".git", "ubproject.toml", "pyproject.toml")
|
|
13
|
+
|
|
14
|
+
# Flags that take a following value we must also drop.
|
|
15
|
+
_DROP_WITH_VALUE = {"-o", "-MF", "-MT", "-MQ"}
|
|
16
|
+
# Exact flags to drop.
|
|
17
|
+
_DROP_EXACT = {"-c", "-MMD", "-MD", "-MG", "-MP"}
|
|
18
|
+
# Separate-form flags whose following VALUE token must be kept verbatim (never
|
|
19
|
+
# treated as the input source and stripped by basename).
|
|
20
|
+
_KEEP_WITH_VALUE = {
|
|
21
|
+
"-include",
|
|
22
|
+
"-isystem",
|
|
23
|
+
"-iquote",
|
|
24
|
+
"-idirafter",
|
|
25
|
+
"-isysroot",
|
|
26
|
+
"-x",
|
|
27
|
+
"-I",
|
|
28
|
+
}
|
|
29
|
+
# Minimum length for joined-form flags like -MFdep.d (prefix length = 3)
|
|
30
|
+
_MIN_JOINED_FLAG_LEN = 3
|
|
31
|
+
|
|
32
|
+
# Suffixes a compiler builds as a translation unit. A discovered C/C++ file
|
|
33
|
+
# absent from compile_commands.json is skipped only when it is a TU source
|
|
34
|
+
# (build-excluded); all other discovered files (headers) are parsed standalone.
|
|
35
|
+
TU_SOURCE_SUFFIXES = {".c", ".cpp", ".cc", ".cxx"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# @Discover compile_commands.json by walking up from the source file, IMPL_PREPROC_3, impl, [FE_PREPROC]
|
|
39
|
+
def find_compile_db(start: Path, project_root: Path | None = None) -> Path | None:
|
|
40
|
+
"""Walk up from ``start`` looking for compile_commands.json.
|
|
41
|
+
|
|
42
|
+
Stops at (inclusive) the directory that contains the db, or at
|
|
43
|
+
``project_root`` / a directory containing a boundary marker / fs root.
|
|
44
|
+
"""
|
|
45
|
+
current = start if start.is_dir() else start.parent
|
|
46
|
+
current = current.resolve()
|
|
47
|
+
root = project_root.resolve() if project_root else None
|
|
48
|
+
while True:
|
|
49
|
+
candidate = current / _DB_NAME
|
|
50
|
+
if candidate.is_file():
|
|
51
|
+
return candidate
|
|
52
|
+
if root is not None and current == root:
|
|
53
|
+
return None
|
|
54
|
+
if any((current / m).exists() for m in _BOUNDARY_MARKERS):
|
|
55
|
+
return None
|
|
56
|
+
if current.parent == current: # filesystem root
|
|
57
|
+
return None
|
|
58
|
+
current = current.parent
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def filter_args(argv: list[str], input_file: str) -> list[str]:
|
|
62
|
+
"""Keep only flags libclang needs; drop the compiler, -c/-o, depfiles, input."""
|
|
63
|
+
out: list[str] = []
|
|
64
|
+
skip_next = False
|
|
65
|
+
keep_next_value = False
|
|
66
|
+
input_base = Path(input_file).name
|
|
67
|
+
# Skip leading non-flag tokens. argv[0] is the compiler, but a build may prefix
|
|
68
|
+
# it with a launcher (ccache/sccache/distcc), so drop every leading non-flag
|
|
69
|
+
# token up to the first flag — otherwise the real compiler leaks in as a
|
|
70
|
+
# positional and libclang treats it as a second input (a NULL TU that silently
|
|
71
|
+
# drops the file). The input source (also a non-flag positional) is stripped by
|
|
72
|
+
# name below.
|
|
73
|
+
in_leading = True
|
|
74
|
+
for arg in argv:
|
|
75
|
+
if in_leading:
|
|
76
|
+
if not arg.startswith("-"):
|
|
77
|
+
continue
|
|
78
|
+
in_leading = False
|
|
79
|
+
if skip_next:
|
|
80
|
+
skip_next = False
|
|
81
|
+
continue
|
|
82
|
+
if keep_next_value:
|
|
83
|
+
# Value of a separate-form -include/-isystem/-I/... — keep verbatim.
|
|
84
|
+
keep_next_value = False
|
|
85
|
+
out.append(arg)
|
|
86
|
+
continue
|
|
87
|
+
if arg in _DROP_WITH_VALUE:
|
|
88
|
+
skip_next = True
|
|
89
|
+
continue
|
|
90
|
+
if arg in _DROP_EXACT:
|
|
91
|
+
continue
|
|
92
|
+
if arg.startswith(("-MF", "-MT", "-MQ")) and len(arg) > _MIN_JOINED_FLAG_LEN:
|
|
93
|
+
continue # joined form, e.g. -MFdep.d
|
|
94
|
+
if arg in _KEEP_WITH_VALUE:
|
|
95
|
+
out.append(arg)
|
|
96
|
+
keep_next_value = True
|
|
97
|
+
continue
|
|
98
|
+
# Strip the TU source positional (exact match, or same basename).
|
|
99
|
+
if not arg.startswith("-") and (
|
|
100
|
+
arg in (input_file, input_base) or Path(arg).name == input_base
|
|
101
|
+
):
|
|
102
|
+
continue
|
|
103
|
+
out.append(arg)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# @Resolve per-file compiler flags from the compilation database, IMPL_PREPROC_4, impl, [FE_PREPROC]
|
|
108
|
+
def load_flags_map(db_path: Path) -> dict[Path, list[str]]:
|
|
109
|
+
"""Parse compile_commands.json -> {absolute file path: filtered args}."""
|
|
110
|
+
entries = json.loads(db_path.read_text())
|
|
111
|
+
flags: dict[Path, list[str]] = {}
|
|
112
|
+
for entry in entries:
|
|
113
|
+
if "file" not in entry or "directory" not in entry:
|
|
114
|
+
continue # malformed entry: skip, keep going
|
|
115
|
+
if "arguments" in entry:
|
|
116
|
+
argv = list(entry["arguments"])
|
|
117
|
+
elif "command" in entry:
|
|
118
|
+
argv = shlex.split(entry["command"])
|
|
119
|
+
else:
|
|
120
|
+
continue
|
|
121
|
+
directory = Path(entry["directory"])
|
|
122
|
+
file_field = entry["file"]
|
|
123
|
+
abs_file = (directory / file_field).resolve()
|
|
124
|
+
flags[abs_file] = filter_args(argv, file_field)
|
|
125
|
+
return flags
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# @Parse headers standalone from configured defines and includes, IMPL_PREPROC_5, impl, [FE_PREPROC]
|
|
129
|
+
def defines_to_args(
|
|
130
|
+
defines: list[str], includes: list[Path], std: str = DEFAULT_CPP_STD
|
|
131
|
+
) -> list[str]:
|
|
132
|
+
"""Build a global flag list for parsing a file standalone.
|
|
133
|
+
|
|
134
|
+
Used for headers and files absent from a compile DB. libclang infers the
|
|
135
|
+
language from the file extension, so a C-inferred file (``.h`` / ``.c`` /
|
|
136
|
+
``.inc``) handed a C++ ``-std`` makes clang reject the combination and return
|
|
137
|
+
a NULL translation unit (surfacing as ``TranslationUnitLoadError``). Pin the
|
|
138
|
+
language to match ``std`` with ``-x`` so such files — e.g. a ``.h`` header
|
|
139
|
+
carrying oneline need markers — parse and extract instead of failing. (C
|
|
140
|
+
sources parse as C++ well enough for comment/marker extraction.)
|
|
141
|
+
"""
|
|
142
|
+
lang = "c++" if std.startswith(("c++", "gnu++")) else "c"
|
|
143
|
+
args = ["-x", lang, f"-std={std}"]
|
|
144
|
+
args += [f"-D{d}" for d in defines]
|
|
145
|
+
args += [f"-I{inc}" for inc in includes]
|
|
146
|
+
return args
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_translation_unit_source(path: Path) -> bool:
|
|
150
|
+
"""True if ``path`` is a compiled translation-unit source (not a header).
|
|
151
|
+
|
|
152
|
+
compile_commands.json lists one entry per compiled TU; headers are never
|
|
153
|
+
entries. So a discovered file absent from the DB is skipped only when it is
|
|
154
|
+
a TU source; header-like files are parsed standalone (see _resolve_preproc_args).
|
|
155
|
+
"""
|
|
156
|
+
return path.suffix.lower() in TU_SOURCE_SUFFIXES
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Parse a C/C++ TU via libclang and yield ACTIVE comment tokens only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sphinx_codelinks.analyse.preproc import loader
|
|
10
|
+
from sphinx_codelinks.analyse.preproc.loader import SkippedRange
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class _Point:
|
|
15
|
+
row: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LibclangComment:
|
|
19
|
+
"""Minimal stand-in for a tree-sitter comment node.
|
|
20
|
+
|
|
21
|
+
Exposes exactly the two attributes the existing extract_* chain reads:
|
|
22
|
+
``.text`` (bytes) and ``.start_point.row`` (0-indexed). ``is_libclang``
|
|
23
|
+
lets extract_marked_content skip tree-sitter scope association.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
is_libclang = True
|
|
27
|
+
|
|
28
|
+
def __init__(self, text: bytes, row: int) -> None:
|
|
29
|
+
self.text: bytes = text
|
|
30
|
+
self.start_point = _Point(row)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _group_skipped(skipped: list[SkippedRange]) -> dict[str, list[tuple[int, int]]]:
|
|
34
|
+
"""Group skipped ranges by normalised file path, built once per TU.
|
|
35
|
+
|
|
36
|
+
A per-comment membership test then scans only its own file's ranges instead
|
|
37
|
+
of every range in the translation unit (headers included). Normalising the
|
|
38
|
+
key (``os.path.normpath``) also fixes clang spelling the same file two ways
|
|
39
|
+
(``./x`` vs ``x``, ``a/../b``), which a naive compare would miss.
|
|
40
|
+
"""
|
|
41
|
+
grouped: dict[str, list[tuple[int, int]]] = {}
|
|
42
|
+
for sr in skipped:
|
|
43
|
+
if sr.file is not None:
|
|
44
|
+
grouped.setdefault(os.path.normpath(str(sr.file)), []).append(
|
|
45
|
+
(sr.start_line, sr.end_line)
|
|
46
|
+
)
|
|
47
|
+
return grouped
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_in_skipped(
|
|
51
|
+
file_path: str, line: int, grouped: dict[str, list[tuple[int, int]]]
|
|
52
|
+
) -> bool:
|
|
53
|
+
return any(
|
|
54
|
+
start <= line <= end
|
|
55
|
+
for start, end in grouped.get(os.path.normpath(file_path), ())
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# @Drop comments inside inactive preprocessor branches, IMPL_PREPROC_2, impl, [FE_PREPROC]
|
|
60
|
+
def extract_active_comments(file_path: Path, args: list[str]) -> list[LibclangComment]:
|
|
61
|
+
"""Return one LibclangComment per ACTIVE comment token in ``file_path``.
|
|
62
|
+
|
|
63
|
+
Comments inside preprocessor-skipped (inactive) ranges are dropped.
|
|
64
|
+
"""
|
|
65
|
+
cx = loader.load_clang_cindex()
|
|
66
|
+
index = cx.Index.create()
|
|
67
|
+
tu = index.parse(str(file_path), args=args, options=loader.PARSE_OPTIONS)
|
|
68
|
+
skipped = _group_skipped(loader.get_all_skipped_ranges(tu))
|
|
69
|
+
|
|
70
|
+
# Read the raw source bytes once. We derive the token extent from them
|
|
71
|
+
# (lossily, so a non-UTF-8 byte can't raise and abort the run) AND slice each
|
|
72
|
+
# comment's text out of them by byte offset below — never via ``tok.spelling``,
|
|
73
|
+
# which decodes the comment as strict UTF-8 and raises UnicodeDecodeError on a
|
|
74
|
+
# non-UTF-8 byte inside a comment.
|
|
75
|
+
raw = file_path.read_bytes()
|
|
76
|
+
line_count = len(raw.decode("utf-8", errors="replace").splitlines())
|
|
77
|
+
main = tu.get_file(str(file_path))
|
|
78
|
+
extent = cx.SourceRange.from_locations(
|
|
79
|
+
cx.SourceLocation.from_position(tu, main, 1, 1),
|
|
80
|
+
cx.SourceLocation.from_position(tu, main, line_count + 1, 1),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
out: list[LibclangComment] = []
|
|
84
|
+
for tok in tu.get_tokens(extent=extent):
|
|
85
|
+
if tok.kind != cx.TokenKind.COMMENT:
|
|
86
|
+
continue
|
|
87
|
+
loc = tok.location
|
|
88
|
+
if loc.file is None:
|
|
89
|
+
continue
|
|
90
|
+
if _is_in_skipped(str(loc.file.name), loc.line, skipped):
|
|
91
|
+
continue # inactive branch -> excluded
|
|
92
|
+
# Slice the comment text from the raw bytes by offset and decode lossily
|
|
93
|
+
# (not via tok.spelling, which raises on a non-UTF-8 byte in the comment).
|
|
94
|
+
# Then normalize CRLF/CR -> LF, matching get_src_strings on the tree-sitter
|
|
95
|
+
# path: a multi-line block comment (e.g. a reST block) from a CRLF-saved
|
|
96
|
+
# file otherwise carries embedded \r into the extracted marker text.
|
|
97
|
+
text = raw[tok.extent.start.offset : tok.extent.end.offset].decode(
|
|
98
|
+
"utf-8", errors="replace"
|
|
99
|
+
)
|
|
100
|
+
spelling = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
101
|
+
out.append(LibclangComment(spelling.encode("utf-8"), loc.line - 1))
|
|
102
|
+
return out
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Import guard for clang.cindex + ctypes shim for clang_getAllSkippedRanges.
|
|
2
|
+
|
|
3
|
+
clang.cindex (shipped by the PyPI ``libclang`` wheel, which bundles the native
|
|
4
|
+
library — no compiler required) does not expose clang_getAllSkippedRanges, so
|
|
5
|
+
we bind it via ctypes, exactly as the design concept's _common.py did.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, NamedTuple
|
|
13
|
+
|
|
14
|
+
_INSTALL_HINT = (
|
|
15
|
+
"The libclang engine requires clang.cindex. Install the extra:\n"
|
|
16
|
+
" pip install 'sphinx-codelinks[libclang]'"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# @Guard the optional libclang dependency with an install hint, IMPL_PREPROC_6, impl, [FE_PREPROC]
|
|
21
|
+
def load_clang_cindex() -> Any: # type: ignore[explicit-any]
|
|
22
|
+
"""Return the clang.cindex module or raise a clear install error."""
|
|
23
|
+
try:
|
|
24
|
+
import clang.cindex as cx # noqa: PLC0415
|
|
25
|
+
except ImportError as exc: # pragma: no cover - exercised via patched import
|
|
26
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
27
|
+
return cx
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Production parse flags (design "Parse flags codelinks should use").
|
|
31
|
+
def _parse_options() -> int:
|
|
32
|
+
cx = load_clang_cindex()
|
|
33
|
+
# ``cx`` is untyped (clang ships no stubs), so the bit-or is ``Any``; coerce
|
|
34
|
+
# back to ``int`` to satisfy the declared return type.
|
|
35
|
+
return int(
|
|
36
|
+
cx.TranslationUnit.PARSE_INCOMPLETE
|
|
37
|
+
| cx.TranslationUnit.PARSE_SKIP_FUNCTION_BODIES
|
|
38
|
+
| cx.TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
PARSE_OPTIONS: int = _parse_options()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SkippedRange(NamedTuple):
|
|
46
|
+
file: Path | None
|
|
47
|
+
start_line: int
|
|
48
|
+
start_col: int
|
|
49
|
+
end_line: int
|
|
50
|
+
end_col: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _CXSourceRangeList(ctypes.Structure):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_BOUND = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bind() -> None:
|
|
61
|
+
global _BOUND # noqa: PLW0603
|
|
62
|
+
if _BOUND:
|
|
63
|
+
return
|
|
64
|
+
cx = load_clang_cindex()
|
|
65
|
+
_CXSourceRangeList._fields_ = [
|
|
66
|
+
("count", ctypes.c_uint),
|
|
67
|
+
("ranges", ctypes.POINTER(cx.SourceRange)),
|
|
68
|
+
]
|
|
69
|
+
lib = cx.conf.lib
|
|
70
|
+
lib.clang_getAllSkippedRanges.argtypes = [ctypes.c_void_p]
|
|
71
|
+
lib.clang_getAllSkippedRanges.restype = ctypes.POINTER(_CXSourceRangeList)
|
|
72
|
+
lib.clang_disposeSourceRangeList.argtypes = [ctypes.POINTER(_CXSourceRangeList)]
|
|
73
|
+
lib.clang_disposeSourceRangeList.restype = None
|
|
74
|
+
_BOUND = True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_all_skipped_ranges(tu: Any) -> list[SkippedRange]: # type: ignore[explicit-any]
|
|
78
|
+
"""Return every source range the preprocessor skipped in this TU."""
|
|
79
|
+
_bind()
|
|
80
|
+
cx = load_clang_cindex()
|
|
81
|
+
ptr = cx.conf.lib.clang_getAllSkippedRanges(tu.obj)
|
|
82
|
+
if not ptr:
|
|
83
|
+
return []
|
|
84
|
+
try:
|
|
85
|
+
rl = ptr.contents
|
|
86
|
+
out: list[SkippedRange] = []
|
|
87
|
+
for i in range(rl.count):
|
|
88
|
+
r = rl.ranges[i]
|
|
89
|
+
start = r.start
|
|
90
|
+
end = r.end
|
|
91
|
+
f = Path(start.file.name) if start.file else None
|
|
92
|
+
out.append(SkippedRange(f, start.line, start.column, end.line, end.column))
|
|
93
|
+
return out
|
|
94
|
+
finally:
|
|
95
|
+
cx.conf.lib.clang_disposeSourceRangeList(ptr)
|
|
@@ -44,6 +44,8 @@ SCOPE_NODE_TYPES = {
|
|
|
44
44
|
"type_declaration",
|
|
45
45
|
"type_spec",
|
|
46
46
|
},
|
|
47
|
+
# @Bash Scope Node Types, IMPL_BASH_2, impl, [FE_BASH]
|
|
48
|
+
CommentType.bash: {"function_definition"},
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
logger = get_logger(__name__)
|
|
@@ -74,6 +76,8 @@ GO_QUERY = """
|
|
|
74
76
|
(comment) @comment
|
|
75
77
|
"""
|
|
76
78
|
JSONC_QUERY = """(comment) @comment"""
|
|
79
|
+
# @Bash comment query for tree-sitter, IMPL_BASH_3, impl, [FE_BASH]
|
|
80
|
+
BASH_QUERY = """(comment) @comment"""
|
|
77
81
|
|
|
78
82
|
# JSON value node types that can be associated with a comment.
|
|
79
83
|
JSON_STRUCTURE_TYPES = {
|
|
@@ -103,7 +107,7 @@ def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
|
|
|
103
107
|
return False
|
|
104
108
|
|
|
105
109
|
|
|
106
|
-
# @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST, FE_GO, FE_JSONC]
|
|
110
|
+
# @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST, FE_GO, FE_JSONC, FE_BASH]
|
|
107
111
|
def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:
|
|
108
112
|
if comment_type == CommentType.cpp:
|
|
109
113
|
import tree_sitter_cpp # noqa: PLC0415
|
|
@@ -140,6 +144,11 @@ def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:
|
|
|
140
144
|
|
|
141
145
|
parsed_language = Language(tree_sitter_json.language())
|
|
142
146
|
query = Query(parsed_language, JSONC_QUERY)
|
|
147
|
+
elif comment_type == CommentType.bash:
|
|
148
|
+
import tree_sitter_bash # noqa: PLC0415
|
|
149
|
+
|
|
150
|
+
parsed_language = Language(tree_sitter_bash.language())
|
|
151
|
+
query = Query(parsed_language, BASH_QUERY)
|
|
143
152
|
else:
|
|
144
153
|
raise ValueError(f"Unsupported comment style: {comment_type}")
|
|
145
154
|
parser = Parser(parsed_language)
|
|
@@ -12,6 +12,7 @@ from sphinx_codelinks.config import (
|
|
|
12
12
|
CodeLinksConfig,
|
|
13
13
|
CodeLinksConfigType,
|
|
14
14
|
CodeLinksProjectConfigType,
|
|
15
|
+
anchor_preproc_paths,
|
|
15
16
|
generate_project_configs,
|
|
16
17
|
)
|
|
17
18
|
from sphinx_codelinks.logger import configure_cli, logger
|
|
@@ -150,6 +151,13 @@ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches
|
|
|
150
151
|
config.parent / analyse_config.git_root
|
|
151
152
|
).resolve()
|
|
152
153
|
|
|
154
|
+
# preprocessor compile_commands / include dirs are relative to the config
|
|
155
|
+
# file's location too (like src_dir / git_root).
|
|
156
|
+
if analyse_config.preprocessor is not None:
|
|
157
|
+
analyse_config.preprocessor = anchor_preproc_paths(
|
|
158
|
+
analyse_config.preprocessor, config.parent
|
|
159
|
+
)
|
|
160
|
+
|
|
153
161
|
analyse_errors = analyse_config.check_fields_configuration()
|
|
154
162
|
errors.extend(analyse_errors)
|
|
155
163
|
if errors:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from collections import deque
|
|
2
|
-
from dataclasses import MISSING, dataclass, field, fields
|
|
2
|
+
from dataclasses import MISSING, dataclass, field, fields, replace
|
|
3
3
|
from enum import Enum
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any, Literal, TypedDict, cast
|
|
@@ -29,6 +29,10 @@ COMMENT_MARKERS = {
|
|
|
29
29
|
}
|
|
30
30
|
ESCAPE = "\\"
|
|
31
31
|
|
|
32
|
+
# Default C/C++ standard for the standalone/defines parse path when no
|
|
33
|
+
# compile_commands.json entry supplies one.
|
|
34
|
+
DEFAULT_CPP_STD = "c++17"
|
|
35
|
+
|
|
32
36
|
|
|
33
37
|
class CommentCategory(str, Enum):
|
|
34
38
|
comment = "comment"
|
|
@@ -120,6 +124,53 @@ class MarkedRstConfig:
|
|
|
120
124
|
return self.check_schema() + self.check_sequence_mutually_exclusive()
|
|
121
125
|
|
|
122
126
|
|
|
127
|
+
@dataclass
|
|
128
|
+
class PreprocessorConfig:
|
|
129
|
+
"""Opt-in libclang engine config. Presence => libclang engine for C/C++."""
|
|
130
|
+
|
|
131
|
+
compile_commands: Path | None = field(
|
|
132
|
+
default=None, metadata={"schema": {"type": ["string", "null"]}}
|
|
133
|
+
)
|
|
134
|
+
"""Explicit path to compile_commands.json. If None, walk-up auto-discovery."""
|
|
135
|
+
|
|
136
|
+
defines: list[str] = field(
|
|
137
|
+
default_factory=list,
|
|
138
|
+
metadata={"schema": {"type": "array", "items": {"type": "string"}}},
|
|
139
|
+
)
|
|
140
|
+
"""Fallback -D defines applied globally when no compile_commands.json applies."""
|
|
141
|
+
|
|
142
|
+
includes: list[Path] = field(
|
|
143
|
+
default_factory=list,
|
|
144
|
+
metadata={"schema": {"type": "array", "items": {"type": "string"}}},
|
|
145
|
+
)
|
|
146
|
+
"""Fallback -I include dirs for the defines path."""
|
|
147
|
+
|
|
148
|
+
std: str = field(
|
|
149
|
+
default=DEFAULT_CPP_STD,
|
|
150
|
+
metadata={"schema": {"type": "string"}},
|
|
151
|
+
)
|
|
152
|
+
"""C/C++ standard for the standalone/defines parse path (e.g. ``c++17``,
|
|
153
|
+
``c++20``, ``c11``); libclang pins ``-x`` to match it. Files resolved from a
|
|
154
|
+
``compile_commands.json`` entry use that entry's own ``-std`` instead."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def anchor_preproc_paths(preproc: PreprocessorConfig, base: Path) -> PreprocessorConfig:
|
|
158
|
+
"""Resolve a preprocessor config's ``compile_commands`` and ``includes``
|
|
159
|
+
against ``base`` (the config file's directory), so a relative path resolves
|
|
160
|
+
against the TOML file rather than the process CWD — matching ``src_dir`` /
|
|
161
|
+
``git_root``. Absolute paths are left unchanged.
|
|
162
|
+
"""
|
|
163
|
+
return replace(
|
|
164
|
+
preproc,
|
|
165
|
+
compile_commands=(
|
|
166
|
+
(base / preproc.compile_commands).resolve()
|
|
167
|
+
if preproc.compile_commands is not None
|
|
168
|
+
else None
|
|
169
|
+
),
|
|
170
|
+
includes=[(base / inc).resolve() for inc in preproc.includes],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
123
174
|
class FieldConfig(TypedDict, total=False):
|
|
124
175
|
name: str
|
|
125
176
|
type: Literal["str", "list[str]"]
|
|
@@ -329,6 +380,7 @@ class AnalyseSectionConfigType(TypedDict, total=False):
|
|
|
329
380
|
need_id_refs: NeedIdRefsConfigType
|
|
330
381
|
marked_rst: MarkedRstConfigType
|
|
331
382
|
oneline_comment_style: OneLineCommentStyleType
|
|
383
|
+
preprocessor: dict[str, object]
|
|
332
384
|
|
|
333
385
|
|
|
334
386
|
class SourceAnalyseConfigType(TypedDict, total=False):
|
|
@@ -344,6 +396,7 @@ class SourceAnalyseConfigType(TypedDict, total=False):
|
|
|
344
396
|
need_id_refs_config: NeedIdRefsConfig
|
|
345
397
|
marked_rst_config: MarkedRstConfig
|
|
346
398
|
oneline_comment_style: OneLineCommentStyle
|
|
399
|
+
preprocessor: PreprocessorConfig | None
|
|
347
400
|
|
|
348
401
|
|
|
349
402
|
class ProjectsAnalyseConfigType(TypedDict, total=False):
|
|
@@ -400,6 +453,17 @@ class SourceAnalyseConfig:
|
|
|
400
453
|
)
|
|
401
454
|
"""Configuration for extracting oneline needs from comments."""
|
|
402
455
|
|
|
456
|
+
preprocessor: PreprocessorConfig | None = field(default=None)
|
|
457
|
+
"""Opt-in libclang preprocessor engine. None => tree-sitter (default).
|
|
458
|
+
|
|
459
|
+
No flat ``metadata["schema"]`` here: this is a nested dataclass, like the
|
|
460
|
+
sibling ``need_id_refs_config`` / ``marked_rst_config`` /
|
|
461
|
+
``oneline_comment_style`` fields. ``check_schema`` only validates fields that
|
|
462
|
+
declare a flat schema; giving this field one made it validate the constructed
|
|
463
|
+
``PreprocessorConfig`` instance against JSON type ``object`` and fail at
|
|
464
|
+
``config-inited``. Its structure is enforced by ``convert_analyse_config``.
|
|
465
|
+
"""
|
|
466
|
+
|
|
403
467
|
@classmethod
|
|
404
468
|
def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any]
|
|
405
469
|
_field = next(_field for _field in fields(cls) if _field.name is name)
|
|
@@ -785,6 +849,35 @@ def convert_src_discovery_config(
|
|
|
785
849
|
return src_discover_config
|
|
786
850
|
|
|
787
851
|
|
|
852
|
+
def _validate_preprocessor_dict(preproc: dict[str, object]) -> None:
|
|
853
|
+
"""Validate the schema-less ``[preprocessor]`` TOML section.
|
|
854
|
+
|
|
855
|
+
The section has no ``TypedDict``, so a mistyped scalar would otherwise be
|
|
856
|
+
coerced into garbage instead of reported: e.g. ``defines = "X"`` (a bare
|
|
857
|
+
string) becomes ``list("X") == ["X"]`` — or worse, ``defines = "cpp17"``
|
|
858
|
+
becomes ``["c", "p", "p", "1", "7"]`` → five bogus ``-D`` flags. Fail loud.
|
|
859
|
+
|
|
860
|
+
:param preproc: the raw ``[preprocessor]`` mapping from TOML.
|
|
861
|
+
:raises TypeError: if a key has the wrong type.
|
|
862
|
+
"""
|
|
863
|
+
for key in ("defines", "includes"):
|
|
864
|
+
value = preproc.get(key)
|
|
865
|
+
if value is not None and (
|
|
866
|
+
not isinstance(value, list) or not all(isinstance(x, str) for x in value)
|
|
867
|
+
):
|
|
868
|
+
raise TypeError(
|
|
869
|
+
f"[preprocessor] {key} must be a list of strings, "
|
|
870
|
+
f"got {type(value).__name__}: {value!r}"
|
|
871
|
+
)
|
|
872
|
+
for key in ("compile_commands", "std"):
|
|
873
|
+
value = preproc.get(key)
|
|
874
|
+
if value is not None and not isinstance(value, str):
|
|
875
|
+
raise TypeError(
|
|
876
|
+
f"[preprocessor] {key} must be a string, "
|
|
877
|
+
f"got {type(value).__name__}: {value!r}"
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
788
881
|
def convert_analyse_config(
|
|
789
882
|
config_dict: AnalyseSectionConfigType | None,
|
|
790
883
|
src_discover: SourceDiscover | None = None,
|
|
@@ -792,7 +885,12 @@ def convert_analyse_config(
|
|
|
792
885
|
analyse_config_dict: SourceAnalyseConfigType = {}
|
|
793
886
|
if config_dict:
|
|
794
887
|
for k, v in config_dict.items():
|
|
795
|
-
if k not in {
|
|
888
|
+
if k not in {
|
|
889
|
+
"online_comment_style",
|
|
890
|
+
"need_id_refs",
|
|
891
|
+
"marked_rst",
|
|
892
|
+
"preprocessor",
|
|
893
|
+
}:
|
|
796
894
|
# Convert string paths to Path objects
|
|
797
895
|
if k in {"src_dir", "git_root"} and isinstance(v, str):
|
|
798
896
|
analyse_config_dict[k] = Path(v) # type: ignore[literal-required]
|
|
@@ -823,6 +921,24 @@ def convert_analyse_config(
|
|
|
823
921
|
analyse_config_dict["marked_rst_config"] = marked_rst_config
|
|
824
922
|
analyse_config_dict["oneline_comment_style"] = oneline_comment_style
|
|
825
923
|
|
|
924
|
+
preprocessor_dict = config_dict.get("preprocessor")
|
|
925
|
+
if preprocessor_dict is not None:
|
|
926
|
+
# The preprocessor section has no TypedDict; its values are dynamic
|
|
927
|
+
# TOML (typed ``object``), so validate the shapes up front (a mistyped
|
|
928
|
+
# scalar would otherwise coerce into garbage flags) and keep the
|
|
929
|
+
# targeted ignores matching the concrete errors mypy reports.
|
|
930
|
+
_validate_preprocessor_dict(preprocessor_dict)
|
|
931
|
+
analyse_config_dict["preprocessor"] = PreprocessorConfig(
|
|
932
|
+
compile_commands=(
|
|
933
|
+
Path(str(preprocessor_dict["compile_commands"]))
|
|
934
|
+
if preprocessor_dict.get("compile_commands")
|
|
935
|
+
else None
|
|
936
|
+
),
|
|
937
|
+
defines=list(preprocessor_dict.get("defines", [])), # type: ignore[call-overload]
|
|
938
|
+
includes=[Path(str(p)) for p in preprocessor_dict.get("includes", [])], # type: ignore[attr-defined]
|
|
939
|
+
std=str(preprocessor_dict.get("std", DEFAULT_CPP_STD)),
|
|
940
|
+
)
|
|
941
|
+
|
|
826
942
|
if src_discover:
|
|
827
943
|
analyse_config_dict["src_files"] = src_discover.source_paths
|
|
828
944
|
analyse_config_dict["src_dir"] = src_discover.src_discover_config.src_dir
|
{sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/source_discover/config.py
RENAMED
|
@@ -13,6 +13,12 @@ COMMENT_FILETYPE = {
|
|
|
13
13
|
"rust": ["rs"],
|
|
14
14
|
"go": ["go"],
|
|
15
15
|
"jsonc": ["jsonc", "json"],
|
|
16
|
+
# Bash uses `#` line comments; zsh and ksh share the same comment syntax and
|
|
17
|
+
# are scanned with the bash grammar. Fish is intentionally excluded: it is
|
|
18
|
+
# not POSIX-compatible and no tree-sitter-fish distribution is published on
|
|
19
|
+
# PyPI, so it cannot be wired in here. Track fish separately if a PyPI
|
|
20
|
+
# grammar becomes available.
|
|
21
|
+
"bash": ["sh", "bash", "zsh", "ksh"],
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
|
|
@@ -27,6 +33,8 @@ class CommentType(str, Enum):
|
|
|
27
33
|
go = "go"
|
|
28
34
|
# @Support JSONC style comments, IMPL_JSONC_1, impl, [FE_JSONC];
|
|
29
35
|
jsonc = "jsonc"
|
|
36
|
+
# @Support Bash style comments, IMPL_BASH_1, impl, [FE_BASH];
|
|
37
|
+
bash = "bash"
|
|
30
38
|
|
|
31
39
|
|
|
32
40
|
class SourceDiscoverSectionConfigType(TypedDict, total=False):
|
|
@@ -16,6 +16,7 @@ from sphinx_codelinks.analyse.models import OneLineNeed
|
|
|
16
16
|
from sphinx_codelinks.config import (
|
|
17
17
|
CodeLinksConfig,
|
|
18
18
|
CodeLinksProjectConfigType,
|
|
19
|
+
anchor_preproc_paths,
|
|
19
20
|
file_lineno_href,
|
|
20
21
|
)
|
|
21
22
|
from sphinx_codelinks.source_discover.config import SourceDiscoverConfig
|
|
@@ -113,19 +114,33 @@ class SourceTracingDirective(SphinxDirective):
|
|
|
113
114
|
# time, forcing a full re-read. Build a per-directive copy instead so the
|
|
114
115
|
# stored config value stays equal to what ``generate_project_configs`` yields.
|
|
115
116
|
base_analyse_config = src_trace_conf["analyse_config"]
|
|
117
|
+
# Resolve the config file's directory once (used for git_root + preproc).
|
|
118
|
+
conf_dir = Path(self.env.app.confdir)
|
|
119
|
+
if src_trace_sphinx_config.config_from_toml:
|
|
120
|
+
src_trace_toml_path = Path(src_trace_sphinx_config.config_from_toml)
|
|
121
|
+
conf_dir = conf_dir / src_trace_toml_path.parent
|
|
116
122
|
# git_root shall be relative to the config file's location (if provided)
|
|
117
123
|
git_root = base_analyse_config.git_root
|
|
118
124
|
if git_root:
|
|
119
|
-
conf_dir = Path(self.env.app.confdir)
|
|
120
|
-
if src_trace_sphinx_config.config_from_toml:
|
|
121
|
-
src_trace_toml_path = Path(src_trace_sphinx_config.config_from_toml)
|
|
122
|
-
conf_dir = conf_dir / src_trace_toml_path.parent
|
|
123
125
|
git_root = (conf_dir / git_root).resolve()
|
|
126
|
+
# preprocessor compile_commands / include dirs are relative to the config
|
|
127
|
+
# file's location too (like src_dir / git_root).
|
|
128
|
+
preprocessor = base_analyse_config.preprocessor
|
|
129
|
+
if preprocessor is not None:
|
|
130
|
+
preprocessor = anchor_preproc_paths(preprocessor, conf_dir)
|
|
131
|
+
# Editing an explicitly-configured compile_commands.json changes the
|
|
132
|
+
# flags (hence which #if branches are active, hence the extracted
|
|
133
|
+
# markers), so register it as a build dependency to trigger a rebuild.
|
|
134
|
+
# (Auto-discovered databases are located per-file inside the analysis
|
|
135
|
+
# and are not tracked here.)
|
|
136
|
+
if preprocessor.compile_commands is not None:
|
|
137
|
+
self.env.note_dependency(str(preprocessor.compile_commands))
|
|
124
138
|
analyse_config = replace(
|
|
125
139
|
base_analyse_config,
|
|
126
140
|
src_dir=src_dir,
|
|
127
141
|
src_files=source_files,
|
|
128
142
|
git_root=git_root,
|
|
143
|
+
preprocessor=preprocessor,
|
|
129
144
|
)
|
|
130
145
|
src_analyse = SourceAnalyse(analyse_config, name=project)
|
|
131
146
|
src_analyse.run()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/debug.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/ub_sct.css
RENAMED
|
File without changes
|