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