sphinx-codelinks 1.2.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 (27) hide show
  1. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/PKG-INFO +10 -5
  2. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/pyproject.toml +25 -71
  3. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/analyse.py +164 -26
  4. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/oneline_parser.py +13 -9
  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.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/projects.py +4 -10
  10. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/utils.py +128 -22
  11. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/cmd.py +20 -4
  12. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/config.py +138 -2
  13. sphinx_codelinks-1.4.0/src/sphinx_codelinks/logger.py +261 -0
  14. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/source_discover/config.py +19 -0
  15. sphinx_codelinks-1.4.0/src/sphinx_codelinks/source_discover/source_discover.py +112 -0
  16. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +40 -20
  17. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/source_tracing.py +42 -5
  18. sphinx_codelinks-1.2.0/src/sphinx_codelinks/logger.py +0 -94
  19. sphinx_codelinks-1.2.0/src/sphinx_codelinks/source_discover/source_discover.py +0 -67
  20. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/LICENSE +0 -0
  21. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/README.md +0 -0
  22. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/__init__.py +0 -0
  23. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/analyse/models.py +0 -0
  24. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/needextend_write.py +0 -0
  25. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/debug.py +0 -0
  26. {sphinx_codelinks-1.2.0 → sphinx_codelinks-1.4.0}/src/sphinx_codelinks/sphinx_extension/html_wrapper.py +0 -0
  27. {sphinx_codelinks-1.2.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.2.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>
@@ -8,12 +8,12 @@ Requires-Python: >= 3.12
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: comment-parser>=1.2.4
11
- Requires-Dist: gitignore-parser>=0.1.11
12
- Requires-Dist: typer>=0.16.0
11
+ Requires-Dist: ignore-python>=0.3.3
12
+ Requires-Dist: typer>=0.16.0,<0.26.8
13
13
  Requires-Dist: click < 8.2
14
14
  Requires-Dist: jsonschema
15
- Requires-Dist: sphinx>=7.4,<9
16
- Requires-Dist: sphinx-needs>=4.2.0
15
+ Requires-Dist: sphinx>=7.4,<10
16
+ Requires-Dist: sphinx-needs>=5,<9
17
17
  Requires-Dist: jinja2
18
18
  Requires-Dist: pygments
19
19
  Requires-Dist: docutils
@@ -24,6 +24,11 @@ Requires-Dist: tree-sitter~=0.25.1
24
24
  Requires-Dist: tree-sitter-c-sharp>=0.23.1
25
25
  Requires-Dist: tree-sitter-yaml>=0.7.1
26
26
  Requires-Dist: tree-sitter-rust>=0.23.0
27
+ Requires-Dist: tree-sitter-go>=0.23.0
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
27
32
 
28
33
  # Sphinx CodeLinks
29
34
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sphinx-codelinks"
3
- version = "1.2.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 = [
@@ -12,12 +12,12 @@ readme = "README.md"
12
12
  requires-python = ">= 3.12"
13
13
  dependencies = [
14
14
  "comment-parser>=1.2.4",
15
- "gitignore-parser>=0.1.11",
16
- "typer>=0.16.0",
15
+ "ignore-python>=0.3.3",
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
- "sphinx>=7.4,<9",
20
- "sphinx-needs>=4.2.0", # unconstrained versions, to be pinned by user or Sphinx
19
+ "sphinx>=7.4,<10",
20
+ "sphinx-needs>=5,<9",
21
21
  "jinja2",
22
22
  "pygments",
23
23
  "docutils", # constrained by user or Sphinx
@@ -30,8 +30,21 @@ dependencies = [
30
30
  "tree-sitter-c-sharp>=0.23.1",
31
31
  "tree-sitter-yaml>=0.7.1",
32
32
  "tree-sitter-rust>=0.23.0",
33
+ "tree-sitter-go>=0.23.0",
34
+ "tree-sitter-json>=0.24.8",
35
+ "tree-sitter-bash>=0.25.1",
33
36
  ]
34
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
+
35
48
  [build-system]
36
49
  requires = ["flit_core >=3.4,<4"]
37
50
  build-backend = "flit_core.buildapi"
@@ -46,13 +59,14 @@ testing = [
46
59
  "moto ~= 5.0",
47
60
  "toml>=0.10.2",
48
61
  "furo>=2024.5.6",
62
+ "libclang>=18",
49
63
  ]
50
64
  docs = [
51
65
  "furo>=2024.5.6",
52
66
  "myst-parser>=4.0.0",
53
67
  "sphinx-design>=0.6.1",
54
68
  "sphinx-code-tabs>=0.5.5",
55
- "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
56
70
  "sphinxcontrib-video>=0.4.1",
57
71
  ]
58
72
  mypy = [
@@ -66,64 +80,9 @@ mypy = [
66
80
  ruff = ["ruff>=0.8.0"]
67
81
  build = ["simple-build>=0.0.2", "shiv>=1.0.8"]
68
82
 
69
- [tool.rye]
70
- managed = true
71
- dev-dependencies = [
72
- "types-docutils",
73
- "types-Pygments",
74
- "syrupy>=4.9.1",
75
- "furo>=2024.5.6",
76
- "moto ~= 5.0",
77
- "mypy>=1.12.1",
78
- "myst-parser>=4.0.0",
79
- "pydantic ~= 2.9",
80
- "pip-licenses>=5.0.0",
81
- "psutil>=7.0.0",
82
- "pytest-cov>=5.0.0",
83
- "pytest>=8.2.2",
84
- "simple-build>=0.0.2",
85
- "sphinx-design>=0.6.1",
86
- "types-psutil>=7.0.0.20250218",
87
- "uv>=0.5.5",
88
- "pytest-docker>=3.1.2",
89
- "shiv>=1.0.8",
90
- "insta-science>=0.2.1",
91
- "types-jsonschema>=4.23.0.20241208",
92
- "toml>=0.10.2",
93
- "sphinx-code-tabs>=0.5.5",
94
- "sphinxcontrib-typer>=0.5.1",
95
- "sphinxcontrib-video>=0.4.1",
96
- ]
97
-
98
83
  [project.scripts]
99
84
  codelinks = "sphinx_codelinks.cmd:app"
100
85
 
101
- [tool.rye.scripts]
102
- # linting and formatting
103
- "mypy:all" = "mypy ."
104
- "rye:lint" = "rye lint"
105
- "rye:format" = "rye format"
106
- "check" = { chain = ["rye:format", "rye:lint", "mypy:all"] }
107
- # docs html
108
- "docs:rm" = "rm -rf docs/_build/html"
109
- "docs" = "sphinx-build -nW --keep-going -b html -T -c docs docs/source docs/_build/html"
110
- "docs:clean" = { chain = ["docs:rm", "docs"] }
111
- # needextend demo
112
- "analyse" = "codelinks analyse tests/data/configs/minimum_config.toml"
113
- "analyse:rm" = "rm -rf output/marked_content.json"
114
- "write" = "codelinks write rst output/marked_content.json --outpath tests/data/needextend_demo/needextend.rst"
115
- "write:rm" = "rm -rf tests/data/needextend_demo/needextend.rst"
116
- "demo:rm" = "rm -rf tests/data/needextend_demo/_build"
117
- "demo:build" = "sphinx-build -nW --keep-going -b html -T -c tests/data/needextend_demo tests/data/needextend_demo tests/data/needextend_demo/_build/html"
118
- "demo:clean" = { chain = [
119
- "demo:rm",
120
- "analyse:rm",
121
- "write:rm",
122
- "analyse",
123
- "write",
124
- "demo:build",
125
- ] }
126
-
127
86
  [tool.ruff.lint]
128
87
  extend-select = [
129
88
  # "ANN",
@@ -159,17 +118,11 @@ force-sort-within-sections = true
159
118
  "**/tests/*" = [
160
119
  "ARG001", # unused-function-argument - fixtures
161
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
162
123
  "PLR2004", # magic-value-comparison - valueable for tests
163
124
  "S101", # assert - needed for tests
164
- ]
165
- "**/build_hooks_*/**" = [
166
- "S607", # start-process-with-partial-path - pyarmor call in rye context
167
- "S603", # subprocess-without-shell-equals-true - pyarmor call
168
- ]
169
- "scripts/*.py" = [
170
- "T201", # print - used for output
171
- "S607", # start-process-with-partial-path - pyarmor call in rye context
172
- "S603", # subprocess-without-shell-equals-true - build scripts
125
+ "SIM300", # yoda-conditions - assert order in tests is intentional
173
126
  ]
174
127
  "src/sphinx_codelinks/sphinx_extension/debug.py" = [
175
128
  "T201", # print - used for output
@@ -202,7 +155,8 @@ plugins = ["pydantic.mypy"]
202
155
  mypy_path = "typings"
203
156
 
204
157
  [[tool.mypy.overrides]]
205
- module = ["licensing.*", "tomlkit.*"]
158
+ # clang.cindex ships no type stubs / py.typed; the libclang engine wraps it.
159
+ module = ["licensing.*", "tomlkit.*", "clang.*"]
206
160
  ignore_missing_imports = true
207
161
 
208
162
  [[tool.mypy.overrides]]
@@ -1,9 +1,8 @@
1
1
  from collections.abc import Generator
2
2
  from dataclasses import dataclass
3
3
  import json
4
- import logging
5
4
  from pathlib import Path
6
- from typing import Any, TypedDict
5
+ from typing import Any, TypedDict, cast
7
6
 
8
7
  from tree_sitter import Node as TreeSitterNode
9
8
 
@@ -26,14 +25,15 @@ from sphinx_codelinks.config import (
26
25
  OneLineCommentStyle,
27
26
  SourceAnalyseConfig,
28
27
  )
28
+ from sphinx_codelinks.logger import get_logger
29
+ from sphinx_codelinks.source_discover.config import CommentType
29
30
 
30
- # initialize logger
31
- logger = logging.getLogger(__name__)
32
- logger.setLevel(logging.INFO)
33
- # log to the console
34
- console = logging.StreamHandler()
35
- console.setLevel(logging.INFO)
36
- logger.addHandler(console)
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ def _count(n: int, noun: str) -> str:
35
+ """Format ``n noun`` with a naive (append-s) plural for progress summaries."""
36
+ return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
37
37
 
38
38
 
39
39
  class AnalyseWarningType(TypedDict):
@@ -57,7 +57,10 @@ class SourceAnalyse:
57
57
  def __init__(
58
58
  self,
59
59
  analyse_config: SourceAnalyseConfig,
60
+ *,
61
+ name: str = "",
60
62
  ) -> None:
63
+ self.name = name
61
64
  self.analyse_config = analyse_config
62
65
  self.src_files: list[SourceFile] = []
63
66
  self.src_comments: list[SourceComment] = []
@@ -76,10 +79,11 @@ class SourceAnalyse:
76
79
  self.git_commit_rev: str | None = (
77
80
  utils.get_current_rev(self.git_root) if self.git_root else None
78
81
  )
79
- self.project_path: Path = (
80
- self.git_root if self.git_root else self.analyse_config.src_dir
81
- )
82
+ self.project_path: Path = self.git_root or self.analyse_config.src_dir
82
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] = {}
83
87
 
84
88
  def get_src_strings(self) -> Generator[tuple[Path, bytes], Any, None]: # type: ignore[explicit-any]
85
89
  """Load source files and extract their content."""
@@ -110,8 +114,126 @@ class SourceAnalyse:
110
114
  self.src_files.append(src_file)
111
115
  self.src_comments.extend(src_comments)
112
116
 
113
- logger.info(f"Source files loaded: {len(self.src_files)}")
114
- logger.info(f"Source comments extracted: {len(self.src_comments)}")
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)
115
237
 
116
238
  def extract_marker(
117
239
  self,
@@ -328,9 +450,12 @@ class SourceAnalyse:
328
450
  )
329
451
  if not filepath:
330
452
  continue
331
- tagged_scope: TreeSitterNode | None = utils.find_associated_scope(
332
- src_comment.node, self.analyse_config.comment_type
333
- )
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
+ )
334
459
  if self.analyse_config.get_need_id_refs:
335
460
  anchors = self.extract_anchors(
336
461
  text, filepath, tagged_scope, src_comment
@@ -353,13 +478,6 @@ class SourceAnalyse:
353
478
  if marked_rst:
354
479
  self.marked_rst.append(marked_rst)
355
480
 
356
- if self.analyse_config.get_need_id_refs:
357
- logger.info(f"Need-id-refs extracted: {len(self.need_id_refs)}")
358
- if self.analyse_config.get_oneline_needs:
359
- logger.info(f"Oneline needs extracted: {len(self.oneline_needs)}")
360
- if self.analyse_config.get_rst:
361
- logger.info(f"Marked rst extracted: {len(self.marked_rst)}")
362
-
363
481
  def merge_marked_content(self) -> None:
364
482
  self.all_marked_content.extend(self.need_id_refs)
365
483
  self.oneline_needs.sort(key=lambda x: x.source_map["start"]["row"])
@@ -378,9 +496,29 @@ class SourceAnalyse:
378
496
  ]
379
497
  with output_path.open("w") as f:
380
498
  json.dump(to_dump, f)
381
- logger.info(f"Marked content dumped to {output_path}")
382
499
 
383
500
  def run(self) -> None:
384
- 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()
385
508
  self.extract_marked_content()
386
509
  self.merge_marked_content()
510
+ self._log_summary()
511
+
512
+ def _log_summary(self) -> None:
513
+ """Emit a per-project marker (default-visible) plus a -v breakdown."""
514
+ label = f"codelinks [{self.name}]" if self.name else "codelinks"
515
+ logger.info(
516
+ f"{label}: {_count(len(self.src_files), 'file')}, "
517
+ f"{_count(len(self.all_marked_content), 'marker')}"
518
+ )
519
+ logger.debug(
520
+ f"{label}: {_count(len(self.src_comments), 'comment')}, "
521
+ f"{_count(len(self.oneline_needs), 'oneline need')}, "
522
+ f"{_count(len(self.need_id_refs), 'id-ref')}, "
523
+ f"{_count(len(self.marked_rst), 'marked-rst block')}"
524
+ )
@@ -1,17 +1,8 @@
1
1
  from dataclasses import dataclass
2
2
  from enum import Enum
3
- import logging
4
3
 
5
4
  from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle
6
5
 
7
- # initialize logger
8
- logger = logging.getLogger(__name__)
9
- logger.setLevel(logging.INFO)
10
- # log to the console
11
- console = logging.StreamHandler()
12
- console.setLevel(logging.INFO)
13
- logger.addHandler(console)
14
-
15
6
 
16
7
  class WarningSubTypeEnum(str, Enum):
17
8
  """Enum for warning sub types."""
@@ -52,6 +43,19 @@ def oneline_parser( # noqa: PLR0912, PLR0911 # handel warnings
52
43
  # start or end sequences do not exist
53
44
  return None
54
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
+
55
59
  # extract the string wrapped by start and end
56
60
  start_idx = start_idx + len(oneline_config.start_sequence)
57
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