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