1import json
2import os
3from collections.abc import Generator, Iterable
4from dataclasses import dataclass, field
5from pathlib import Path
6from typing import TYPE_CHECKING, Any, cast
7
8from tree_sitter import Node as TreeSitterNode
9from tree_sitter import Parser, Query
10
11from sphinx_codelinks.analyse import multiline_parser, utils
12from sphinx_codelinks.analyse.entity import (
13 CodeScope,
14 code_scope,
15 is_doc_comment,
16 scope_language,
17)
18from sphinx_codelinks.analyse.models import (
19 MarkedContentType,
20 MultilineNeed,
21 NeedIdRefs,
22 OneLineNeed,
23 SourceComment,
24 SourceFile,
25 SourceMap,
26 WarningSubTypeEnum,
27)
28from sphinx_codelinks.analyse.oneline_parser import (
29 OnelineParserInvalidWarning,
30 docstring_tag,
31 doxygen_tag,
32 jsdoc_tag,
33 oneline_parser,
34 opens_powershell_literal,
35)
36from sphinx_codelinks.analyse.references import _relative_posix
37from sphinx_codelinks.config import (
38 UNIX_NEWLINE,
39 CommentCategory,
40 OneLineCommentStyle,
41 SourceAnalyseConfig,
42)
43from sphinx_codelinks.logger import get_logger
44from sphinx_codelinks.source_discover.config import CommentType
45
46if TYPE_CHECKING:
47 from sphinx_codelinks.analyse.preproc.libclang_parser import LibclangComment
48
49logger = get_logger(__name__)
50
51# The last key of the marked content's sort. Two entries can start at one position
52# only with overlapping markers (a need-id marker that ends with the one-line start
53# sequence), which the configuration check does not refuse. Such a tie is listed
54# references, then one-line needs, then multi-line needs: an explicit rank, because
55# the type names sort the other way round. Two references never tie: the marker
56# matches of a line do not overlap (``find_marker_matches``).
57_KIND_RANK: dict[MarkedContentType, int] = {
58 MarkedContentType.need_id_refs: 0,
59 MarkedContentType.need: 1,
60 MarkedContentType.multiline_need: 2,
61}
62
63
64def _is_alphanumeric(char: str) -> bool:
65 """Whether ``char``'s Unicode general category is a letter (L*) or a number (N*).
66
67 The one notion of alphanumeric of the reference markers' rules, shared with another
68 implementation of them. ``str.isalnum`` is exactly that test (no other character
69 passes it): characters Unicode calls Alphabetic without being letters -- a circled
70 letter (So), a combining mark (Mn, Mc) -- are not alphanumeric here. The
71 classification follows each tool's own Unicode tables (here the running Python's,
72 ``unicodedata.unidata_version``), so a character newer than the older of two tables
73 can be classed differently.
74 """
75 return char.isalnum()
76
77
78def _is_word_char(char: str) -> bool:
79 """A word character, for a reference marker's boundary: alphanumeric (see
80 :func:`_is_alphanumeric`) or ``_``."""
81 return _is_alphanumeric(char) or char == "_"
82
83
84def _find_marker(line: str, marker: str, start: int) -> int:
85 """The index of the first match of ``marker`` in ``line`` at or after ``start``.
86
87 A marker whose last character is a word character matches only where the
88 character after it is not one (or the line ends): ``\\verifies`` does not match
89 ``\\verifiesall``. No condition applies before the match. ``-1`` when none.
90 """
91 word_final = _is_word_char(marker[-1])
92 idx = line.find(marker, start)
93 while idx != -1:
94 after = idx + len(marker)
95 if not (word_final and after < len(line) and _is_word_char(line[after])):
96 return idx
97 idx = line.find(marker, idx + 1)
98 return -1
99
100
101def find_marker_matches(
102 line: str, markers: Iterable[str], *, first_only: bool = False
103) -> list[tuple[str, int, int]]:
104 """The reference-marker matches of one line, left to right: ``(marker, start, end)``.
105
106 The line is scanned from left to right. At each index, among the markers that
107 match there (see :func:`_find_marker`), the LONGEST wins, and the scan resumes at
108 the end of that match, so matches never overlap. The same marker can match more
109 than once. An empty marker never matches.
110
111 :param line: One line of a comment.
112 :param markers: The configured reference markers.
113 :param first_only: Stop after the first match.
114 """
115 candidates = [marker for marker in markers if marker]
116 matches: list[tuple[str, int, int]] = []
117 pos = 0
118 while candidates:
119 best: tuple[int, str] | None = None
120 for marker in candidates:
121 idx = _find_marker(line, marker, pos)
122 if idx == -1:
123 continue
124 if (
125 best is None
126 or idx < best[0]
127 or (idx == best[0] and len(marker) > len(best[1]))
128 ):
129 best = (idx, marker)
130 if best is None:
131 break
132 idx, marker = best
133 matches.append((marker, idx, idx + len(marker)))
134 if first_only:
135 break
136 pos = idx + len(marker)
137 return matches
138
139
140def _char_column(src: bytes, byte_offset: int) -> int:
141 """The column, in characters, of ``byte_offset`` in the UTF-8 ``src``."""
142 line_start = src.rfind(b"\n", 0, byte_offset) + 1
143 return len(src[line_start:byte_offset].decode("utf-8", errors="replace"))
144
145
146def _count(n: int, noun: str) -> str:
147 """Format ``n noun`` with a naive (append-s) plural for progress summaries."""
148 return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
149
150
151def _row_count(src_comment: SourceComment) -> int:
152 """The rows a comment covers: a Rust ``///`` node's text ends with its newline, which
153 starts no row of its own."""
154 return src_comment.text.rstrip(b"\n").count(b"\n") + 1
155
156
157#: The message of a ``marker_above_conditional`` warning.
158ABOVE_CONDITIONAL_MSG = (
159 "directly above a preprocessor conditional, so attached to no entity: "
160 "put the marker inside the arm, above the definition"
161)
162
163#: How a tag warning names the tag it found.
164_TAG_NAMES = {
165 WarningSubTypeEnum.docstring_tag: "docstring tag",
166 WarningSubTypeEnum.jsdoc_tag: "JSDoc tag",
167 WarningSubTypeEnum.doxygen_tag: "Doxygen tag",
168}
169
170
171def _is_jsdoc(text: bytes) -> bool:
172 """Whether the comment ``text`` is a JSDoc comment: a block comment opened by
173 exactly ``/**``, as JSDoc reads one (``/***`` and the empty ``/**/`` are not)."""
174 return (
175 text.startswith(b"/**")
176 and not text.startswith(b"/***")
177 and not text.startswith(b"/**/")
178 )
179
180
181def _docstring_contents(
182 node: TreeSitterNode, raw: bytes, column: int
183) -> list[tuple[str, int, int]]:
184 """The content of each string of a docstring statement, between its quotes.
185
186 Each with its 0-based row and its column, in characters: the quotes' column plus
187 the prefix letters' and the delimiter's length. ``raw`` is the statement's text as
188 the file writes it, ``column`` its column.
189 """
190 contents = []
191 for string in node.named_children:
192 if string.type != "string":
193 continue
194 kinds = {child.type: child for child in string.children}
195 start, end = kinds.get("string_start"), kinds.get("string_end")
196 if start is None or end is None:
197 continue
198 begin = start.end_byte - node.start_byte
199 before = raw[:begin]
200 row_start = before.rfind(b"\n") + 1
201 content_column = len(before[row_start:].decode("utf-8", errors="replace"))
202 if row_start == 0:
203 content_column += column
204 contents.append(
205 (
206 raw[begin : end.start_byte - node.start_byte].decode(
207 "utf-8", errors="replace"
208 ),
209 start.end_point.row,
210 content_column,
211 )
212 )
213 return contents
214
215
216def _scanned_texts(
217 src_comment: SourceComment, claimed_rows: set[int] | None
218) -> list[tuple[str, int, int]]:
219 """What the one-line and reference extractors scan of a comment.
220
221 Each text comes with the 0-based row and the column, in characters, at which it
222 starts. A docstring is scanned as its content, without the quotes; a block
223 comment without its closing ``*/``, a PowerShell ``<# … #>`` comment without its
224 ``#>``, and a legacy ``<!-- … -->`` comment without its ``-->``, which are not
225 marker text. The rows a multi-line need claims are blanked.
226 """
227 node = src_comment.node
228 if getattr(node, "type", None) == CommentCategory.docstring:
229 texts = _docstring_contents(node, src_comment.text, src_comment.column)
230 else:
231 text = src_comment.text.decode("utf-8")
232 if (text.startswith("/*") and text.endswith("*/")) or (
233 text.startswith("<#") and text.endswith("#>")
234 ):
235 text = text[:-2]
236 elif text.startswith("<!--") and text.endswith("-->"):
237 text = text[:-3]
238 texts = [(text, node.start_point.row, src_comment.column)]
239 if claimed_rows:
240 # a block's lines are its own: no one-line need and no reference in them
241 texts = [
242 (multiline_parser.blank_rows(text, row, claimed_rows), row, column)
243 for text, row, column in texts
244 ]
245 return texts
246
247
248@dataclass
249class AnalyseWarning:
250 file_path: str
251 lineno: int
252 msg: str
253 type: str
254 sub_type: str
255
256
257@dataclass
258class BuiltinHeaderWarnings:
259 """What the libclang engine has already warned about clang's builtin headers.
260
261 One object shared by several analyses makes each warning once across all of them:
262 the CLI shares one across its projects, a Sphinx build one across its directives
263 and its scan of config-only projects.
264 """
265
266 files: set[Path] = field(default_factory=set)
267 """The parsed files already warned about a builtin header they could not find."""
268 unavailable: bool = False
269 """Whether the shipped builtin headers were warned to be unavailable."""
270
271
272@dataclass(frozen=True)
273class _Waiting:
274 """A compiled source with no compilation-database entry, waiting for its includer."""
275
276 position: int
277 """Its index in ``src_files``."""
278 path: Path
279 database: Path | None
280 """The database that applies to it."""
281 candidates: tuple[Path, ...]
282 """The main files of the database's entries whose ``#include`` lines name it,
283 ordered by path."""
284
285
286class SourceAnalyse:
287 def __init__(
288 self,
289 analyse_config: SourceAnalyseConfig,
290 *,
291 name: str = "",
292 builtin_header_warnings: BuiltinHeaderWarnings | None = None,
293 ) -> None:
294 """
295 :param builtin_header_warnings: What was already warned about clang's builtin
296 headers, shared with other analyses so that each warning is made once
297 across them all; by default this analysis' own.
298 """
299 self.name = name
300 self.analyse_config = analyse_config
301 self.src_files: list[SourceFile] = []
302 self.src_comments: list[SourceComment] = []
303 self.need_id_refs: list[NeedIdRefs] = []
304 self.oneline_needs: list[OneLineNeed] = []
305 self.multiline_needs: list[MultilineNeed] = []
306 self.all_marked_content: list[NeedIdRefs | OneLineNeed | MultilineNeed] = []
307 # Use explicitly configured git_root if provided, otherwise auto-detect
308 if self.analyse_config.git_root is not None:
309 self.git_root: Path | None = self.analyse_config.git_root.resolve()
310 else:
311 self.git_root = utils.locate_git_root(self.analyse_config.src_dir)
312 self.git_remote_url: str | None = (
313 utils.get_remote_url(self.git_root) if self.git_root else None
314 )
315 self.git_commit_rev: str | None = (
316 utils.get_current_rev(self.git_root) if self.git_root else None
317 )
318 self.project_path: Path = self.git_root or self.analyse_config.src_dir
319 self.warnings: list[AnalyseWarning] = []
320 """The analysis' warnings, one-line and multi-line alike, in extraction order."""
321 # Per-run memo of parsed compile_commands.json, keyed by DB path, so the
322 # database is read once per run instead of once per source file.
323 self._flags_map_cache: dict[Path, dict[Path, list[str]] | None] = {}
324 # Per-run memo of each database's inclusion index (included file -> the
325 # entries' main files that include it), built only when a source needs it.
326 self._inclusion_index_cache: dict[Path, dict[Path, list[Path]]] = {}
327 self.builtin_header_warnings = (
328 builtin_header_warnings
329 if builtin_header_warnings is not None
330 else BuiltinHeaderWarnings()
331 )
332 self.dependencies: set[Path] = set()
333 """Files besides ``src_files`` the extraction read: the main file of every
334 entry tried as a source's includer, the files its unit included that are not
335 system headers, and the database. An edit to one of them can change what is
336 extracted."""
337
338 @property
339 def oneline_warnings(self) -> list[AnalyseWarning]:
340 """The old name of :attr:`warnings`, kept for one release; read-only."""
341 return self.warnings
342
343 def get_src_strings(self) -> Generator[tuple[Path, bytes], Any, None]:
344 """Load source files and extract their content."""
345 for src_path in self.analyse_config.src_files:
346 if not utils.is_text_file(src_path):
347 continue
348 with src_path.open("r", encoding="utf-8", newline="") as f:
349 # Normalize all line endings to Unix LF
350 text = f.read()
351 text = text.replace("\r\n", "\n").replace("\r", "\n")
352 yield src_path, text.encode("utf-8")
353
354 def create_src_objects(self) -> None:
355 comment_type = self.analyse_config.comment_type
356 # One (parser, query) pair per distinct grammar actually needed, built
357 # lazily so a parser is never rebuilt per file. Every comment type
358 # except TypeScript uses a single grammar for the whole run;
359 # TypeScript alone varies its grammar per file (utils.ts_grammar_key)
360 # because a legacy TypeScript-only cast parses as JSX under the wrong
361 # grammar — see the CommentType.ts branch of utils.init_tree_sitter.
362 parser_cache: dict[str, tuple[Parser, Query]] = {}
363
364 for src_path, src_string in self.get_src_strings():
365 # `comment_type` is normally a CommentType member, but a few call
366 # sites carry it as a plain (possibly invalid) str instead — see
367 # SourceAnalyseConfig.comment_type — so key on `str(comment_type)`
368 # rather than `.value`, which only the enum has.
369 cache_key = (
370 utils.ts_grammar_key(src_path)
371 if comment_type == CommentType.ts
372 else str(comment_type)
373 )
374 if cache_key not in parser_cache:
375 parser_cache[cache_key] = utils.init_tree_sitter(comment_type, src_path)
376 parser, query = parser_cache[cache_key]
377
378 tree, cpp_names = utils.parse_source(
379 src_string, parser, comment_type, src_path
380 )
381 comments: list[TreeSitterNode] | None = utils.captured_comments(tree, query)
382 if not comments:
383 continue
384 src_comments: list[SourceComment] = [
385 SourceComment(node, _char_column(src_string, node.start_byte))
386 for node in comments
387 ]
388
389 src_file = SourceFile(src_path.absolute())
390 src_file.cpp_names = cpp_names
391 src_file.add_comments(src_comments)
392 if self.analyse_config.get_multiline_needs:
393 src_file.lines = src_string.decode("utf-8").split(UNIX_NEWLINE)
394 self.src_files.append(src_file)
395 self.src_comments.extend(src_comments)
396
397 def _flags_map_for(self, db_path: Path) -> dict[Path, list[str]] | None:
398 """Return the parsed compile_commands.json for ``db_path``, memoized.
399
400 The database is read and parsed once per run instead of once per source
401 file (O(files x entries) -> O(entries)). A present-but-malformed or
402 unreadable database is warned once and cached as ``None`` so callers fall
403 back to the configured defines without re-reading or re-warning per file.
404 """
405 from sphinx_codelinks.analyse.preproc import compile_db
406
407 if db_path in self._flags_map_cache:
408 return self._flags_map_cache[db_path]
409 try:
410 flags = compile_db.load_flags_map(db_path)
411 except (OSError, ValueError, TypeError) as exc:
412 logger.warning(
413 f"codelinks: failed to read {db_path} ({exc}); "
414 f"falling back to the configured defines"
415 )
416 self._flags_map_cache[db_path] = None
417 return None
418 self._flags_map_cache[db_path] = flags
419 return flags
420
421 def _compile_db_for(self, src_path: Path) -> Path | None:
422 """The compilation database that applies to ``src_path``, if one is named
423 or found (an explicit path that is not a readable file included)."""
424 from sphinx_codelinks.analyse.preproc import compile_db
425
426 preproc = self.analyse_config.preprocessor
427 if preproc is None:
428 return None
429 if preproc.compile_commands is not None:
430 return preproc.compile_commands
431 return compile_db.find_compile_db(src_path, self.project_path)
432
433 def _resolve_preproc_args(self, src_path: Path) -> list[str] | None:
434 from sphinx_codelinks.analyse.preproc import compile_db
435
436 # `run()` calls this (via create_src_objects_libclang) only when
437 # `preprocessor is not None`, but keep the guard: it narrows the type for
438 # the checker and is a cheap defense (an `assert` would trip bandit S101).
439 preproc = self.analyse_config.preprocessor
440 if preproc is None:
441 return []
442 db_path = self._compile_db_for(src_path)
443 if db_path is not None and db_path.is_file():
444 flags = self._flags_map_for(db_path)
445 if flags is None:
446 # Present-but-malformed/unreadable DB (warned once in the helper):
447 # fall back to the configured defines so extraction still runs.
448 return compile_db.defines_to_args(
449 preproc.defines, preproc.includes, preproc.std
450 )
451 args = flags.get(src_path.absolute().resolve())
452 if args is not None:
453 return args
454 # Absent from the DB. compile_commands.json lists only compiled
455 # translation units, never headers — so a header here is parsed
456 # standalone with the global defines (one run = one variant). A
457 # compiled source absent from the DB has no arguments of its own: it is
458 # read through an entry that includes it, or skipped (spec §3.3).
459 if compile_db.is_translation_unit_source(src_path):
460 return None
461 return compile_db.defines_to_args(
462 preproc.defines, preproc.includes, preproc.std
463 )
464 # No readable DB. `find_compile_db` only ever returns a real file, so a
465 # non-None `db_path` that reaches here is an explicit `compile_commands`
466 # path that is not a readable file (typo/missing): warn and fall back
467 # rather than skip silently. A genuinely absent DB (db_path is None) just
468 # falls back to the manual defines applied globally.
469 if db_path is not None:
470 logger.warning(
471 f"codelinks: compile_commands path {db_path} is not a readable "
472 f"file; falling back to the configured defines"
473 )
474 return compile_db.defines_to_args(
475 preproc.defines, preproc.includes, preproc.std
476 )
477
478 def _with_builtin_headers(self, args: list[str]) -> list[str]:
479 """``args`` with the resource directory that gives a parse clang's builtin
480 headers (:func:`.preproc.builtin_headers.resource_dir_args`); warns once (see
481 :class:`BuiltinHeaderWarnings`) when the headers that ship with the package
482 are wanted but missing."""
483 from sphinx_codelinks.analyse.preproc import builtin_headers
484
485 preproc = self.analyse_config.preprocessor
486 configured = preproc.resource_dir if preproc is not None else None
487 args, unavailable = builtin_headers.resource_dir_args(args, configured)
488 if unavailable and not self.builtin_header_warnings.unavailable:
489 self.builtin_header_warnings.unavailable = True
490 logger.warning(
491 f"codelinks: clang's builtin headers are not under "
492 f"{builtin_headers.BUNDLED_RESOURCE_DIR / 'include'}, so C/C++ files "
493 f"are parsed without them, and an #if on what <stddef.h>, <stdint.h>, "
494 f"<limits.h> or <stdatomic.h> define may take the wrong branch; set "
495 f"resource_dir in [analyse.preprocessor] to a clang resource directory "
496 f"(clang -print-resource-dir)",
497 subtype="builtin_header",
498 )
499 return args
500
501 def _warn_missing_builtin_header(
502 self, path: Path, tu: Any, args: list[str]
503 ) -> None:
504 """Warn, once per parsed file (see :class:`BuiltinHeaderWarnings`), when its
505 parse found no builtin header. When its arguments ``args`` leave clang's
506 builtin headers out (``-nostdinc``), the warning names that flag, which no
507 ``resource_dir`` overrides; otherwise it points at ``resource_dir``."""
508 from sphinx_codelinks.analyse.preproc import builtin_headers, compile_db
509
510 warned = self.builtin_header_warnings.files
511 if path in warned:
512 return
513 missing = builtin_headers.missing_builtin_header(
514 tu, compile_db.working_directory(args)
515 )
516 if missing is None:
517 return
518 warned.add(path)
519 name, file, line = missing
520 where = f" (included at {file}:{line})" if file is not None else ""
521 excluding = builtin_headers.excluding_flag(args)
522 remedy = (
523 f"the compile command's {excluding} leaves clang's builtin headers out"
524 if excluding is not None
525 else "set resource_dir in [analyse.preprocessor] to a clang resource "
526 "directory that has it (clang -print-resource-dir)"
527 )
528 logger.warning(
529 f"codelinks: {path}: clang's builtin header '{name}' was not "
530 f"found{where}, so an #if on what it defines takes its #else branch; "
531 f"{remedy}",
532 subtype="builtin_header",
533 )
534
535 def _includer_candidates(self, db_path: Path, src_path: Path) -> list[Path]:
536 """The main files of ``db_path``'s entries whose ``#include`` lines name
537 ``src_path``, ordered by path (see :mod:`.preproc.includer`)."""
538 from sphinx_codelinks.analyse.preproc import includer
539
540 if db_path not in self._inclusion_index_cache:
541 flags = self._flags_map_for(db_path) or {}
542 self._inclusion_index_cache[db_path] = includer.inclusion_index(flags)
543 return self._inclusion_index_cache[db_path].get(src_path.resolve(), [])
544
545 def _read_through_includers(
546 self, waiting: list[_Waiting]
547 ) -> dict[int, list["LibclangComment"]]:
548 """Read each waiting source through its includer; warn about the rest.
549
550 A source's includer is the first of its candidates, by path, whose parse
551 confirms that its main file includes the source (the lexical scan cannot see
552 ``#if`` arms). The candidates are parsed in path order across every waiting
553 source, each once, and each unit is released before the next is parsed: when
554 a candidate is parsed it is the next candidate of every source that lists
555 it, so it is never needed again. One libclang cannot load confirms nothing.
556
557 :return: The comments read, by the source's position in ``src_files``.
558 """
559 from sphinx_codelinks.analyse.preproc import compile_db, libclang_parser, loader
560
561 translation_unit_load_error = (
562 loader.load_clang_cindex().TranslationUnitLoadError
563 )
564 read: dict[int, list[LibclangComment]] = {}
565 # each unread source's next candidate, by position
566 next_candidate = {w.position: 0 for w in waiting}
567 unread = {w.position: w for w in waiting}
568 while True:
569 heads = {
570 (w.database, w.candidates[next_candidate[position]])
571 for position, w in unread.items()
572 if w.database is not None
573 and next_candidate[position] < len(w.candidates)
574 }
575 if not heads:
576 break
577 database, main = min(
578 heads, key=lambda head: (os.fsencode(head[1]), os.fsencode(head[0]))
579 )
580 args = self._with_builtin_headers(
581 (self._flags_map_for(database) or {})[main]
582 )
583 self.dependencies.update((database, main))
584 try:
585 tu = libclang_parser.parse_translation_unit(main, args)
586 except translation_unit_load_error:
587 logger.debug(
588 f"codelinks: {main} could not be loaded as a translation unit, "
589 f"so it includes no source"
590 )
591 tu = None
592 includes: set[Path] = set()
593 else:
594 self._warn_missing_builtin_header(main, tu, args)
595 working_directory = compile_db.working_directory(args)
596 includes = libclang_parser.main_file_inclusions(tu, working_directory)
597 self.dependencies.update(
598 libclang_parser.included_files(tu, working_directory)
599 )
600 for position, w in list(unread.items()):
601 if w.database != database or next_candidate[position] >= len(
602 w.candidates
603 ):
604 continue
605 if w.candidates[next_candidate[position]] != main:
606 continue
607 if tu is not None and w.path.resolve() in includes:
608 logger.debug(
609 f"codelinks: reading {w.path} through {main}, which includes it"
610 )
611 read[position] = libclang_parser.extract_included_active_comments(
612 tu, w.path
613 )
614 del unread[position]
615 else:
616 next_candidate[position] += 1
617 del tu # released before the next candidate is parsed
618 for w in unread.values():
619 logger.warning(
620 f"codelinks: skipping {w.path} — it has no entry in "
621 f"{w.database} and no entry of it includes it",
622 subtype="not_in_compile_database",
623 )
624 return read
625
[docs] 626 # @Extract traceability objects with the preprocessor-aware libclang engine, IMPL_PREPROC_1, impl, [FE_PREPROC]
627 def create_src_objects_libclang(self) -> None:
628 from sphinx_codelinks.analyse.preproc import (
629 libclang_parser,
630 loader,
631 )
632
633 # Resolve the exception via the loader (never a direct ``import
634 # clang.cindex``) so a missing ``libclang`` extra still surfaces the
635 # loader's friendly install hint rather than a bare ImportError.
636 translation_unit_load_error = (
637 loader.load_clang_cindex().TranslationUnitLoadError
638 )
639 src_files = self.analyse_config.src_files
640 read: dict[int, list[LibclangComment]] = {}
641 # the compiled sources the database has no entry for: read through the entry
642 # that includes them, with that entry's flags, once every other file is read
643 waiting: list[_Waiting] = []
644 for position, src_path in enumerate(src_files):
645 if not utils.is_text_file(src_path):
646 continue
647 args = self._resolve_preproc_args(src_path)
648 if args is None:
649 database = self._compile_db_for(src_path)
650 candidates = (
651 ()
652 if database is None
653 else tuple(self._includer_candidates(database, src_path))
654 )
655 waiting.append(_Waiting(position, src_path, database, candidates))
656 continue
657 args = self._with_builtin_headers(args)
658 try:
659 read[position] = libclang_parser.extract_active_comments(
660 src_path,
661 args,
662 on_parse=lambda tu, path=src_path, args=args: (
663 self._warn_missing_builtin_header(path, tu, args)
664 ),
665 )
666 except translation_unit_load_error:
667 # Last-resort guard. Standalone parses pin ``-x`` to match the
668 # ``-std`` (see defines_to_args), so the common case — a ``.h``/
669 # ``.c`` header handed a C++ ``-std`` — now parses as C++ and its
670 # markers extract. This only fires if libclang still cannot load
671 # the file as a translation unit at all; skip it rather than
672 # aborting the whole run. Surfaced as a ``warning`` so the silent
673 # data-loss (a file's markers dropped) is visible — accepting
674 # that this fails ``sphinx-build -W``.
675 logger.warning(
676 f"codelinks: skipping {src_path} — libclang could not load "
677 f"it as a translation unit"
678 )
679 read.update(self._read_through_includers(waiting))
680
681 for position in sorted(read):
682 src_path = src_files[position]
683 comments = read[position]
684 if not comments:
685 continue
686 # ``c`` is a LibclangComment duck-typing the tree-sitter Node
687 # interface SourceComment reads (``.text`` / ``.start_point.row``);
688 # the Node-only path (find_associated_scope) is guarded by
689 # ``is_libclang`` so it never runs on these. The cast is that duck typing,
690 # which is why ty, seeing two unrelated classes, has to be told.
691 src_comments = [
692 SourceComment(
693 cast("TreeSitterNode", c), # ty: ignore[disjoint-cast]
694 c.column,
695 )
696 for c in comments
697 ]
698 src_file = SourceFile(src_path.absolute())
699 src_file.add_comments(src_comments)
700 if self.analyse_config.get_multiline_needs:
701 # multi-line needs read what precedes a comment on its row from
702 # the row itself
703 text = src_path.read_text(encoding="utf-8", errors="replace")
704 text = text.replace("\r\n", "\n").replace("\r", "\n")
705 src_file.lines = text.split(UNIX_NEWLINE)
706 self.src_files.append(src_file)
707 self.src_comments.extend(src_comments)
708
709 def extract_marker(
710 self,
711 text: str,
712 ) -> Generator[tuple[str, list[str], int, int, int], None, None]:
713 """Yield each reference-marker match of ``text`` with its ids.
714
715 Yields ``(marker, need_ids, row_offset, start_column, end_column)``, the
716 columns spanning the match's own id text. The matches of a line are found by
717 :func:`find_marker_matches`; a match's id text runs from its end to the start
718 of the next match on the line, or to the end of the line. A match with no id
719 yields nothing.
720 """
721 markers = self.analyse_config.need_id_refs_config.markers
722 row_offset = 0
723 for line in text.split(UNIX_NEWLINE):
724 matches = find_marker_matches(line, markers)
725 for index, (marker, _start, end) in enumerate(matches):
726 is_last = index == len(matches) - 1
727 after_marker = (
728 line[end:] if is_last else line[end : matches[index + 1][1]]
729 )
730 markered_text = after_marker.strip()
731 if is_last and markered_text.endswith("*/"):
732 # The end of a one-line block comment, not an id.
733 markered_text = markered_text[:-2].rstrip()
734 need_ids = markered_text.replace(",", " ").split()
735 if not need_ids:
736 continue
737 start_column = end + len(after_marker) - len(after_marker.lstrip())
738 end_column = start_column + len(markered_text)
739 yield marker, need_ids, row_offset, start_column, end_column
740 row_offset += 1
741
[docs] 742 # @Extract need ID references from code comments, IMPL_LNK_1, impl, [FE_LNK]
743 def extract_anchors(
744 self,
745 text: str,
746 filepath: Path,
747 tagged_scope: TreeSitterNode | None,
748 src_comment: SourceComment,
749 first_row: int | None = None,
750 first_column: int | None = None,
751 *,
752 scope: CodeScope | None = None,
753 ) -> list[NeedIdRefs]:
754 """Extract need-ids-refs from a comment.
755
756 ``text`` starts at ``first_row`` and ``first_column`` (by default the
757 comment's own row and column). ``scope`` is the named ``tagged_scope``.
758 """
759 if first_row is None:
760 first_row = src_comment.node.start_point.row
761 if first_column is None:
762 first_column = src_comment.column
763 anchors: list[NeedIdRefs] = []
764 for (
765 marker,
766 need_ids,
767 row_offset,
768 start_column,
769 end_column,
770 ) in self.extract_marker(text):
771 lineno = first_row + row_offset + 1
772 line_column = first_column if row_offset == 0 else 0
773 remote_url = self.git_remote_url
774 if self.git_remote_url and self.git_commit_rev:
775 remote_url = utils.form_https_url(
776 self.git_remote_url,
777 self.git_commit_rev,
778 self.project_path,
779 filepath,
780 lineno,
781 )
782 source_map: SourceMap = {
783 "start": {
784 "row": lineno - 1,
785 "column": line_column + start_column,
786 },
787 "end": {
788 "row": lineno - 1,
789 "column": line_column + end_column,
790 },
791 }
792 anchors.append(
793 NeedIdRefs(
794 filepath,
795 remote_url,
796 source_map,
797 src_comment,
798 tagged_scope,
799 need_ids,
800 marker,
801 scope=scope,
802 )
803 )
804 return anchors
805
806 def _is_need_id_refs_line(self, line: str) -> bool:
807 """Whether a comment line is an ``@need-ids:`` reference rather than a need.
808
809 A line whose text, after comment decoration and whitespace, starts with a
810 configured need-id-refs marker is a reference, and never a one-line need.
811 Without it the default one-line start sequence ``@`` also
812 matched ``// @need-ids: A, B`` and made a need titled ``need-ids: A`` with the id
813 ``B``. A line with anything alphanumeric before the marker is not a reference
814 line here (``// see @need-ids: X`` -- the one-line parser ignores it anyway, and
815 the references on it are still extracted), and a line holding a one-line need
816 before the marker (``[[…]] @need-ids: X``) keeps its need: only a line that
817 STARTS with the marker is withheld from the one-line parser. The marker is the
818 line's first match as :func:`find_marker_matches` finds it, so a word that only
819 begins with a marker (``\\verifiesall`` for ``\\verifies``) is none.
820 """
821 matches = find_marker_matches(
822 line, self.analyse_config.need_id_refs_config.markers, first_only=True
823 )
824 return bool(matches) and not any(
825 _is_alphanumeric(char) for char in line[: matches[0][1]]
826 )
827
828 def extract_oneline_need(
829 self,
830 text: str,
831 src_comment: SourceComment,
832 oneline_comment_style: OneLineCommentStyle,
833 first_row: int | None = None,
834 ) -> Generator[tuple[dict[str, str | list[str] | int], int]]:
835 if first_row is None:
836 first_row = src_comment.node.start_point.row
837 # every line counts as terminated, the last one too
838 lines = [f"{line}{UNIX_NEWLINE}" for line in text.split(UNIX_NEWLINE)]
839 row_offset = 0
840
841 # Only a Python docstring can hold docstring tags; a ``#`` comment never does.
842 in_docstring = (
843 getattr(src_comment.node, "type", None) == CommentCategory.docstring
844 )
845 # Only a TypeScript or JavaScript JSDoc comment can hold JSDoc tags.
846 in_jsdoc = self.analyse_config.comment_type == CommentType.ts and _is_jsdoc(
847 src_comment.text
848 )
849 in_powershell = self.analyse_config.comment_type == CommentType.powershell
850 # Only a VHDL ``--!`` comment, Doxygen's VHDL comment, can hold Doxygen commands
851 # (a ``--!!`` comment is a plain one)
852 in_doxygen = self.analyse_config.comment_type == CommentType.vhdl and (
853 is_doc_comment(src_comment.node, CommentType.vhdl, text=src_comment.text)
854 )
855 for line in lines:
856 if self._is_need_id_refs_line(line):
857 row_offset += 1
858 continue
859 start_sequence = oneline_comment_style.start_sequence
860 if in_powershell and opens_powershell_literal(line, start_sequence):
861 # a hashtable or an array, ``@{ … }`` or ``@( … )``: code, not a marker
862 row_offset += 1
863 continue
864 tag, kind, holders = None, WarningSubTypeEnum.docstring_tag, "docstrings"
865 if in_docstring:
866 tag = docstring_tag(line, start_sequence)
867 elif in_jsdoc and start_sequence == "@":
868 # ``@`` is JSDoc's own sigil: under any other start sequence a JSDoc
869 # line is read as any other line is
870 tag = jsdoc_tag(line, start_sequence)
871 kind, holders = WarningSubTypeEnum.jsdoc_tag, "JSDoc comments"
872 if tag is not None and not oneline_parser(line, oneline_comment_style):
873 # a tag line the one-line parser would ignore stays silent
874 row_offset += 1
875 continue
876 elif in_doxygen and start_sequence == "@":
877 # as for JSDoc: ``@`` is Doxygen's sigil, and a silent line stays silent
878 tag = doxygen_tag(line, start_sequence)
879 kind, holders = WarningSubTypeEnum.doxygen_tag, "Doxygen comments"
880 if tag is not None and not oneline_parser(line, oneline_comment_style):
881 row_offset += 1
882 continue
883 if tag is not None:
884 if src_comment.source_file:
885 self.warnings.append(
886 AnalyseWarning(
887 str(src_comment.source_file.filepath),
888 first_row + row_offset + 1,
889 f"'{start_sequence}{tag}' is a {_TAG_NAMES[kind]}, not a "
890 "one-line need; use a start sequence that "
891 f"{holders} do not contain",
892 MarkedContentType.need,
893 kind.value,
894 )
895 )
896 row_offset += 1
897 continue
898 resolved = oneline_parser(line, oneline_comment_style)
899 if not resolved:
900 row_offset += 1
901 continue
902 if isinstance(resolved, OnelineParserInvalidWarning):
903 if not src_comment.source_file:
904 row_offset += 1
905 continue
906 lineno = first_row + row_offset + 1
907 warning = AnalyseWarning(
908 str(src_comment.source_file.filepath),
909 lineno,
910 resolved.msg,
911 MarkedContentType.need,
912 resolved.sub_type.value,
913 )
914 self.warnings.append(warning)
915 row_offset += 1
916 continue
917 yield resolved, row_offset
918 row_offset += 1
919
[docs] 920 # @Extract one-line traceability needs from comments, IMPL_ONE_1, impl, [FE_DEF, FE_CMT]
921 def extract_oneline_needs(
922 self,
923 text: str,
924 filepath: Path,
925 tagged_scope: TreeSitterNode | None,
926 src_comment: SourceComment,
927 oneline_comment_style: OneLineCommentStyle,
928 first_row: int | None = None,
929 first_column: int | None = None,
930 *,
931 scope: CodeScope | None = None,
932 ) -> list[OneLineNeed]:
933 """Extract the one-line needs of a comment.
934
935 ``text`` starts at ``first_row`` and ``first_column`` (by default the
936 comment's own row and column). ``scope`` is the named ``tagged_scope``.
937 """
938 if first_row is None:
939 first_row = src_comment.node.start_point.row
940 if first_column is None:
941 first_column = src_comment.column
942 row_offset = 0
943 oneline_needs = []
944 for resolved, row_offset in self.extract_oneline_need(
945 text, src_comment, oneline_comment_style, first_row
946 ):
947 lineno = first_row + row_offset + 1
948 line_column = first_column if row_offset == 0 else 0
949 start_column = line_column + cast("int", resolved["start_column"])
950 end_column = line_column + cast("int", resolved["end_column"])
951 remote_url = self.git_remote_url
952 if self.git_remote_url and self.git_commit_rev:
953 remote_url = utils.form_https_url(
954 self.git_remote_url,
955 self.git_commit_rev,
956 self.project_path,
957 filepath,
958 lineno,
959 )
960 source_map: SourceMap = {
961 "start": {
962 "row": lineno - 1,
963 "column": start_column,
964 },
965 "end": {
966 "row": lineno - 1,
967 "column": end_column,
968 },
969 }
970 del resolved["start_column"]
971 del resolved["end_column"]
972 oneline_needs.append(
973 OneLineNeed(
974 filepath,
975 remote_url,
976 source_map,
977 src_comment,
978 tagged_scope,
979 resolved, # int arguments were deleted # ty: ignore[invalid-argument-type]
980 scope=scope,
981 )
982 )
983 return oneline_needs
984
[docs] 985 # @Extract multi-line needs from comment runs, IMPL_MLN_1, impl, [FE_MULTILINE_NEEDS]
986 def extract_multiline_needs(self) -> dict[int, set[int]]:
987 """Extract the multi-line needs of every file, and the rows their blocks claim.
988
989 Runs before the other extractors: the rows a block claims (and those of a
990 refused header, consumed to its close) are hidden from them.
991
992 :return: For each comment holding claimed rows (keyed by ``id()`` of its
993 :class:`SourceComment`), those 0-based rows.
994 """
995 claimed: dict[int, set[int]] = {}
996 config = self.analyse_config.multiline_needs_config
997 for src_file in self.src_files:
998 for run in multiline_parser.form_runs(
999 src_file.src_comments, src_file.lines
1000 ):
1001 result = multiline_parser.parse_run(
1002 run.lines,
1003 config,
1004 leaderless_block=run.leaderless_block,
1005 mixed_leaders=run.mixed_leaders,
1006 )
1007 for issue in result.issues:
1008 self.warnings.append(
1009 AnalyseWarning(
1010 str(src_file.filepath),
1011 issue.row + 1,
1012 issue.msg,
1013 MarkedContentType.multiline_need,
1014 issue.kind.value,
1015 )
1016 )
1017 for block in result.blocks:
1018 self.multiline_needs.append(
1019 self._multiline_need(block, run, src_file.filepath)
1020 )
1021 if not result.claimed_rows:
1022 continue
1023 for src_comment in run.comments:
1024 first = src_comment.node.start_point.row
1025 rows = result.claimed_rows.intersection(
1026 range(first, first + _row_count(src_comment))
1027 )
1028 if rows:
1029 claimed.setdefault(id(src_comment), set()).update(rows)
1030 return claimed
1031
1032 def _multiline_need(
1033 self,
1034 block: multiline_parser.ParsedBlock,
1035 run: multiline_parser.CommentRun,
1036 filepath: Path,
1037 ) -> MultilineNeed:
1038 """The record of one parsed block (see :class:`MultilineNeed`)."""
1039 src_comment = next(
1040 (
1041 comment
1042 for comment in run.comments
1043 if comment.node.start_point.row
1044 <= block.open_row
1045 < comment.node.start_point.row + _row_count(comment)
1046 ),
1047 run.comments[0],
1048 )
1049 tagged_scope, scope, unattached = self._associate(src_comment, filepath)
1050 lineno = block.open_row + 1
1051 remote_url = self.git_remote_url
1052 if self.git_remote_url and self.git_commit_rev:
1053 remote_url = utils.form_https_url(
1054 self.git_remote_url,
1055 self.git_commit_rev,
1056 self.project_path,
1057 filepath,
1058 lineno,
1059 )
1060 source_map: SourceMap = {
1061 "start": {"row": block.open_row, "column": block.open_col},
1062 "end": {"row": block.close_row, "column": block.end_col},
1063 }
1064 content_start = (
1065 {"line": block.content_start[0] + 1, "col": block.content_start[1]}
1066 if block.content_start is not None
1067 else None
1068 )
1069 source: dict[str, Any] = {
1070 "project": self.name,
1071 "path": _relative_posix(filepath, self.project_path),
1072 "root": "git" if self.git_root is not None else "src_dir",
1073 "commit": self.git_commit_rev,
1074 "start": {"line": lineno, "col": block.open_col},
1075 "end": {"line": block.close_row + 1, "col": block.end_col},
1076 "content_start": content_start,
1077 "option_lines": dict(block.option_rows),
1078 "scope": scope,
1079 }
1080 need = MultilineNeed(
1081 filepath,
1082 remote_url,
1083 source_map,
1084 src_comment,
1085 tagged_scope,
1086 block.need,
1087 block.markup,
1088 source,
1089 scope=scope,
1090 )
1091 self._warn_unattached(unattached, [need])
1092 return need
1093
1094 def _associate(
1095 self, src_comment: SourceComment, filepath: Path
1096 ) -> tuple[TreeSitterNode | None, CodeScope | None, utils.Unattached | None]:
1097 """The entity a comment of ``filepath`` is attached to: its node and its
1098 ``scope`` object, and why it is attached to none where the analysis warns
1099 about that (:class:`~sphinx_codelinks.analyse.utils.Unattached`).
1100
1101 A libclang comment has no tree-sitter node, so it is attached to nothing.
1102 """
1103 if getattr(src_comment.node, "is_libclang", False):
1104 return None, None, None
1105 comment_type = self.analyse_config.comment_type
1106 association = utils.associate(
1107 src_comment.node, comment_type, text=src_comment.text
1108 )
1109 if association is None:
1110 return None, None, None
1111 if isinstance(association, utils.Unattached):
1112 return None, None, association
1113 source_file = src_comment.source_file
1114 written = (
1115 source_file.node_text(association.node)
1116 if source_file is not None
1117 else association.node.text
1118 )
1119 if not written:
1120 return None, None, None
1121 return (
1122 association.node,
1123 code_scope(
1124 association,
1125 comment_type,
1126 scope_language(comment_type, filepath),
1127 source_file.cpp_names if source_file is not None else None,
1128 ),
1129 None,
1130 )
1131
1132 def _warn_unattached(
1133 self,
1134 unattached: utils.Unattached | None,
1135 records: Iterable[NeedIdRefs | OneLineNeed | MultilineNeed],
1136 ) -> None:
1137 """Warn about each marker in ``records`` that rule 7 attached to no entity, at
1138 its line (a multi-line need's open line)."""
1139 if unattached is not utils.Unattached.above_conditional:
1140 return
1141 for record in records:
1142 self.warnings.append(
1143 AnalyseWarning(
1144 str(record.filepath),
1145 record.source_map["start"]["row"] + 1,
1146 ABOVE_CONDITIONAL_MSG,
1147 record.type,
1148 WarningSubTypeEnum.marker_above_conditional.value,
1149 )
1150 )
1151
1152 def extract_marked_content(self) -> None:
1153 claimed: dict[int, set[int]] = {}
1154 if self.analyse_config.get_multiline_needs:
1155 claimed = self.extract_multiline_needs()
1156 for src_comment in self.src_comments:
1157 if not src_comment.text:
1158 continue
1159 filepath = (
1160 src_comment.source_file.filepath if src_comment.source_file else None
1161 )
1162 if not filepath:
1163 continue
1164 tagged_scope, scope, unattached = self._associate(src_comment, filepath)
1165 for text, first_row, first_column in _scanned_texts(
1166 src_comment, claimed.get(id(src_comment))
1167 ):
1168 if self.analyse_config.get_need_id_refs:
1169 anchors = self.extract_anchors(
1170 text,
1171 filepath,
1172 tagged_scope,
1173 src_comment,
1174 first_row,
1175 first_column,
1176 scope=scope,
1177 )
1178 self._warn_unattached(unattached, anchors)
1179 self.need_id_refs.extend(anchors)
1180 if self.analyse_config.get_oneline_needs:
1181 oneline_needs = self.extract_oneline_needs(
1182 text,
1183 filepath,
1184 tagged_scope,
1185 src_comment,
1186 self.analyse_config.oneline_comment_style,
1187 first_row,
1188 first_column,
1189 scope=scope,
1190 )
1191 self._warn_unattached(unattached, oneline_needs)
1192 self.oneline_needs.extend(oneline_needs)
1193
1194 def merge_marked_content(self) -> None:
1195 """List the marked content in source order: by file, row and column (#2150).
1196
1197 Tree-sitter hands a file's comments over in an order that differs between
1198 runs, so the column is what keeps two entries of one row in a stable order.
1199 ``oneline_needs`` is sorted too, by row, then file, then column: ``src-trace``
1200 creates and renders the needs in its order, so of two markers with one id in
1201 one file the leftmost is the one created, and on one row the needs of two files
1202 keep the file order they had before the column was a key -- for a caller that
1203 passes the files in discovery order, as every production caller does.
1204 """
1205 # The file component is the key discovery sorts the files by
1206 # (``SourceDiscover``), not the ``Path``: a ``Path`` compares by parts, so
1207 # ``a/b.cpp`` would sort before ``a-c.cpp``, which discovery lists first.
1208 self.oneline_needs.sort(
1209 key=lambda x: (
1210 x.source_map["start"]["row"],
1211 os.path.normcase(os.path.normpath(x.filepath)),
1212 x.source_map["start"]["column"],
1213 )
1214 )
1215 self.all_marked_content.extend(self.need_id_refs)
1216 self.all_marked_content.extend(self.oneline_needs)
1217 self.all_marked_content.extend(self.multiline_needs)
1218 self.all_marked_content.sort(
1219 key=lambda x: (
1220 x.filepath,
1221 x.source_map["start"]["row"],
1222 x.source_map["start"]["column"],
1223 _KIND_RANK[x.type],
1224 )
1225 )
1226
1227 def dump_marked_content(self, outdir: Path) -> None:
1228 output_path = outdir / "marked_content.json"
1229 output_path.parent.mkdir(parents=True, exist_ok=True)
1230 to_dump = [
1231 marked_content.to_dict() for marked_content in self.all_marked_content
1232 ]
1233 with output_path.open("w") as f:
1234 json.dump(to_dump, f)
1235
1236 def run(self, *, log_summary: bool = True) -> None:
1237 """Parse the source files and extract their markers.
1238
1239 :param log_summary: Whether to print the per-project summary line; a caller
1240 that prints its own (the configuration pass of the Sphinx extension) passes
1241 ``False``.
1242 """
1243 if (
1244 self.analyse_config.preprocessor is not None
1245 and self.analyse_config.comment_type == CommentType.cpp
1246 ):
1247 self.create_src_objects_libclang()
1248 else:
1249 self.create_src_objects()
1250 self.extract_marked_content()
1251 self.merge_marked_content()
1252 if log_summary:
1253 self.log_summary()
1254
1255 def log_summary(self, extra: str = "") -> None:
1256 """Emit a per-project marker (default-visible) plus a -v breakdown.
1257
1258 :param extra: Appended to the default-visible line (the ``src-trace``
1259 directive's count of the one-line needs it did not create).
1260 """
1261 label = f"codelinks [{self.name}]" if self.name else "codelinks"
1262 logger.info(
1263 f"{label}: {_count(len(self.src_files), 'file')}, "
1264 f"{_count(len(self.all_marked_content), 'marker')}{extra}"
1265 )
1266 logger.debug(
1267 f"{label}: {_count(len(self.src_comments), 'comment')}, "
1268 f"{_count(len(self.oneline_needs), 'oneline need')}, "
1269 f"{_count(len(self.need_id_refs), 'id-ref')}, "
1270 f"{_count(len(self.multiline_needs), 'multi-line need')}"
1271 )