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