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        )