1import json
2from collections.abc import Generator
3from dataclasses import dataclass
4from pathlib import Path
5from typing import Any, cast
6
7from tree_sitter import Node as TreeSitterNode
8
9from sphinx_codelinks.analyse import multiline_parser, utils
10from sphinx_codelinks.analyse.models import (
11 MarkedContentType,
12 MultilineNeed,
13 NeedIdRefs,
14 OneLineNeed,
15 SourceComment,
16 SourceFile,
17 SourceMap,
18)
19from sphinx_codelinks.analyse.oneline_parser import (
20 OnelineParserInvalidWarning,
21 oneline_parser,
22)
23from sphinx_codelinks.analyse.references import _relative_posix
24from sphinx_codelinks.config import (
25 UNIX_NEWLINE,
26 OneLineCommentStyle,
27 SourceAnalyseConfig,
28)
29from sphinx_codelinks.logger import get_logger
30from sphinx_codelinks.source_discover.config import CommentType
31
32logger = get_logger(__name__)
33
34
35def _count(n: int, noun: str) -> str:
36 """Format ``n noun`` with a naive (append-s) plural for progress summaries."""
37 return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
38
39
40def _row_count(src_comment: SourceComment) -> int:
41 """The rows a comment covers: a Rust ``///`` node's text ends with its newline, which
42 starts no row of its own."""
43 return (src_comment.node.text or b"").rstrip(b"\n").count(b"\n") + 1
44
45
46@dataclass
47class AnalyseWarning:
48 file_path: str
49 lineno: int
50 msg: str
51 type: str
52 sub_type: str
53
54
55class SourceAnalyse:
56 def __init__(
57 self,
58 analyse_config: SourceAnalyseConfig,
59 *,
60 name: str = "",
61 ) -> None:
62 self.name = name
63 self.analyse_config = analyse_config
64 self.src_files: list[SourceFile] = []
65 self.src_comments: list[SourceComment] = []
66 self.need_id_refs: list[NeedIdRefs] = []
67 self.oneline_needs: list[OneLineNeed] = []
68 self.multiline_needs: list[MultilineNeed] = []
69 self.all_marked_content: list[NeedIdRefs | OneLineNeed | MultilineNeed] = []
70 # Use explicitly configured git_root if provided, otherwise auto-detect
71 if self.analyse_config.git_root is not None:
72 self.git_root: Path | None = self.analyse_config.git_root.resolve()
73 else:
74 self.git_root = utils.locate_git_root(self.analyse_config.src_dir)
75 self.git_remote_url: str | None = (
76 utils.get_remote_url(self.git_root) if self.git_root else None
77 )
78 self.git_commit_rev: str | None = (
79 utils.get_current_rev(self.git_root) if self.git_root else None
80 )
81 self.project_path: Path = self.git_root or self.analyse_config.src_dir
82 self.warnings: list[AnalyseWarning] = []
83 """The analysis' warnings, one-line and multi-line alike, in extraction order."""
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] = {}
87
88 @property
89 def oneline_warnings(self) -> list[AnalyseWarning]:
90 """The old name of :attr:`warnings`, kept for one release; read-only."""
91 return self.warnings
92
93 def get_src_strings(self) -> Generator[tuple[Path, bytes], Any, None]:
94 """Load source files and extract their content."""
95 for src_path in self.analyse_config.src_files:
96 if not utils.is_text_file(src_path):
97 continue
98 with src_path.open("r", encoding="utf-8", newline="") as f:
99 # Normalize all line endings to Unix LF
100 text = f.read()
101 text = text.replace("\r\n", "\n").replace("\r", "\n")
102 yield src_path, text.encode("utf-8")
103
104 def create_src_objects(self) -> None:
105 parser, query = utils.init_tree_sitter(self.analyse_config.comment_type)
106
107 for src_path, src_string in self.get_src_strings():
108 comments: list[TreeSitterNode] | None = utils.extract_comments(
109 src_string, parser, query
110 )
111 if not comments:
112 continue
113 src_comments: list[SourceComment] = [
114 SourceComment(node) for node in comments
115 ]
116
117 src_file = SourceFile(src_path.absolute())
118 src_file.add_comments(src_comments)
119 if self.analyse_config.get_multiline_needs:
120 src_file.lines = src_string.decode("utf-8").split(UNIX_NEWLINE)
121 self.src_files.append(src_file)
122 self.src_comments.extend(src_comments)
123
124 def _flags_map_for(self, db_path: Path) -> dict[Path, list[str]] | None:
125 """Return the parsed compile_commands.json for ``db_path``, memoized.
126
127 The database is read and parsed once per run instead of once per source
128 file (O(files x entries) -> O(entries)). A present-but-malformed or
129 unreadable database is warned once and cached as ``None`` so callers fall
130 back to the configured defines without re-reading or re-warning per file.
131 """
132 from sphinx_codelinks.analyse.preproc import compile_db
133
134 if db_path in self._flags_map_cache:
135 return self._flags_map_cache[db_path]
136 try:
137 flags = compile_db.load_flags_map(db_path)
138 except (OSError, ValueError, TypeError) as exc:
139 logger.warning(
140 f"codelinks: failed to read {db_path} ({exc}); "
141 f"falling back to the configured defines"
142 )
143 self._flags_map_cache[db_path] = None
144 return None
145 self._flags_map_cache[db_path] = flags
146 return flags
147
148 def _resolve_preproc_args(self, src_path: Path) -> list[str] | None:
149 from sphinx_codelinks.analyse.preproc import compile_db
150
151 # `run()` calls this (via create_src_objects_libclang) only when
152 # `preprocessor is not None`, but keep the guard: it narrows the type for
153 # the checker and is a cheap defense (an `assert` would trip bandit S101).
154 preproc = self.analyse_config.preprocessor
155 if preproc is None:
156 return []
157 db_path = preproc.compile_commands
158 if db_path is None:
159 db_path = compile_db.find_compile_db(src_path, self.project_path)
160 if db_path is not None and db_path.is_file():
161 flags = self._flags_map_for(db_path)
162 if flags is None:
163 # Present-but-malformed/unreadable DB (warned once in the helper):
164 # fall back to the configured defines so extraction still runs.
165 return compile_db.defines_to_args(
166 preproc.defines, preproc.includes, preproc.std
167 )
168 args = flags.get(src_path.absolute().resolve())
169 if args is not None:
170 return args
171 # Absent from the DB. compile_commands.json lists only compiled
172 # translation units, never headers — so a header here is parsed
173 # standalone with the global defines (one run = one variant). A
174 # compiled source absent from the build is skipped (spec §3.3).
175 if compile_db.is_translation_unit_source(src_path):
176 return None
177 return compile_db.defines_to_args(
178 preproc.defines, preproc.includes, preproc.std
179 )
180 # No readable DB. `find_compile_db` only ever returns a real file, so a
181 # non-None `db_path` that reaches here is an explicit `compile_commands`
182 # path that is not a readable file (typo/missing): warn and fall back
183 # rather than skip silently. A genuinely absent DB (db_path is None) just
184 # falls back to the manual defines applied globally.
185 if db_path is not None:
186 logger.warning(
187 f"codelinks: compile_commands path {db_path} is not a readable "
188 f"file; falling back to the configured defines"
189 )
190 return compile_db.defines_to_args(
191 preproc.defines, preproc.includes, preproc.std
192 )
193
[docs]194 # @Extract traceability objects with the preprocessor-aware libclang engine, IMPL_PREPROC_1, impl, [FE_PREPROC]
195 def create_src_objects_libclang(self) -> None:
196 from sphinx_codelinks.analyse.preproc import (
197 libclang_parser,
198 loader,
199 )
200
201 # Resolve the exception via the loader (never a direct ``import
202 # clang.cindex``) so a missing ``libclang`` extra still surfaces the
203 # loader's friendly install hint rather than a bare ImportError.
204 translation_unit_load_error = (
205 loader.load_clang_cindex().TranslationUnitLoadError
206 )
207
208 for src_path in self.analyse_config.src_files:
209 if not utils.is_text_file(src_path):
210 continue
211 args = self._resolve_preproc_args(src_path)
212 if args is None:
213 logger.debug(
214 f"codelinks: skipping {src_path} — not found in compile_commands.json"
215 )
216 continue
217 try:
218 comments = libclang_parser.extract_active_comments(src_path, args)
219 except translation_unit_load_error:
220 # Last-resort guard. Standalone parses pin ``-x`` to match the
221 # ``-std`` (see defines_to_args), so the common case — a ``.h``/
222 # ``.c`` header handed a C++ ``-std`` — now parses as C++ and its
223 # markers extract. This only fires if libclang still cannot load
224 # the file as a translation unit at all; skip it rather than
225 # aborting the whole run. Surfaced as a ``warning`` so the silent
226 # data-loss (a file's markers dropped) is visible — accepting that
227 # this fails ``sphinx-build -W``.
228 logger.warning(
229 f"codelinks: skipping {src_path} — libclang could not load it "
230 f"as a translation unit"
231 )
232 continue
233 if not comments:
234 continue
235 # ``c`` is a LibclangComment duck-typing the tree-sitter Node
236 # interface SourceComment reads (``.text`` / ``.start_point.row``);
237 # the Node-only path (find_associated_scope) is guarded by
238 # ``is_libclang`` so it never runs on these.
239 src_comments = [SourceComment(cast("TreeSitterNode", c)) for c in comments]
240 src_file = SourceFile(src_path.absolute())
241 src_file.add_comments(src_comments)
242 if self.analyse_config.get_multiline_needs:
243 # a libclang comment has no column: multi-line needs read what
244 # precedes it on its row from the row itself
245 text = src_path.read_text(encoding="utf-8", errors="replace")
246 text = text.replace("\r\n", "\n").replace("\r", "\n")
247 src_file.lines = text.split(UNIX_NEWLINE)
248 self.src_files.append(src_file)
249 self.src_comments.extend(src_comments)
250
251 def extract_marker(
252 self,
253 text: str,
254 ) -> Generator[tuple[str, list[str], int, int, int], None, None]:
255 lines = text.splitlines()
256 row_offset = 0
257 for line in lines:
258 for marker in self.analyse_config.need_id_refs_config.markers:
259 marker_idx = line.find(marker)
260 if marker_idx == -1:
261 continue
262 markered_text = line[marker_idx + len(marker) :].strip()
263 need_ids = markered_text.replace(",", " ").split()
264 start_column = marker_idx + len(marker)
265 end_column = start_column + len(markered_text)
266 yield marker, need_ids, row_offset, start_column, end_column
267 row_offset += 1
268
[docs]269 # @Extract need ID references from code comments, IMPL_LNK_1, impl, [FE_LNK]
270 def extract_anchors(
271 self,
272 text: str,
273 filepath: Path,
274 tagged_scope: TreeSitterNode | None,
275 src_comment: SourceComment,
276 ) -> list[NeedIdRefs]:
277 """Extract need-ids-refs from a comment."""
278 anchors: list[NeedIdRefs] = []
279 for (
280 marker,
281 need_ids,
282 row_offset,
283 start_column,
284 end_column,
285 ) in self.extract_marker(text):
286 lineno = src_comment.node.start_point.row + row_offset + 1
287 remote_url = self.git_remote_url
288 if self.git_remote_url and self.git_commit_rev:
289 remote_url = utils.form_https_url(
290 self.git_remote_url,
291 self.git_commit_rev,
292 self.project_path,
293 filepath,
294 lineno,
295 )
296 source_map: SourceMap = {
297 "start": {
298 "row": lineno - 1,
299 "column": start_column,
300 },
301 "end": {
302 "row": lineno - 1,
303 "column": end_column,
304 },
305 }
306 anchors.append(
307 NeedIdRefs(
308 filepath,
309 remote_url,
310 source_map,
311 src_comment,
312 tagged_scope,
313 need_ids,
314 marker,
315 )
316 )
317 return anchors
318
319 def _is_need_id_refs_line(self, line: str) -> bool:
320 """Whether a comment line is an ``@need-ids:`` reference rather than a need.
321
322 A line whose text, after comment decoration and whitespace, starts with a
323 configured need-id-refs marker is a reference, and never a one-line need --
324 ubCode's precedence. Without it the default one-line start sequence ``@`` also
325 matched ``// @need-ids: A, B`` and made a need titled ``need-ids: A`` with the id
326 ``B``. A line with anything alphanumeric before the marker is not a reference
327 line here (``// see @need-ids: X`` -- the one-line parser ignores it anyway, and
328 the references on it are still extracted), and a line holding a one-line need
329 before the marker (``[[…]] @need-ids: X``) keeps its need: only a line that
330 STARTS with the marker is withheld from the one-line parser.
331 """
332 for marker in self.analyse_config.need_id_refs_config.markers:
333 marker_idx = line.find(marker)
334 if marker_idx != -1 and not any(
335 char.isalnum() for char in line[:marker_idx]
336 ):
337 return True
338 return False
339
340 def extract_oneline_need(
341 self,
342 text: str,
343 src_comment: SourceComment,
344 oneline_comment_style: OneLineCommentStyle,
345 ) -> Generator[tuple[dict[str, str | list[str] | int], int]]:
346 lines = text.splitlines(keepends=True)
347 row_offset = 0
348 if len(lines) == 1:
349 # single line comment has no newline char in the extracted comment
350 lines[0] = f"{lines[0]}{UNIX_NEWLINE}"
351
352 for line in lines:
353 if self._is_need_id_refs_line(line):
354 row_offset += 1
355 continue
356 resolved = oneline_parser(line, oneline_comment_style)
357 if not resolved:
358 row_offset += 1
359 continue
360 if isinstance(resolved, OnelineParserInvalidWarning):
361 if not src_comment.source_file:
362 row_offset += 1
363 continue
364 lineno = src_comment.node.start_point.row + row_offset + 1
365 warning = AnalyseWarning(
366 str(src_comment.source_file.filepath),
367 lineno,
368 resolved.msg,
369 MarkedContentType.need,
370 resolved.sub_type.value,
371 )
372 self.warnings.append(warning)
373 row_offset += 1
374 continue
375 yield resolved, row_offset
376 row_offset += 1
377
[docs]378 # @Extract one-line traceability needs from comments, IMPL_ONE_1, impl, [FE_DEF, FE_CMT]
379 def extract_oneline_needs(
380 self,
381 text: str,
382 filepath: Path,
383 tagged_scope: TreeSitterNode | None,
384 src_comment: SourceComment,
385 oneline_comment_style: OneLineCommentStyle,
386 ) -> list[OneLineNeed]:
387 row_offset = 0
388 oneline_needs = []
389 for resolved, row_offset in self.extract_oneline_need(
390 text, src_comment, oneline_comment_style
391 ):
392 lineno = src_comment.node.start_point.row + row_offset + 1
393 remote_url = self.git_remote_url
394 if self.git_remote_url and self.git_commit_rev:
395 remote_url = utils.form_https_url(
396 self.git_remote_url,
397 self.git_commit_rev,
398 self.project_path,
399 filepath,
400 lineno,
401 )
402 source_map: SourceMap = {
403 "start": {
404 "row": lineno - 1,
405 "column": resolved[
406 "start_column"
407 ], # dynamic keys # ty: ignore[invalid-argument-type]
408 },
409 "end": {
410 "row": lineno - 1,
411 "column": resolved[
412 "end_column"
413 ], # dynamic keys # ty: ignore[invalid-argument-type]
414 },
415 }
416 del resolved["start_column"]
417 del resolved["end_column"]
418 oneline_needs.append(
419 OneLineNeed(
420 filepath,
421 remote_url,
422 source_map,
423 src_comment,
424 tagged_scope,
425 resolved, # int arguments were deleted # ty: ignore[invalid-argument-type]
426 )
427 )
428 return oneline_needs
429
[docs]430 # @Extract multi-line needs from comment runs, IMPL_MLN_1, impl, [FE_MULTILINE_NEEDS]
431 def extract_multiline_needs(self) -> dict[int, set[int]]:
432 """Extract the multi-line needs of every file, and the rows their blocks claim.
433
434 Runs before the other extractors: the rows a block claims (and those of a
435 refused header, consumed to its close) are hidden from them.
436
437 :return: For each comment holding claimed rows (keyed by ``id()`` of its
438 :class:`SourceComment`), those 0-based rows.
439 """
440 claimed: dict[int, set[int]] = {}
441 config = self.analyse_config.multiline_needs_config
442 for src_file in self.src_files:
443 for run in multiline_parser.form_runs(
444 src_file.src_comments, src_file.lines
445 ):
446 result = multiline_parser.parse_run(
447 run.lines,
448 config,
449 leaderless_block=run.leaderless_block,
450 mixed_leaders=run.mixed_leaders,
451 )
452 for issue in result.issues:
453 self.warnings.append(
454 AnalyseWarning(
455 str(src_file.filepath),
456 issue.row + 1,
457 issue.msg,
458 MarkedContentType.multiline_need,
459 issue.kind.value,
460 )
461 )
462 for block in result.blocks:
463 self.multiline_needs.append(
464 self._multiline_need(block, run, src_file.filepath)
465 )
466 if not result.claimed_rows:
467 continue
468 for src_comment in run.comments:
469 first = src_comment.node.start_point.row
470 rows = result.claimed_rows.intersection(
471 range(first, first + _row_count(src_comment))
472 )
473 if rows:
474 claimed.setdefault(id(src_comment), set()).update(rows)
475 return claimed
476
477 def _multiline_need(
478 self,
479 block: multiline_parser.ParsedBlock,
480 run: multiline_parser.CommentRun,
481 filepath: Path,
482 ) -> MultilineNeed:
483 """The record of one parsed block (see :class:`MultilineNeed`)."""
484 src_comment = next(
485 (
486 comment
487 for comment in run.comments
488 if comment.node.start_point.row
489 <= block.open_row
490 < comment.node.start_point.row + _row_count(comment)
491 ),
492 run.comments[0],
493 )
494 tagged_scope: TreeSitterNode | None = None
495 if not getattr(src_comment.node, "is_libclang", False):
496 tagged_scope = utils.find_associated_scope(
497 src_comment.node, self.analyse_config.comment_type
498 )
499 lineno = block.open_row + 1
500 remote_url = self.git_remote_url
501 if self.git_remote_url and self.git_commit_rev:
502 remote_url = utils.form_https_url(
503 self.git_remote_url,
504 self.git_commit_rev,
505 self.project_path,
506 filepath,
507 lineno,
508 )
509 source_map: SourceMap = {
510 "start": {"row": block.open_row, "column": block.open_col},
511 "end": {"row": block.close_row, "column": block.end_col},
512 }
513 content_start = (
514 {"line": block.content_start[0] + 1, "col": block.content_start[1]}
515 if block.content_start is not None
516 else None
517 )
518 scope = (
519 {
520 "kind": tagged_scope.type,
521 "start": tagged_scope.start_point.row + 1,
522 "end": tagged_scope.end_point.row + 1,
523 }
524 if tagged_scope is not None
525 else None
526 )
527 source: dict[str, Any] = {
528 "project": self.name,
529 "path": _relative_posix(filepath, self.project_path),
530 "root": "git" if self.git_root is not None else "src_dir",
531 "commit": self.git_commit_rev,
532 "start": {"line": lineno, "col": block.open_col},
533 "end": {"line": block.close_row + 1, "col": block.end_col},
534 "content_start": content_start,
535 "option_lines": dict(block.option_rows),
536 "scope": scope,
537 }
538 return MultilineNeed(
539 filepath,
540 remote_url,
541 source_map,
542 src_comment,
543 tagged_scope,
544 block.need,
545 block.markup,
546 source,
547 )
548
549 def extract_marked_content(self) -> None:
550 claimed: dict[int, set[int]] = {}
551 if self.analyse_config.get_multiline_needs:
552 claimed = self.extract_multiline_needs()
553 for src_comment in self.src_comments:
554 text = (
555 src_comment.node.text.decode("utf-8") if src_comment.node.text else None
556 )
557 if not text:
558 continue
559 filepath = (
560 src_comment.source_file.filepath if src_comment.source_file else None
561 )
562 if not filepath:
563 continue
564 claimed_rows = claimed.get(id(src_comment))
565 if claimed_rows:
566 # a block's lines are its own: no one-line need and no reference in them
567 text = multiline_parser.blank_rows(
568 text, src_comment.node.start_point.row, claimed_rows
569 )
570 if getattr(src_comment.node, "is_libclang", False):
571 tagged_scope: TreeSitterNode | None = None
572 else:
573 tagged_scope = utils.find_associated_scope(
574 src_comment.node, self.analyse_config.comment_type
575 )
576 if self.analyse_config.get_need_id_refs:
577 anchors = self.extract_anchors(
578 text, filepath, tagged_scope, src_comment
579 )
580 self.need_id_refs.extend(anchors)
581
582 if self.analyse_config.get_oneline_needs:
583 oneline_needs = self.extract_oneline_needs(
584 text,
585 filepath,
586 tagged_scope,
587 src_comment,
588 self.analyse_config.oneline_comment_style,
589 )
590 self.oneline_needs.extend(oneline_needs)
591
592 def merge_marked_content(self) -> None:
593 self.all_marked_content.extend(self.need_id_refs)
594 self.oneline_needs.sort(key=lambda x: x.source_map["start"]["row"])
595 self.all_marked_content.extend(self.oneline_needs)
596 self.all_marked_content.extend(self.multiline_needs)
597 self.all_marked_content.sort(
598 key=lambda x: (x.filepath, x.source_map["start"]["row"])
599 )
600
601 def dump_marked_content(self, outdir: Path) -> None:
602 output_path = outdir / "marked_content.json"
603 if not output_path.parent.exists():
604 output_path.parent.mkdir(parents=True)
605 to_dump = [
606 marked_content.to_dict() for marked_content in self.all_marked_content
607 ]
608 with output_path.open("w") as f:
609 json.dump(to_dump, f)
610
611 def run(self, *, log_summary: bool = True) -> None:
612 """Parse the source files and extract their markers.
613
614 :param log_summary: Whether to print the per-project summary line; a caller
615 that prints its own (the configuration pass of the Sphinx extension) passes
616 ``False``.
617 """
618 if (
619 self.analyse_config.preprocessor is not None
620 and self.analyse_config.comment_type == CommentType.cpp
621 ):
622 self.create_src_objects_libclang()
623 else:
624 self.create_src_objects()
625 self.extract_marked_content()
626 self.merge_marked_content()
627 if log_summary:
628 self.log_summary()
629
630 def log_summary(self, extra: str = "") -> None:
631 """Emit a per-project marker (default-visible) plus a -v breakdown.
632
633 :param extra: Appended to the default-visible line (the ``src-trace``
634 directive's count of the one-line needs it did not create).
635 """
636 label = f"codelinks [{self.name}]" if self.name else "codelinks"
637 logger.info(
638 f"{label}: {_count(len(self.src_files), 'file')}, "
639 f"{_count(len(self.all_marked_content), 'marker')}{extra}"
640 )
641 logger.debug(
642 f"{label}: {_count(len(self.src_comments), 'comment')}, "
643 f"{_count(len(self.oneline_needs), 'oneline need')}, "
644 f"{_count(len(self.need_id_refs), 'id-ref')}, "
645 f"{_count(len(self.multiline_needs), 'multi-line need')}"
646 )