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        )