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. The cast is that duck typing,
367 # which is why ty, seeing two unrelated classes, has to be told.
368 src_comments = [
369 SourceComment(
370 cast("TreeSitterNode", c), # ty: ignore[disjoint-cast]
371 c.column,
372 )
373 for c in comments
374 ]
375 src_file = SourceFile(src_path.absolute())
376 src_file.add_comments(src_comments)
377 if self.analyse_config.get_multiline_needs:
378 # multi-line needs read what precedes a comment on its row from
379 # the row itself
380 text = src_path.read_text(encoding="utf-8", errors="replace")
381 text = text.replace("\r\n", "\n").replace("\r", "\n")
382 src_file.lines = text.split(UNIX_NEWLINE)
383 self.src_files.append(src_file)
384 self.src_comments.extend(src_comments)
385
386 def extract_marker(
387 self,
388 text: str,
389 ) -> Generator[tuple[str, list[str], int, int, int], None, None]:
390 row_offset = 0
391 for line in text.split(UNIX_NEWLINE):
392 for marker in self.analyse_config.need_id_refs_config.markers:
393 marker_idx = line.find(marker)
394 if marker_idx == -1:
395 continue
396 after_marker = line[marker_idx + len(marker) :]
397 markered_text = after_marker.strip()
398 if markered_text.endswith("*/"):
399 # The end of a one-line block comment, not an id.
400 markered_text = markered_text[:-2].rstrip()
401 need_ids = markered_text.replace(",", " ").split()
402 if not need_ids:
403 continue
404 start_column = (
405 marker_idx
406 + len(marker)
407 + len(after_marker)
408 - len(after_marker.lstrip())
409 )
410 end_column = start_column + len(markered_text)
411 yield marker, need_ids, row_offset, start_column, end_column
412 row_offset += 1
413
[docs]414 # @Extract need ID references from code comments, IMPL_LNK_1, impl, [FE_LNK]
415 def extract_anchors(
416 self,
417 text: str,
418 filepath: Path,
419 tagged_scope: TreeSitterNode | None,
420 src_comment: SourceComment,
421 first_row: int | None = None,
422 first_column: int | None = None,
423 ) -> list[NeedIdRefs]:
424 """Extract need-ids-refs from a comment.
425
426 ``text`` starts at ``first_row`` and ``first_column`` (by default the
427 comment's own row and column).
428 """
429 if first_row is None:
430 first_row = src_comment.node.start_point.row
431 if first_column is None:
432 first_column = src_comment.column
433 anchors: list[NeedIdRefs] = []
434 for (
435 marker,
436 need_ids,
437 row_offset,
438 start_column,
439 end_column,
440 ) in self.extract_marker(text):
441 lineno = first_row + row_offset + 1
442 line_column = first_column if row_offset == 0 else 0
443 remote_url = self.git_remote_url
444 if self.git_remote_url and self.git_commit_rev:
445 remote_url = utils.form_https_url(
446 self.git_remote_url,
447 self.git_commit_rev,
448 self.project_path,
449 filepath,
450 lineno,
451 )
452 source_map: SourceMap = {
453 "start": {
454 "row": lineno - 1,
455 "column": line_column + start_column,
456 },
457 "end": {
458 "row": lineno - 1,
459 "column": line_column + end_column,
460 },
461 }
462 anchors.append(
463 NeedIdRefs(
464 filepath,
465 remote_url,
466 source_map,
467 src_comment,
468 tagged_scope,
469 need_ids,
470 marker,
471 )
472 )
473 return anchors
474
475 def _is_need_id_refs_line(self, line: str) -> bool:
476 """Whether a comment line is an ``@need-ids:`` reference rather than a need.
477
478 A line whose text, after comment decoration and whitespace, starts with a
479 configured need-id-refs marker is a reference, and never a one-line need --
480 ubCode's precedence. Without it the default one-line start sequence ``@`` also
481 matched ``// @need-ids: A, B`` and made a need titled ``need-ids: A`` with the id
482 ``B``. A line with anything alphanumeric before the marker is not a reference
483 line here (``// see @need-ids: X`` -- the one-line parser ignores it anyway, and
484 the references on it are still extracted), and a line holding a one-line need
485 before the marker (``[[…]] @need-ids: X``) keeps its need: only a line that
486 STARTS with the marker is withheld from the one-line parser.
487 """
488 for marker in self.analyse_config.need_id_refs_config.markers:
489 marker_idx = line.find(marker)
490 if marker_idx != -1 and not any(
491 char.isalnum() for char in line[:marker_idx]
492 ):
493 return True
494 return False
495
496 def extract_oneline_need(
497 self,
498 text: str,
499 src_comment: SourceComment,
500 oneline_comment_style: OneLineCommentStyle,
501 first_row: int | None = None,
502 ) -> Generator[tuple[dict[str, str | list[str] | int], int]]:
503 if first_row is None:
504 first_row = src_comment.node.start_point.row
505 # every line counts as terminated, the last one too
506 lines = [f"{line}{UNIX_NEWLINE}" for line in text.split(UNIX_NEWLINE)]
507 row_offset = 0
508
509 # Only a Python docstring can hold docstring tags; a ``#`` comment never does.
510 in_docstring = (
511 getattr(src_comment.node, "type", None) == CommentCategory.docstring
512 )
513 # Only a TypeScript or JavaScript JSDoc comment can hold JSDoc tags.
514 in_jsdoc = self.analyse_config.comment_type == CommentType.ts and _is_jsdoc(
515 src_comment.node
516 )
517 for line in lines:
518 if self._is_need_id_refs_line(line):
519 row_offset += 1
520 continue
521 start_sequence = oneline_comment_style.start_sequence
522 tag, kind, holders = None, WarningSubTypeEnum.docstring_tag, "docstrings"
523 if in_docstring:
524 tag = docstring_tag(line, start_sequence)
525 elif in_jsdoc and start_sequence == "@":
526 # ``@`` is JSDoc's own sigil: under any other start sequence a JSDoc
527 # line is read as any other line is
528 tag = jsdoc_tag(line, start_sequence)
529 kind, holders = WarningSubTypeEnum.jsdoc_tag, "JSDoc comments"
530 if tag is not None and not oneline_parser(line, oneline_comment_style):
531 # a tag line the one-line parser would ignore stays silent
532 row_offset += 1
533 continue
534 if tag is not None:
535 if src_comment.source_file:
536 self.warnings.append(
537 AnalyseWarning(
538 str(src_comment.source_file.filepath),
539 first_row + row_offset + 1,
540 f"'{start_sequence}{tag}' is a {_TAG_NAMES[kind]}, not a "
541 "one-line need; use a start sequence that "
542 f"{holders} do not contain",
543 MarkedContentType.need,
544 kind.value,
545 )
546 )
547 row_offset += 1
548 continue
549 resolved = oneline_parser(line, oneline_comment_style)
550 if not resolved:
551 row_offset += 1
552 continue
553 if isinstance(resolved, OnelineParserInvalidWarning):
554 if not src_comment.source_file:
555 row_offset += 1
556 continue
557 lineno = first_row + row_offset + 1
558 warning = AnalyseWarning(
559 str(src_comment.source_file.filepath),
560 lineno,
561 resolved.msg,
562 MarkedContentType.need,
563 resolved.sub_type.value,
564 )
565 self.warnings.append(warning)
566 row_offset += 1
567 continue
568 yield resolved, row_offset
569 row_offset += 1
570
[docs]571 # @Extract one-line traceability needs from comments, IMPL_ONE_1, impl, [FE_DEF, FE_CMT]
572 def extract_oneline_needs(
573 self,
574 text: str,
575 filepath: Path,
576 tagged_scope: TreeSitterNode | None,
577 src_comment: SourceComment,
578 oneline_comment_style: OneLineCommentStyle,
579 first_row: int | None = None,
580 first_column: int | None = None,
581 ) -> list[OneLineNeed]:
582 """Extract the one-line needs of a comment.
583
584 ``text`` starts at ``first_row`` and ``first_column`` (by default the
585 comment's own row and column).
586 """
587 if first_row is None:
588 first_row = src_comment.node.start_point.row
589 if first_column is None:
590 first_column = src_comment.column
591 row_offset = 0
592 oneline_needs = []
593 for resolved, row_offset in self.extract_oneline_need(
594 text, src_comment, oneline_comment_style, first_row
595 ):
596 lineno = first_row + row_offset + 1
597 line_column = first_column if row_offset == 0 else 0
598 start_column = line_column + cast("int", resolved["start_column"])
599 end_column = line_column + cast("int", resolved["end_column"])
600 remote_url = self.git_remote_url
601 if self.git_remote_url and self.git_commit_rev:
602 remote_url = utils.form_https_url(
603 self.git_remote_url,
604 self.git_commit_rev,
605 self.project_path,
606 filepath,
607 lineno,
608 )
609 source_map: SourceMap = {
610 "start": {
611 "row": lineno - 1,
612 "column": start_column,
613 },
614 "end": {
615 "row": lineno - 1,
616 "column": end_column,
617 },
618 }
619 del resolved["start_column"]
620 del resolved["end_column"]
621 oneline_needs.append(
622 OneLineNeed(
623 filepath,
624 remote_url,
625 source_map,
626 src_comment,
627 tagged_scope,
628 resolved, # int arguments were deleted # ty: ignore[invalid-argument-type]
629 )
630 )
631 return oneline_needs
632
[docs]633 # @Extract multi-line needs from comment runs, IMPL_MLN_1, impl, [FE_MULTILINE_NEEDS]
634 def extract_multiline_needs(self) -> dict[int, set[int]]:
635 """Extract the multi-line needs of every file, and the rows their blocks claim.
636
637 Runs before the other extractors: the rows a block claims (and those of a
638 refused header, consumed to its close) are hidden from them.
639
640 :return: For each comment holding claimed rows (keyed by ``id()`` of its
641 :class:`SourceComment`), those 0-based rows.
642 """
643 claimed: dict[int, set[int]] = {}
644 config = self.analyse_config.multiline_needs_config
645 for src_file in self.src_files:
646 for run in multiline_parser.form_runs(
647 src_file.src_comments, src_file.lines
648 ):
649 result = multiline_parser.parse_run(
650 run.lines,
651 config,
652 leaderless_block=run.leaderless_block,
653 mixed_leaders=run.mixed_leaders,
654 )
655 for issue in result.issues:
656 self.warnings.append(
657 AnalyseWarning(
658 str(src_file.filepath),
659 issue.row + 1,
660 issue.msg,
661 MarkedContentType.multiline_need,
662 issue.kind.value,
663 )
664 )
665 for block in result.blocks:
666 self.multiline_needs.append(
667 self._multiline_need(block, run, src_file.filepath)
668 )
669 if not result.claimed_rows:
670 continue
671 for src_comment in run.comments:
672 first = src_comment.node.start_point.row
673 rows = result.claimed_rows.intersection(
674 range(first, first + _row_count(src_comment))
675 )
676 if rows:
677 claimed.setdefault(id(src_comment), set()).update(rows)
678 return claimed
679
680 def _multiline_need(
681 self,
682 block: multiline_parser.ParsedBlock,
683 run: multiline_parser.CommentRun,
684 filepath: Path,
685 ) -> MultilineNeed:
686 """The record of one parsed block (see :class:`MultilineNeed`)."""
687 src_comment = next(
688 (
689 comment
690 for comment in run.comments
691 if comment.node.start_point.row
692 <= block.open_row
693 < comment.node.start_point.row + _row_count(comment)
694 ),
695 run.comments[0],
696 )
697 tagged_scope: TreeSitterNode | None = None
698 if not getattr(src_comment.node, "is_libclang", False):
699 tagged_scope = utils.find_associated_scope(
700 src_comment.node, self.analyse_config.comment_type
701 )
702 lineno = block.open_row + 1
703 remote_url = self.git_remote_url
704 if self.git_remote_url and self.git_commit_rev:
705 remote_url = utils.form_https_url(
706 self.git_remote_url,
707 self.git_commit_rev,
708 self.project_path,
709 filepath,
710 lineno,
711 )
712 source_map: SourceMap = {
713 "start": {"row": block.open_row, "column": block.open_col},
714 "end": {"row": block.close_row, "column": block.end_col},
715 }
716 content_start = (
717 {"line": block.content_start[0] + 1, "col": block.content_start[1]}
718 if block.content_start is not None
719 else None
720 )
721 scope = (
722 {
723 "kind": tagged_scope.type,
724 "start": tagged_scope.start_point.row + 1,
725 "end": tagged_scope.end_point.row + 1,
726 }
727 if tagged_scope is not None
728 else None
729 )
730 source: dict[str, Any] = {
731 "project": self.name,
732 "path": _relative_posix(filepath, self.project_path),
733 "root": "git" if self.git_root is not None else "src_dir",
734 "commit": self.git_commit_rev,
735 "start": {"line": lineno, "col": block.open_col},
736 "end": {"line": block.close_row + 1, "col": block.end_col},
737 "content_start": content_start,
738 "option_lines": dict(block.option_rows),
739 "scope": scope,
740 }
741 return MultilineNeed(
742 filepath,
743 remote_url,
744 source_map,
745 src_comment,
746 tagged_scope,
747 block.need,
748 block.markup,
749 source,
750 )
751
752 def extract_marked_content(self) -> None:
753 claimed: dict[int, set[int]] = {}
754 if self.analyse_config.get_multiline_needs:
755 claimed = self.extract_multiline_needs()
756 for src_comment in self.src_comments:
757 if not src_comment.node.text:
758 continue
759 filepath = (
760 src_comment.source_file.filepath if src_comment.source_file else None
761 )
762 if not filepath:
763 continue
764 if getattr(src_comment.node, "is_libclang", False):
765 tagged_scope: TreeSitterNode | None = None
766 else:
767 tagged_scope = utils.find_associated_scope(
768 src_comment.node, self.analyse_config.comment_type
769 )
770 for text, first_row, first_column in _scanned_texts(
771 src_comment, claimed.get(id(src_comment))
772 ):
773 if self.analyse_config.get_need_id_refs:
774 self.need_id_refs.extend(
775 self.extract_anchors(
776 text,
777 filepath,
778 tagged_scope,
779 src_comment,
780 first_row,
781 first_column,
782 )
783 )
784 if self.analyse_config.get_oneline_needs:
785 self.oneline_needs.extend(
786 self.extract_oneline_needs(
787 text,
788 filepath,
789 tagged_scope,
790 src_comment,
791 self.analyse_config.oneline_comment_style,
792 first_row,
793 first_column,
794 )
795 )
796
797 def merge_marked_content(self) -> None:
798 """List the marked content in source order: by file, row and column (#2150).
799
800 Tree-sitter hands a file's comments over in an order that differs between
801 runs, so the column is what keeps two entries of one row in a stable order.
802 ``oneline_needs`` is sorted too, by row, then file, then column: ``src-trace``
803 creates and renders the needs in its order, so of two markers with one id in
804 one file the leftmost is the one created, and on one row the needs of two files
805 keep the file order they had before the column was a key -- for a caller that
806 passes the files in discovery order, as every production caller does.
807 """
808 # The file component is the key discovery sorts the files by
809 # (``SourceDiscover``), not the ``Path``: a ``Path`` compares by parts, so
810 # ``a/b.cpp`` would sort before ``a-c.cpp``, which discovery lists first.
811 self.oneline_needs.sort(
812 key=lambda x: (
813 x.source_map["start"]["row"],
814 os.path.normcase(os.path.normpath(x.filepath)),
815 x.source_map["start"]["column"],
816 )
817 )
818 self.all_marked_content.extend(self.need_id_refs)
819 self.all_marked_content.extend(self.oneline_needs)
820 self.all_marked_content.extend(self.multiline_needs)
821 self.all_marked_content.sort(
822 key=lambda x: (
823 x.filepath,
824 x.source_map["start"]["row"],
825 x.source_map["start"]["column"],
826 _KIND_RANK[x.type],
827 )
828 )
829
830 def dump_marked_content(self, outdir: Path) -> None:
831 output_path = outdir / "marked_content.json"
832 output_path.parent.mkdir(parents=True, exist_ok=True)
833 to_dump = [
834 marked_content.to_dict() for marked_content in self.all_marked_content
835 ]
836 with output_path.open("w") as f:
837 json.dump(to_dump, f)
838
839 def run(self, *, log_summary: bool = True) -> None:
840 """Parse the source files and extract their markers.
841
842 :param log_summary: Whether to print the per-project summary line; a caller
843 that prints its own (the configuration pass of the Sphinx extension) passes
844 ``False``.
845 """
846 if (
847 self.analyse_config.preprocessor is not None
848 and self.analyse_config.comment_type == CommentType.cpp
849 ):
850 self.create_src_objects_libclang()
851 else:
852 self.create_src_objects()
853 self.extract_marked_content()
854 self.merge_marked_content()
855 if log_summary:
856 self.log_summary()
857
858 def log_summary(self, extra: str = "") -> None:
859 """Emit a per-project marker (default-visible) plus a -v breakdown.
860
861 :param extra: Appended to the default-visible line (the ``src-trace``
862 directive's count of the one-line needs it did not create).
863 """
864 label = f"codelinks [{self.name}]" if self.name else "codelinks"
865 logger.info(
866 f"{label}: {_count(len(self.src_files), 'file')}, "
867 f"{_count(len(self.all_marked_content), 'marker')}{extra}"
868 )
869 logger.debug(
870 f"{label}: {_count(len(self.src_comments), 'comment')}, "
871 f"{_count(len(self.oneline_needs), 'oneline need')}, "
872 f"{_count(len(self.need_id_refs), 'id-ref')}, "
873 f"{_count(len(self.multiline_needs), 'multi-line need')}"
874 )