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.
Files changed (25) hide show
  1. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/PKG-INFO +5 -2
  2. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/pyproject.toml +20 -4
  3. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/analyse.py +140 -8
  4. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/oneline_parser.py +13 -0
  5. sphinx_codelinks-1.4.0/src/sphinx_codelinks/analyse/preproc/__init__.py +5 -0
  6. sphinx_codelinks-1.4.0/src/sphinx_codelinks/analyse/preproc/compile_db.py +156 -0
  7. sphinx_codelinks-1.4.0/src/sphinx_codelinks/analyse/preproc/libclang_parser.py +102 -0
  8. sphinx_codelinks-1.4.0/src/sphinx_codelinks/analyse/preproc/loader.py +95 -0
  9. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/utils.py +10 -1
  10. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/cmd.py +8 -0
  11. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/config.py +118 -2
  12. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/source_discover/config.py +8 -0
  13. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +19 -4
  14. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/LICENSE +0 -0
  15. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/README.md +0 -0
  16. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/__init__.py +0 -0
  17. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/models.py +0 -0
  18. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/projects.py +0 -0
  19. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/logger.py +0 -0
  20. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/needextend_write.py +0 -0
  21. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/source_discover/source_discover.py +0 -0
  22. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/debug.py +0 -0
  23. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/html_wrapper.py +0 -0
  24. {sphinx_codelinks-1.3.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/source_tracing.py +0 -0
  25. {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.0
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.0"
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
- module = ["licensing.*", "tomlkit.*"]
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
- tagged_scope: TreeSitterNode | None = utils.find_associated_scope(
331
- src_comment.node, self.analyse_config.comment_type
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
- self.create_src_objects()
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()
@@ -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,5 @@
1
+ """libclang-based, preprocessor-aware extraction engine (opt-in)."""
2
+
3
+ from sphinx_codelinks.analyse.preproc import compile_db, libclang_parser, loader
4
+
5
+ __all__ = ["compile_db", "libclang_parser", "loader"]
@@ -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 {"online_comment_style", "need_id_refs", "marked_rst"}:
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
@@ -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()