1import json
   2import os
   3from collections.abc import Generator, Iterable
   4from dataclasses import dataclass, field
   5from pathlib import Path
   6from typing import TYPE_CHECKING, 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.entity import (
  13    CodeScope,
  14    code_scope,
  15    is_doc_comment,
  16    scope_language,
  17)
  18from sphinx_codelinks.analyse.models import (
  19    MarkedContentType,
  20    MultilineNeed,
  21    NeedIdRefs,
  22    OneLineNeed,
  23    SourceComment,
  24    SourceFile,
  25    SourceMap,
  26    WarningSubTypeEnum,
  27)
  28from sphinx_codelinks.analyse.oneline_parser import (
  29    OnelineParserInvalidWarning,
  30    docstring_tag,
  31    doxygen_tag,
  32    jsdoc_tag,
  33    oneline_parser,
  34    opens_powershell_literal,
  35)
  36from sphinx_codelinks.analyse.references import _relative_posix
  37from sphinx_codelinks.config import (
  38    UNIX_NEWLINE,
  39    CommentCategory,
  40    OneLineCommentStyle,
  41    SourceAnalyseConfig,
  42)
  43from sphinx_codelinks.logger import get_logger
  44from sphinx_codelinks.source_discover.config import CommentType
  45
  46if TYPE_CHECKING:
  47    from sphinx_codelinks.analyse.preproc.libclang_parser import LibclangComment
  48
  49logger = get_logger(__name__)
  50
  51# The last key of the marked content's sort. Two entries can start at one position
  52# only with overlapping markers (a need-id marker that ends with the one-line start
  53# sequence), which the configuration check does not refuse. Such a tie is listed
  54# references, then one-line needs, then multi-line needs: an explicit rank, because
  55# the type names sort the other way round. Two references never tie: the marker
  56# matches of a line do not overlap (``find_marker_matches``).
  57_KIND_RANK: dict[MarkedContentType, int] = {
  58    MarkedContentType.need_id_refs: 0,
  59    MarkedContentType.need: 1,
  60    MarkedContentType.multiline_need: 2,
  61}
  62
  63
  64def _is_alphanumeric(char: str) -> bool:
  65    """Whether ``char``'s Unicode general category is a letter (L*) or a number (N*).
  66
  67    The one notion of alphanumeric of the reference markers' rules, shared with another
  68    implementation of them. ``str.isalnum`` is exactly that test (no other character
  69    passes it): characters Unicode calls Alphabetic without being letters -- a circled
  70    letter (So), a combining mark (Mn, Mc) -- are not alphanumeric here. The
  71    classification follows each tool's own Unicode tables (here the running Python's,
  72    ``unicodedata.unidata_version``), so a character newer than the older of two tables
  73    can be classed differently.
  74    """
  75    return char.isalnum()
  76
  77
  78def _is_word_char(char: str) -> bool:
  79    """A word character, for a reference marker's boundary: alphanumeric (see
  80    :func:`_is_alphanumeric`) or ``_``."""
  81    return _is_alphanumeric(char) or char == "_"
  82
  83
  84def _find_marker(line: str, marker: str, start: int) -> int:
  85    """The index of the first match of ``marker`` in ``line`` at or after ``start``.
  86
  87    A marker whose last character is a word character matches only where the
  88    character after it is not one (or the line ends): ``\\verifies`` does not match
  89    ``\\verifiesall``. No condition applies before the match. ``-1`` when none.
  90    """
  91    word_final = _is_word_char(marker[-1])
  92    idx = line.find(marker, start)
  93    while idx != -1:
  94        after = idx + len(marker)
  95        if not (word_final and after < len(line) and _is_word_char(line[after])):
  96            return idx
  97        idx = line.find(marker, idx + 1)
  98    return -1
  99
 100
 101def find_marker_matches(
 102    line: str, markers: Iterable[str], *, first_only: bool = False
 103) -> list[tuple[str, int, int]]:
 104    """The reference-marker matches of one line, left to right: ``(marker, start, end)``.
 105
 106    The line is scanned from left to right. At each index, among the markers that
 107    match there (see :func:`_find_marker`), the LONGEST wins, and the scan resumes at
 108    the end of that match, so matches never overlap. The same marker can match more
 109    than once. An empty marker never matches.
 110
 111    :param line: One line of a comment.
 112    :param markers: The configured reference markers.
 113    :param first_only: Stop after the first match.
 114    """
 115    candidates = [marker for marker in markers if marker]
 116    matches: list[tuple[str, int, int]] = []
 117    pos = 0
 118    while candidates:
 119        best: tuple[int, str] | None = None
 120        for marker in candidates:
 121            idx = _find_marker(line, marker, pos)
 122            if idx == -1:
 123                continue
 124            if (
 125                best is None
 126                or idx < best[0]
 127                or (idx == best[0] and len(marker) > len(best[1]))
 128            ):
 129                best = (idx, marker)
 130        if best is None:
 131            break
 132        idx, marker = best
 133        matches.append((marker, idx, idx + len(marker)))
 134        if first_only:
 135            break
 136        pos = idx + len(marker)
 137    return matches
 138
 139
 140def _char_column(src: bytes, byte_offset: int) -> int:
 141    """The column, in characters, of ``byte_offset`` in the UTF-8 ``src``."""
 142    line_start = src.rfind(b"\n", 0, byte_offset) + 1
 143    return len(src[line_start:byte_offset].decode("utf-8", errors="replace"))
 144
 145
 146def _count(n: int, noun: str) -> str:
 147    """Format ``n noun`` with a naive (append-s) plural for progress summaries."""
 148    return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
 149
 150
 151def _row_count(src_comment: SourceComment) -> int:
 152    """The rows a comment covers: a Rust ``///`` node's text ends with its newline, which
 153    starts no row of its own."""
 154    return src_comment.text.rstrip(b"\n").count(b"\n") + 1
 155
 156
 157#: The message of a ``marker_above_conditional`` warning.
 158ABOVE_CONDITIONAL_MSG = (
 159    "directly above a preprocessor conditional, so attached to no entity: "
 160    "put the marker inside the arm, above the definition"
 161)
 162
 163#: How a tag warning names the tag it found.
 164_TAG_NAMES = {
 165    WarningSubTypeEnum.docstring_tag: "docstring tag",
 166    WarningSubTypeEnum.jsdoc_tag: "JSDoc tag",
 167    WarningSubTypeEnum.doxygen_tag: "Doxygen tag",
 168}
 169
 170
 171def _is_jsdoc(text: bytes) -> bool:
 172    """Whether the comment ``text`` is a JSDoc comment: a block comment opened by
 173    exactly ``/**``, as JSDoc reads one (``/***`` and the empty ``/**/`` are not)."""
 174    return (
 175        text.startswith(b"/**")
 176        and not text.startswith(b"/***")
 177        and not text.startswith(b"/**/")
 178    )
 179
 180
 181def _docstring_contents(
 182    node: TreeSitterNode, raw: bytes, column: int
 183) -> list[tuple[str, int, int]]:
 184    """The content of each string of a docstring statement, between its quotes.
 185
 186    Each with its 0-based row and its column, in characters: the quotes' column plus
 187    the prefix letters' and the delimiter's length. ``raw`` is the statement's text as
 188    the file writes it, ``column`` its column.
 189    """
 190    contents = []
 191    for string in node.named_children:
 192        if string.type != "string":
 193            continue
 194        kinds = {child.type: child for child in string.children}
 195        start, end = kinds.get("string_start"), kinds.get("string_end")
 196        if start is None or end is None:
 197            continue
 198        begin = start.end_byte - node.start_byte
 199        before = raw[:begin]
 200        row_start = before.rfind(b"\n") + 1
 201        content_column = len(before[row_start:].decode("utf-8", errors="replace"))
 202        if row_start == 0:
 203            content_column += column
 204        contents.append(
 205            (
 206                raw[begin : end.start_byte - node.start_byte].decode(
 207                    "utf-8", errors="replace"
 208                ),
 209                start.end_point.row,
 210                content_column,
 211            )
 212        )
 213    return contents
 214
 215
 216def _scanned_texts(
 217    src_comment: SourceComment, claimed_rows: set[int] | None
 218) -> list[tuple[str, int, int]]:
 219    """What the one-line and reference extractors scan of a comment.
 220
 221    Each text comes with the 0-based row and the column, in characters, at which it
 222    starts. A docstring is scanned as its content, without the quotes; a block
 223    comment without its closing ``*/``, a PowerShell ``<# … #>`` comment without its
 224    ``#>``, and a legacy ``<!-- … -->`` comment without its ``-->``, which are not
 225    marker text. The rows a multi-line need claims are blanked.
 226    """
 227    node = src_comment.node
 228    if getattr(node, "type", None) == CommentCategory.docstring:
 229        texts = _docstring_contents(node, src_comment.text, src_comment.column)
 230    else:
 231        text = src_comment.text.decode("utf-8")
 232        if (text.startswith("/*") and text.endswith("*/")) or (
 233            text.startswith("<#") and text.endswith("#>")
 234        ):
 235            text = text[:-2]
 236        elif text.startswith("<!--") and text.endswith("-->"):
 237            text = text[:-3]
 238        texts = [(text, node.start_point.row, src_comment.column)]
 239    if claimed_rows:
 240        # a block's lines are its own: no one-line need and no reference in them
 241        texts = [
 242            (multiline_parser.blank_rows(text, row, claimed_rows), row, column)
 243            for text, row, column in texts
 244        ]
 245    return texts
 246
 247
 248@dataclass
 249class AnalyseWarning:
 250    file_path: str
 251    lineno: int
 252    msg: str
 253    type: str
 254    sub_type: str
 255
 256
 257@dataclass
 258class BuiltinHeaderWarnings:
 259    """What the libclang engine has already warned about clang's builtin headers.
 260
 261    One object shared by several analyses makes each warning once across all of them:
 262    the CLI shares one across its projects, a Sphinx build one across its directives
 263    and its scan of config-only projects.
 264    """
 265
 266    files: set[Path] = field(default_factory=set)
 267    """The parsed files already warned about a builtin header they could not find."""
 268    unavailable: bool = False
 269    """Whether the shipped builtin headers were warned to be unavailable."""
 270
 271
 272@dataclass(frozen=True)
 273class _Waiting:
 274    """A compiled source with no compilation-database entry, waiting for its includer."""
 275
 276    position: int
 277    """Its index in ``src_files``."""
 278    path: Path
 279    database: Path | None
 280    """The database that applies to it."""
 281    candidates: tuple[Path, ...]
 282    """The main files of the database's entries whose ``#include`` lines name it,
 283    ordered by path."""
 284
 285
 286class SourceAnalyse:
 287    def __init__(
 288        self,
 289        analyse_config: SourceAnalyseConfig,
 290        *,
 291        name: str = "",
 292        builtin_header_warnings: BuiltinHeaderWarnings | None = None,
 293    ) -> None:
 294        """
 295        :param builtin_header_warnings: What was already warned about clang's builtin
 296            headers, shared with other analyses so that each warning is made once
 297            across them all; by default this analysis' own.
 298        """
 299        self.name = name
 300        self.analyse_config = analyse_config
 301        self.src_files: list[SourceFile] = []
 302        self.src_comments: list[SourceComment] = []
 303        self.need_id_refs: list[NeedIdRefs] = []
 304        self.oneline_needs: list[OneLineNeed] = []
 305        self.multiline_needs: list[MultilineNeed] = []
 306        self.all_marked_content: list[NeedIdRefs | OneLineNeed | MultilineNeed] = []
 307        # Use explicitly configured git_root if provided, otherwise auto-detect
 308        if self.analyse_config.git_root is not None:
 309            self.git_root: Path | None = self.analyse_config.git_root.resolve()
 310        else:
 311            self.git_root = utils.locate_git_root(self.analyse_config.src_dir)
 312        self.git_remote_url: str | None = (
 313            utils.get_remote_url(self.git_root) if self.git_root else None
 314        )
 315        self.git_commit_rev: str | None = (
 316            utils.get_current_rev(self.git_root) if self.git_root else None
 317        )
 318        self.project_path: Path = self.git_root or self.analyse_config.src_dir
 319        self.warnings: list[AnalyseWarning] = []
 320        """The analysis' warnings, one-line and multi-line alike, in extraction order."""
 321        # Per-run memo of parsed compile_commands.json, keyed by DB path, so the
 322        # database is read once per run instead of once per source file.
 323        self._flags_map_cache: dict[Path, dict[Path, list[str]] | None] = {}
 324        # Per-run memo of each database's inclusion index (included file -> the
 325        # entries' main files that include it), built only when a source needs it.
 326        self._inclusion_index_cache: dict[Path, dict[Path, list[Path]]] = {}
 327        self.builtin_header_warnings = (
 328            builtin_header_warnings
 329            if builtin_header_warnings is not None
 330            else BuiltinHeaderWarnings()
 331        )
 332        self.dependencies: set[Path] = set()
 333        """Files besides ``src_files`` the extraction read: the main file of every
 334        entry tried as a source's includer, the files its unit included that are not
 335        system headers, and the database. An edit to one of them can change what is
 336        extracted."""
 337
 338    @property
 339    def oneline_warnings(self) -> list[AnalyseWarning]:
 340        """The old name of :attr:`warnings`, kept for one release; read-only."""
 341        return self.warnings
 342
 343    def get_src_strings(self) -> Generator[tuple[Path, bytes], Any, None]:
 344        """Load source files and extract their content."""
 345        for src_path in self.analyse_config.src_files:
 346            if not utils.is_text_file(src_path):
 347                continue
 348            with src_path.open("r", encoding="utf-8", newline="") as f:
 349                # Normalize all line endings to Unix LF
 350                text = f.read()
 351            text = text.replace("\r\n", "\n").replace("\r", "\n")
 352            yield src_path, text.encode("utf-8")
 353
 354    def create_src_objects(self) -> None:
 355        comment_type = self.analyse_config.comment_type
 356        # One (parser, query) pair per distinct grammar actually needed, built
 357        # lazily so a parser is never rebuilt per file. Every comment type
 358        # except TypeScript uses a single grammar for the whole run;
 359        # TypeScript alone varies its grammar per file (utils.ts_grammar_key)
 360        # because a legacy TypeScript-only cast parses as JSX under the wrong
 361        # grammar — see the CommentType.ts branch of utils.init_tree_sitter.
 362        parser_cache: dict[str, tuple[Parser, Query]] = {}
 363
 364        for src_path, src_string in self.get_src_strings():
 365            # `comment_type` is normally a CommentType member, but a few call
 366            # sites carry it as a plain (possibly invalid) str instead — see
 367            # SourceAnalyseConfig.comment_type — so key on `str(comment_type)`
 368            # rather than `.value`, which only the enum has.
 369            cache_key = (
 370                utils.ts_grammar_key(src_path)
 371                if comment_type == CommentType.ts
 372                else str(comment_type)
 373            )
 374            if cache_key not in parser_cache:
 375                parser_cache[cache_key] = utils.init_tree_sitter(comment_type, src_path)
 376            parser, query = parser_cache[cache_key]
 377
 378            tree, cpp_names = utils.parse_source(
 379                src_string, parser, comment_type, src_path
 380            )
 381            comments: list[TreeSitterNode] | None = utils.captured_comments(tree, query)
 382            if not comments:
 383                continue
 384            src_comments: list[SourceComment] = [
 385                SourceComment(node, _char_column(src_string, node.start_byte))
 386                for node in comments
 387            ]
 388
 389            src_file = SourceFile(src_path.absolute())
 390            src_file.cpp_names = cpp_names
 391            src_file.add_comments(src_comments)
 392            if self.analyse_config.get_multiline_needs:
 393                src_file.lines = src_string.decode("utf-8").split(UNIX_NEWLINE)
 394            self.src_files.append(src_file)
 395            self.src_comments.extend(src_comments)
 396
 397    def _flags_map_for(self, db_path: Path) -> dict[Path, list[str]] | None:
 398        """Return the parsed compile_commands.json for ``db_path``, memoized.
 399
 400        The database is read and parsed once per run instead of once per source
 401        file (O(files x entries) -> O(entries)). A present-but-malformed or
 402        unreadable database is warned once and cached as ``None`` so callers fall
 403        back to the configured defines without re-reading or re-warning per file.
 404        """
 405        from sphinx_codelinks.analyse.preproc import compile_db
 406
 407        if db_path in self._flags_map_cache:
 408            return self._flags_map_cache[db_path]
 409        try:
 410            flags = compile_db.load_flags_map(db_path)
 411        except (OSError, ValueError, TypeError) as exc:
 412            logger.warning(
 413                f"codelinks: failed to read {db_path} ({exc}); "
 414                f"falling back to the configured defines"
 415            )
 416            self._flags_map_cache[db_path] = None
 417            return None
 418        self._flags_map_cache[db_path] = flags
 419        return flags
 420
 421    def _compile_db_for(self, src_path: Path) -> Path | None:
 422        """The compilation database that applies to ``src_path``, if one is named
 423        or found (an explicit path that is not a readable file included)."""
 424        from sphinx_codelinks.analyse.preproc import compile_db
 425
 426        preproc = self.analyse_config.preprocessor
 427        if preproc is None:
 428            return None
 429        if preproc.compile_commands is not None:
 430            return preproc.compile_commands
 431        return compile_db.find_compile_db(src_path, self.project_path)
 432
 433    def _resolve_preproc_args(self, src_path: Path) -> list[str] | None:
 434        from sphinx_codelinks.analyse.preproc import compile_db
 435
 436        # `run()` calls this (via create_src_objects_libclang) only when
 437        # `preprocessor is not None`, but keep the guard: it narrows the type for
 438        # the checker and is a cheap defense (an `assert` would trip bandit S101).
 439        preproc = self.analyse_config.preprocessor
 440        if preproc is None:
 441            return []
 442        db_path = self._compile_db_for(src_path)
 443        if db_path is not None and db_path.is_file():
 444            flags = self._flags_map_for(db_path)
 445            if flags is None:
 446                # Present-but-malformed/unreadable DB (warned once in the helper):
 447                # fall back to the configured defines so extraction still runs.
 448                return compile_db.defines_to_args(
 449                    preproc.defines, preproc.includes, preproc.std
 450                )
 451            args = flags.get(src_path.absolute().resolve())
 452            if args is not None:
 453                return args
 454            # Absent from the DB. compile_commands.json lists only compiled
 455            # translation units, never headers — so a header here is parsed
 456            # standalone with the global defines (one run = one variant). A
 457            # compiled source absent from the DB has no arguments of its own: it is
 458            # read through an entry that includes it, or skipped (spec §3.3).
 459            if compile_db.is_translation_unit_source(src_path):
 460                return None
 461            return compile_db.defines_to_args(
 462                preproc.defines, preproc.includes, preproc.std
 463            )
 464        # No readable DB. `find_compile_db` only ever returns a real file, so a
 465        # non-None `db_path` that reaches here is an explicit `compile_commands`
 466        # path that is not a readable file (typo/missing): warn and fall back
 467        # rather than skip silently. A genuinely absent DB (db_path is None) just
 468        # falls back to the manual defines applied globally.
 469        if db_path is not None:
 470            logger.warning(
 471                f"codelinks: compile_commands path {db_path} is not a readable "
 472                f"file; falling back to the configured defines"
 473            )
 474        return compile_db.defines_to_args(
 475            preproc.defines, preproc.includes, preproc.std
 476        )
 477
 478    def _with_builtin_headers(self, args: list[str]) -> list[str]:
 479        """``args`` with the resource directory that gives a parse clang's builtin
 480        headers (:func:`.preproc.builtin_headers.resource_dir_args`); warns once (see
 481        :class:`BuiltinHeaderWarnings`) when the headers that ship with the package
 482        are wanted but missing."""
 483        from sphinx_codelinks.analyse.preproc import builtin_headers
 484
 485        preproc = self.analyse_config.preprocessor
 486        configured = preproc.resource_dir if preproc is not None else None
 487        args, unavailable = builtin_headers.resource_dir_args(args, configured)
 488        if unavailable and not self.builtin_header_warnings.unavailable:
 489            self.builtin_header_warnings.unavailable = True
 490            logger.warning(
 491                f"codelinks: clang's builtin headers are not under "
 492                f"{builtin_headers.BUNDLED_RESOURCE_DIR / 'include'}, so C/C++ files "
 493                f"are parsed without them, and an #if on what <stddef.h>, <stdint.h>, "
 494                f"<limits.h> or <stdatomic.h> define may take the wrong branch; set "
 495                f"resource_dir in [analyse.preprocessor] to a clang resource directory "
 496                f"(clang -print-resource-dir)",
 497                subtype="builtin_header",
 498            )
 499        return args
 500
 501    def _warn_missing_builtin_header(
 502        self, path: Path, tu: Any, args: list[str]
 503    ) -> None:
 504        """Warn, once per parsed file (see :class:`BuiltinHeaderWarnings`), when its
 505        parse found no builtin header. When its arguments ``args`` leave clang's
 506        builtin headers out (``-nostdinc``), the warning names that flag, which no
 507        ``resource_dir`` overrides; otherwise it points at ``resource_dir``."""
 508        from sphinx_codelinks.analyse.preproc import builtin_headers, compile_db
 509
 510        warned = self.builtin_header_warnings.files
 511        if path in warned:
 512            return
 513        missing = builtin_headers.missing_builtin_header(
 514            tu, compile_db.working_directory(args)
 515        )
 516        if missing is None:
 517            return
 518        warned.add(path)
 519        name, file, line = missing
 520        where = f" (included at {file}:{line})" if file is not None else ""
 521        excluding = builtin_headers.excluding_flag(args)
 522        remedy = (
 523            f"the compile command's {excluding} leaves clang's builtin headers out"
 524            if excluding is not None
 525            else "set resource_dir in [analyse.preprocessor] to a clang resource "
 526            "directory that has it (clang -print-resource-dir)"
 527        )
 528        logger.warning(
 529            f"codelinks: {path}: clang's builtin header '{name}' was not "
 530            f"found{where}, so an #if on what it defines takes its #else branch; "
 531            f"{remedy}",
 532            subtype="builtin_header",
 533        )
 534
 535    def _includer_candidates(self, db_path: Path, src_path: Path) -> list[Path]:
 536        """The main files of ``db_path``'s entries whose ``#include`` lines name
 537        ``src_path``, ordered by path (see :mod:`.preproc.includer`)."""
 538        from sphinx_codelinks.analyse.preproc import includer
 539
 540        if db_path not in self._inclusion_index_cache:
 541            flags = self._flags_map_for(db_path) or {}
 542            self._inclusion_index_cache[db_path] = includer.inclusion_index(flags)
 543        return self._inclusion_index_cache[db_path].get(src_path.resolve(), [])
 544
 545    def _read_through_includers(
 546        self, waiting: list[_Waiting]
 547    ) -> dict[int, list["LibclangComment"]]:
 548        """Read each waiting source through its includer; warn about the rest.
 549
 550        A source's includer is the first of its candidates, by path, whose parse
 551        confirms that its main file includes the source (the lexical scan cannot see
 552        ``#if`` arms). The candidates are parsed in path order across every waiting
 553        source, each once, and each unit is released before the next is parsed: when
 554        a candidate is parsed it is the next candidate of every source that lists
 555        it, so it is never needed again. One libclang cannot load confirms nothing.
 556
 557        :return: The comments read, by the source's position in ``src_files``.
 558        """
 559        from sphinx_codelinks.analyse.preproc import compile_db, libclang_parser, loader
 560
 561        translation_unit_load_error = (
 562            loader.load_clang_cindex().TranslationUnitLoadError
 563        )
 564        read: dict[int, list[LibclangComment]] = {}
 565        # each unread source's next candidate, by position
 566        next_candidate = {w.position: 0 for w in waiting}
 567        unread = {w.position: w for w in waiting}
 568        while True:
 569            heads = {
 570                (w.database, w.candidates[next_candidate[position]])
 571                for position, w in unread.items()
 572                if w.database is not None
 573                and next_candidate[position] < len(w.candidates)
 574            }
 575            if not heads:
 576                break
 577            database, main = min(
 578                heads, key=lambda head: (os.fsencode(head[1]), os.fsencode(head[0]))
 579            )
 580            args = self._with_builtin_headers(
 581                (self._flags_map_for(database) or {})[main]
 582            )
 583            self.dependencies.update((database, main))
 584            try:
 585                tu = libclang_parser.parse_translation_unit(main, args)
 586            except translation_unit_load_error:
 587                logger.debug(
 588                    f"codelinks: {main} could not be loaded as a translation unit, "
 589                    f"so it includes no source"
 590                )
 591                tu = None
 592                includes: set[Path] = set()
 593            else:
 594                self._warn_missing_builtin_header(main, tu, args)
 595                working_directory = compile_db.working_directory(args)
 596                includes = libclang_parser.main_file_inclusions(tu, working_directory)
 597                self.dependencies.update(
 598                    libclang_parser.included_files(tu, working_directory)
 599                )
 600            for position, w in list(unread.items()):
 601                if w.database != database or next_candidate[position] >= len(
 602                    w.candidates
 603                ):
 604                    continue
 605                if w.candidates[next_candidate[position]] != main:
 606                    continue
 607                if tu is not None and w.path.resolve() in includes:
 608                    logger.debug(
 609                        f"codelinks: reading {w.path} through {main}, which includes it"
 610                    )
 611                    read[position] = libclang_parser.extract_included_active_comments(
 612                        tu, w.path
 613                    )
 614                    del unread[position]
 615                else:
 616                    next_candidate[position] += 1
 617            del tu  # released before the next candidate is parsed
 618        for w in unread.values():
 619            logger.warning(
 620                f"codelinks: skipping {w.path} — it has no entry in "
 621                f"{w.database} and no entry of it includes it",
 622                subtype="not_in_compile_database",
 623            )
 624        return read
 625
[docs] 626    # @Extract traceability objects with the preprocessor-aware libclang engine, IMPL_PREPROC_1, impl, [FE_PREPROC]
 627    def create_src_objects_libclang(self) -> None:
 628        from sphinx_codelinks.analyse.preproc import (
 629            libclang_parser,
 630            loader,
 631        )
 632
 633        # Resolve the exception via the loader (never a direct ``import
 634        # clang.cindex``) so a missing ``libclang`` extra still surfaces the
 635        # loader's friendly install hint rather than a bare ImportError.
 636        translation_unit_load_error = (
 637            loader.load_clang_cindex().TranslationUnitLoadError
 638        )
 639        src_files = self.analyse_config.src_files
 640        read: dict[int, list[LibclangComment]] = {}
 641        # the compiled sources the database has no entry for: read through the entry
 642        # that includes them, with that entry's flags, once every other file is read
 643        waiting: list[_Waiting] = []
 644        for position, src_path in enumerate(src_files):
 645            if not utils.is_text_file(src_path):
 646                continue
 647            args = self._resolve_preproc_args(src_path)
 648            if args is None:
 649                database = self._compile_db_for(src_path)
 650                candidates = (
 651                    ()
 652                    if database is None
 653                    else tuple(self._includer_candidates(database, src_path))
 654                )
 655                waiting.append(_Waiting(position, src_path, database, candidates))
 656                continue
 657            args = self._with_builtin_headers(args)
 658            try:
 659                read[position] = libclang_parser.extract_active_comments(
 660                    src_path,
 661                    args,
 662                    on_parse=lambda tu, path=src_path, args=args: (
 663                        self._warn_missing_builtin_header(path, tu, args)
 664                    ),
 665                )
 666            except translation_unit_load_error:
 667                # Last-resort guard. Standalone parses pin ``-x`` to match the
 668                # ``-std`` (see defines_to_args), so the common case — a ``.h``/
 669                # ``.c`` header handed a C++ ``-std`` — now parses as C++ and its
 670                # markers extract. This only fires if libclang still cannot load
 671                # the file as a translation unit at all; skip it rather than
 672                # aborting the whole run. Surfaced as a ``warning`` so the silent
 673                # data-loss (a file's markers dropped) is visible — accepting
 674                # that this fails ``sphinx-build -W``.
 675                logger.warning(
 676                    f"codelinks: skipping {src_path} — libclang could not load "
 677                    f"it as a translation unit"
 678                )
 679        read.update(self._read_through_includers(waiting))
 680
 681        for position in sorted(read):
 682            src_path = src_files[position]
 683            comments = read[position]
 684            if not comments:
 685                continue
 686            # ``c`` is a LibclangComment duck-typing the tree-sitter Node
 687            # interface SourceComment reads (``.text`` / ``.start_point.row``);
 688            # the Node-only path (find_associated_scope) is guarded by
 689            # ``is_libclang`` so it never runs on these. The cast is that duck typing,
 690            # which is why ty, seeing two unrelated classes, has to be told.
 691            src_comments = [
 692                SourceComment(
 693                    cast("TreeSitterNode", c),  # ty: ignore[disjoint-cast]
 694                    c.column,
 695                )
 696                for c in comments
 697            ]
 698            src_file = SourceFile(src_path.absolute())
 699            src_file.add_comments(src_comments)
 700            if self.analyse_config.get_multiline_needs:
 701                # multi-line needs read what precedes a comment on its row from
 702                # the row itself
 703                text = src_path.read_text(encoding="utf-8", errors="replace")
 704                text = text.replace("\r\n", "\n").replace("\r", "\n")
 705                src_file.lines = text.split(UNIX_NEWLINE)
 706            self.src_files.append(src_file)
 707            self.src_comments.extend(src_comments)
 708
 709    def extract_marker(
 710        self,
 711        text: str,
 712    ) -> Generator[tuple[str, list[str], int, int, int], None, None]:
 713        """Yield each reference-marker match of ``text`` with its ids.
 714
 715        Yields ``(marker, need_ids, row_offset, start_column, end_column)``, the
 716        columns spanning the match's own id text. The matches of a line are found by
 717        :func:`find_marker_matches`; a match's id text runs from its end to the start
 718        of the next match on the line, or to the end of the line. A match with no id
 719        yields nothing.
 720        """
 721        markers = self.analyse_config.need_id_refs_config.markers
 722        row_offset = 0
 723        for line in text.split(UNIX_NEWLINE):
 724            matches = find_marker_matches(line, markers)
 725            for index, (marker, _start, end) in enumerate(matches):
 726                is_last = index == len(matches) - 1
 727                after_marker = (
 728                    line[end:] if is_last else line[end : matches[index + 1][1]]
 729                )
 730                markered_text = after_marker.strip()
 731                if is_last and markered_text.endswith("*/"):
 732                    # The end of a one-line block comment, not an id.
 733                    markered_text = markered_text[:-2].rstrip()
 734                need_ids = markered_text.replace(",", " ").split()
 735                if not need_ids:
 736                    continue
 737                start_column = end + len(after_marker) - len(after_marker.lstrip())
 738                end_column = start_column + len(markered_text)
 739                yield marker, need_ids, row_offset, start_column, end_column
 740            row_offset += 1
 741
[docs] 742    # @Extract need ID references from code comments, IMPL_LNK_1, impl, [FE_LNK]
 743    def extract_anchors(
 744        self,
 745        text: str,
 746        filepath: Path,
 747        tagged_scope: TreeSitterNode | None,
 748        src_comment: SourceComment,
 749        first_row: int | None = None,
 750        first_column: int | None = None,
 751        *,
 752        scope: CodeScope | None = None,
 753    ) -> list[NeedIdRefs]:
 754        """Extract need-ids-refs from a comment.
 755
 756        ``text`` starts at ``first_row`` and ``first_column`` (by default the
 757        comment's own row and column). ``scope`` is the named ``tagged_scope``.
 758        """
 759        if first_row is None:
 760            first_row = src_comment.node.start_point.row
 761        if first_column is None:
 762            first_column = src_comment.column
 763        anchors: list[NeedIdRefs] = []
 764        for (
 765            marker,
 766            need_ids,
 767            row_offset,
 768            start_column,
 769            end_column,
 770        ) in self.extract_marker(text):
 771            lineno = first_row + row_offset + 1
 772            line_column = first_column if row_offset == 0 else 0
 773            remote_url = self.git_remote_url
 774            if self.git_remote_url and self.git_commit_rev:
 775                remote_url = utils.form_https_url(
 776                    self.git_remote_url,
 777                    self.git_commit_rev,
 778                    self.project_path,
 779                    filepath,
 780                    lineno,
 781                )
 782            source_map: SourceMap = {
 783                "start": {
 784                    "row": lineno - 1,
 785                    "column": line_column + start_column,
 786                },
 787                "end": {
 788                    "row": lineno - 1,
 789                    "column": line_column + end_column,
 790                },
 791            }
 792            anchors.append(
 793                NeedIdRefs(
 794                    filepath,
 795                    remote_url,
 796                    source_map,
 797                    src_comment,
 798                    tagged_scope,
 799                    need_ids,
 800                    marker,
 801                    scope=scope,
 802                )
 803            )
 804        return anchors
 805
 806    def _is_need_id_refs_line(self, line: str) -> bool:
 807        """Whether a comment line is an ``@need-ids:`` reference rather than a need.
 808
 809        A line whose text, after comment decoration and whitespace, starts with a
 810        configured need-id-refs marker is a reference, and never a one-line need.
 811        Without it the default one-line start sequence ``@`` also
 812        matched ``// @need-ids: A, B`` and made a need titled ``need-ids: A`` with the id
 813        ``B``. A line with anything alphanumeric before the marker is not a reference
 814        line here (``// see @need-ids: X`` -- the one-line parser ignores it anyway, and
 815        the references on it are still extracted), and a line holding a one-line need
 816        before the marker (``[[…]] @need-ids: X``) keeps its need: only a line that
 817        STARTS with the marker is withheld from the one-line parser. The marker is the
 818        line's first match as :func:`find_marker_matches` finds it, so a word that only
 819        begins with a marker (``\\verifiesall`` for ``\\verifies``) is none.
 820        """
 821        matches = find_marker_matches(
 822            line, self.analyse_config.need_id_refs_config.markers, first_only=True
 823        )
 824        return bool(matches) and not any(
 825            _is_alphanumeric(char) for char in line[: matches[0][1]]
 826        )
 827
 828    def extract_oneline_need(
 829        self,
 830        text: str,
 831        src_comment: SourceComment,
 832        oneline_comment_style: OneLineCommentStyle,
 833        first_row: int | None = None,
 834    ) -> Generator[tuple[dict[str, str | list[str] | int], int]]:
 835        if first_row is None:
 836            first_row = src_comment.node.start_point.row
 837        # every line counts as terminated, the last one too
 838        lines = [f"{line}{UNIX_NEWLINE}" for line in text.split(UNIX_NEWLINE)]
 839        row_offset = 0
 840
 841        # Only a Python docstring can hold docstring tags; a ``#`` comment never does.
 842        in_docstring = (
 843            getattr(src_comment.node, "type", None) == CommentCategory.docstring
 844        )
 845        # Only a TypeScript or JavaScript JSDoc comment can hold JSDoc tags.
 846        in_jsdoc = self.analyse_config.comment_type == CommentType.ts and _is_jsdoc(
 847            src_comment.text
 848        )
 849        in_powershell = self.analyse_config.comment_type == CommentType.powershell
 850        # Only a VHDL ``--!`` comment, Doxygen's VHDL comment, can hold Doxygen commands
 851        # (a ``--!!`` comment is a plain one)
 852        in_doxygen = self.analyse_config.comment_type == CommentType.vhdl and (
 853            is_doc_comment(src_comment.node, CommentType.vhdl, text=src_comment.text)
 854        )
 855        for line in lines:
 856            if self._is_need_id_refs_line(line):
 857                row_offset += 1
 858                continue
 859            start_sequence = oneline_comment_style.start_sequence
 860            if in_powershell and opens_powershell_literal(line, start_sequence):
 861                # a hashtable or an array, ``@{ … }`` or ``@( … )``: code, not a marker
 862                row_offset += 1
 863                continue
 864            tag, kind, holders = None, WarningSubTypeEnum.docstring_tag, "docstrings"
 865            if in_docstring:
 866                tag = docstring_tag(line, start_sequence)
 867            elif in_jsdoc and start_sequence == "@":
 868                # ``@`` is JSDoc's own sigil: under any other start sequence a JSDoc
 869                # line is read as any other line is
 870                tag = jsdoc_tag(line, start_sequence)
 871                kind, holders = WarningSubTypeEnum.jsdoc_tag, "JSDoc comments"
 872                if tag is not None and not oneline_parser(line, oneline_comment_style):
 873                    # a tag line the one-line parser would ignore stays silent
 874                    row_offset += 1
 875                    continue
 876            elif in_doxygen and start_sequence == "@":
 877                # as for JSDoc: ``@`` is Doxygen's sigil, and a silent line stays silent
 878                tag = doxygen_tag(line, start_sequence)
 879                kind, holders = WarningSubTypeEnum.doxygen_tag, "Doxygen comments"
 880                if tag is not None and not oneline_parser(line, oneline_comment_style):
 881                    row_offset += 1
 882                    continue
 883            if tag is not None:
 884                if src_comment.source_file:
 885                    self.warnings.append(
 886                        AnalyseWarning(
 887                            str(src_comment.source_file.filepath),
 888                            first_row + row_offset + 1,
 889                            f"'{start_sequence}{tag}' is a {_TAG_NAMES[kind]}, not a "
 890                            "one-line need; use a start sequence that "
 891                            f"{holders} do not contain",
 892                            MarkedContentType.need,
 893                            kind.value,
 894                        )
 895                    )
 896                row_offset += 1
 897                continue
 898            resolved = oneline_parser(line, oneline_comment_style)
 899            if not resolved:
 900                row_offset += 1
 901                continue
 902            if isinstance(resolved, OnelineParserInvalidWarning):
 903                if not src_comment.source_file:
 904                    row_offset += 1
 905                    continue
 906                lineno = first_row + row_offset + 1
 907                warning = AnalyseWarning(
 908                    str(src_comment.source_file.filepath),
 909                    lineno,
 910                    resolved.msg,
 911                    MarkedContentType.need,
 912                    resolved.sub_type.value,
 913                )
 914                self.warnings.append(warning)
 915                row_offset += 1
 916                continue
 917            yield resolved, row_offset
 918            row_offset += 1
 919
[docs] 920    # @Extract one-line traceability needs from comments, IMPL_ONE_1, impl, [FE_DEF, FE_CMT]
 921    def extract_oneline_needs(
 922        self,
 923        text: str,
 924        filepath: Path,
 925        tagged_scope: TreeSitterNode | None,
 926        src_comment: SourceComment,
 927        oneline_comment_style: OneLineCommentStyle,
 928        first_row: int | None = None,
 929        first_column: int | None = None,
 930        *,
 931        scope: CodeScope | None = None,
 932    ) -> list[OneLineNeed]:
 933        """Extract the one-line needs of a comment.
 934
 935        ``text`` starts at ``first_row`` and ``first_column`` (by default the
 936        comment's own row and column). ``scope`` is the named ``tagged_scope``.
 937        """
 938        if first_row is None:
 939            first_row = src_comment.node.start_point.row
 940        if first_column is None:
 941            first_column = src_comment.column
 942        row_offset = 0
 943        oneline_needs = []
 944        for resolved, row_offset in self.extract_oneline_need(
 945            text, src_comment, oneline_comment_style, first_row
 946        ):
 947            lineno = first_row + row_offset + 1
 948            line_column = first_column if row_offset == 0 else 0
 949            start_column = line_column + cast("int", resolved["start_column"])
 950            end_column = line_column + cast("int", resolved["end_column"])
 951            remote_url = self.git_remote_url
 952            if self.git_remote_url and self.git_commit_rev:
 953                remote_url = utils.form_https_url(
 954                    self.git_remote_url,
 955                    self.git_commit_rev,
 956                    self.project_path,
 957                    filepath,
 958                    lineno,
 959                )
 960            source_map: SourceMap = {
 961                "start": {
 962                    "row": lineno - 1,
 963                    "column": start_column,
 964                },
 965                "end": {
 966                    "row": lineno - 1,
 967                    "column": end_column,
 968                },
 969            }
 970            del resolved["start_column"]
 971            del resolved["end_column"]
 972            oneline_needs.append(
 973                OneLineNeed(
 974                    filepath,
 975                    remote_url,
 976                    source_map,
 977                    src_comment,
 978                    tagged_scope,
 979                    resolved,  # int arguments were deleted  # ty: ignore[invalid-argument-type]
 980                    scope=scope,
 981                )
 982            )
 983        return oneline_needs
 984
[docs] 985    # @Extract multi-line needs from comment runs, IMPL_MLN_1, impl, [FE_MULTILINE_NEEDS]
 986    def extract_multiline_needs(self) -> dict[int, set[int]]:
 987        """Extract the multi-line needs of every file, and the rows their blocks claim.
 988
 989        Runs before the other extractors: the rows a block claims (and those of a
 990        refused header, consumed to its close) are hidden from them.
 991
 992        :return: For each comment holding claimed rows (keyed by ``id()`` of its
 993            :class:`SourceComment`), those 0-based rows.
 994        """
 995        claimed: dict[int, set[int]] = {}
 996        config = self.analyse_config.multiline_needs_config
 997        for src_file in self.src_files:
 998            for run in multiline_parser.form_runs(
 999                src_file.src_comments, src_file.lines
1000            ):
1001                result = multiline_parser.parse_run(
1002                    run.lines,
1003                    config,
1004                    leaderless_block=run.leaderless_block,
1005                    mixed_leaders=run.mixed_leaders,
1006                )
1007                for issue in result.issues:
1008                    self.warnings.append(
1009                        AnalyseWarning(
1010                            str(src_file.filepath),
1011                            issue.row + 1,
1012                            issue.msg,
1013                            MarkedContentType.multiline_need,
1014                            issue.kind.value,
1015                        )
1016                    )
1017                for block in result.blocks:
1018                    self.multiline_needs.append(
1019                        self._multiline_need(block, run, src_file.filepath)
1020                    )
1021                if not result.claimed_rows:
1022                    continue
1023                for src_comment in run.comments:
1024                    first = src_comment.node.start_point.row
1025                    rows = result.claimed_rows.intersection(
1026                        range(first, first + _row_count(src_comment))
1027                    )
1028                    if rows:
1029                        claimed.setdefault(id(src_comment), set()).update(rows)
1030        return claimed
1031
1032    def _multiline_need(
1033        self,
1034        block: multiline_parser.ParsedBlock,
1035        run: multiline_parser.CommentRun,
1036        filepath: Path,
1037    ) -> MultilineNeed:
1038        """The record of one parsed block (see :class:`MultilineNeed`)."""
1039        src_comment = next(
1040            (
1041                comment
1042                for comment in run.comments
1043                if comment.node.start_point.row
1044                <= block.open_row
1045                < comment.node.start_point.row + _row_count(comment)
1046            ),
1047            run.comments[0],
1048        )
1049        tagged_scope, scope, unattached = self._associate(src_comment, filepath)
1050        lineno = block.open_row + 1
1051        remote_url = self.git_remote_url
1052        if self.git_remote_url and self.git_commit_rev:
1053            remote_url = utils.form_https_url(
1054                self.git_remote_url,
1055                self.git_commit_rev,
1056                self.project_path,
1057                filepath,
1058                lineno,
1059            )
1060        source_map: SourceMap = {
1061            "start": {"row": block.open_row, "column": block.open_col},
1062            "end": {"row": block.close_row, "column": block.end_col},
1063        }
1064        content_start = (
1065            {"line": block.content_start[0] + 1, "col": block.content_start[1]}
1066            if block.content_start is not None
1067            else None
1068        )
1069        source: dict[str, Any] = {
1070            "project": self.name,
1071            "path": _relative_posix(filepath, self.project_path),
1072            "root": "git" if self.git_root is not None else "src_dir",
1073            "commit": self.git_commit_rev,
1074            "start": {"line": lineno, "col": block.open_col},
1075            "end": {"line": block.close_row + 1, "col": block.end_col},
1076            "content_start": content_start,
1077            "option_lines": dict(block.option_rows),
1078            "scope": scope,
1079        }
1080        need = MultilineNeed(
1081            filepath,
1082            remote_url,
1083            source_map,
1084            src_comment,
1085            tagged_scope,
1086            block.need,
1087            block.markup,
1088            source,
1089            scope=scope,
1090        )
1091        self._warn_unattached(unattached, [need])
1092        return need
1093
1094    def _associate(
1095        self, src_comment: SourceComment, filepath: Path
1096    ) -> tuple[TreeSitterNode | None, CodeScope | None, utils.Unattached | None]:
1097        """The entity a comment of ``filepath`` is attached to: its node and its
1098        ``scope`` object, and why it is attached to none where the analysis warns
1099        about that (:class:`~sphinx_codelinks.analyse.utils.Unattached`).
1100
1101        A libclang comment has no tree-sitter node, so it is attached to nothing.
1102        """
1103        if getattr(src_comment.node, "is_libclang", False):
1104            return None, None, None
1105        comment_type = self.analyse_config.comment_type
1106        association = utils.associate(
1107            src_comment.node, comment_type, text=src_comment.text
1108        )
1109        if association is None:
1110            return None, None, None
1111        if isinstance(association, utils.Unattached):
1112            return None, None, association
1113        source_file = src_comment.source_file
1114        written = (
1115            source_file.node_text(association.node)
1116            if source_file is not None
1117            else association.node.text
1118        )
1119        if not written:
1120            return None, None, None
1121        return (
1122            association.node,
1123            code_scope(
1124                association,
1125                comment_type,
1126                scope_language(comment_type, filepath),
1127                source_file.cpp_names if source_file is not None else None,
1128            ),
1129            None,
1130        )
1131
1132    def _warn_unattached(
1133        self,
1134        unattached: utils.Unattached | None,
1135        records: Iterable[NeedIdRefs | OneLineNeed | MultilineNeed],
1136    ) -> None:
1137        """Warn about each marker in ``records`` that rule 7 attached to no entity, at
1138        its line (a multi-line need's open line)."""
1139        if unattached is not utils.Unattached.above_conditional:
1140            return
1141        for record in records:
1142            self.warnings.append(
1143                AnalyseWarning(
1144                    str(record.filepath),
1145                    record.source_map["start"]["row"] + 1,
1146                    ABOVE_CONDITIONAL_MSG,
1147                    record.type,
1148                    WarningSubTypeEnum.marker_above_conditional.value,
1149                )
1150            )
1151
1152    def extract_marked_content(self) -> None:
1153        claimed: dict[int, set[int]] = {}
1154        if self.analyse_config.get_multiline_needs:
1155            claimed = self.extract_multiline_needs()
1156        for src_comment in self.src_comments:
1157            if not src_comment.text:
1158                continue
1159            filepath = (
1160                src_comment.source_file.filepath if src_comment.source_file else None
1161            )
1162            if not filepath:
1163                continue
1164            tagged_scope, scope, unattached = self._associate(src_comment, filepath)
1165            for text, first_row, first_column in _scanned_texts(
1166                src_comment, claimed.get(id(src_comment))
1167            ):
1168                if self.analyse_config.get_need_id_refs:
1169                    anchors = self.extract_anchors(
1170                        text,
1171                        filepath,
1172                        tagged_scope,
1173                        src_comment,
1174                        first_row,
1175                        first_column,
1176                        scope=scope,
1177                    )
1178                    self._warn_unattached(unattached, anchors)
1179                    self.need_id_refs.extend(anchors)
1180                if self.analyse_config.get_oneline_needs:
1181                    oneline_needs = self.extract_oneline_needs(
1182                        text,
1183                        filepath,
1184                        tagged_scope,
1185                        src_comment,
1186                        self.analyse_config.oneline_comment_style,
1187                        first_row,
1188                        first_column,
1189                        scope=scope,
1190                    )
1191                    self._warn_unattached(unattached, oneline_needs)
1192                    self.oneline_needs.extend(oneline_needs)
1193
1194    def merge_marked_content(self) -> None:
1195        """List the marked content in source order: by file, row and column (#2150).
1196
1197        Tree-sitter hands a file's comments over in an order that differs between
1198        runs, so the column is what keeps two entries of one row in a stable order.
1199        ``oneline_needs`` is sorted too, by row, then file, then column: ``src-trace``
1200        creates and renders the needs in its order, so of two markers with one id in
1201        one file the leftmost is the one created, and on one row the needs of two files
1202        keep the file order they had before the column was a key -- for a caller that
1203        passes the files in discovery order, as every production caller does.
1204        """
1205        # The file component is the key discovery sorts the files by
1206        # (``SourceDiscover``), not the ``Path``: a ``Path`` compares by parts, so
1207        # ``a/b.cpp`` would sort before ``a-c.cpp``, which discovery lists first.
1208        self.oneline_needs.sort(
1209            key=lambda x: (
1210                x.source_map["start"]["row"],
1211                os.path.normcase(os.path.normpath(x.filepath)),
1212                x.source_map["start"]["column"],
1213            )
1214        )
1215        self.all_marked_content.extend(self.need_id_refs)
1216        self.all_marked_content.extend(self.oneline_needs)
1217        self.all_marked_content.extend(self.multiline_needs)
1218        self.all_marked_content.sort(
1219            key=lambda x: (
1220                x.filepath,
1221                x.source_map["start"]["row"],
1222                x.source_map["start"]["column"],
1223                _KIND_RANK[x.type],
1224            )
1225        )
1226
1227    def dump_marked_content(self, outdir: Path) -> None:
1228        output_path = outdir / "marked_content.json"
1229        output_path.parent.mkdir(parents=True, exist_ok=True)
1230        to_dump = [
1231            marked_content.to_dict() for marked_content in self.all_marked_content
1232        ]
1233        with output_path.open("w") as f:
1234            json.dump(to_dump, f)
1235
1236    def run(self, *, log_summary: bool = True) -> None:
1237        """Parse the source files and extract their markers.
1238
1239        :param log_summary: Whether to print the per-project summary line; a caller
1240            that prints its own (the configuration pass of the Sphinx extension) passes
1241            ``False``.
1242        """
1243        if (
1244            self.analyse_config.preprocessor is not None
1245            and self.analyse_config.comment_type == CommentType.cpp
1246        ):
1247            self.create_src_objects_libclang()
1248        else:
1249            self.create_src_objects()
1250        self.extract_marked_content()
1251        self.merge_marked_content()
1252        if log_summary:
1253            self.log_summary()
1254
1255    def log_summary(self, extra: str = "") -> None:
1256        """Emit a per-project marker (default-visible) plus a -v breakdown.
1257
1258        :param extra: Appended to the default-visible line (the ``src-trace``
1259            directive's count of the one-line needs it did not create).
1260        """
1261        label = f"codelinks [{self.name}]" if self.name else "codelinks"
1262        logger.info(
1263            f"{label}: {_count(len(self.src_files), 'file')}, "
1264            f"{_count(len(self.all_marked_content), 'marker')}{extra}"
1265        )
1266        logger.debug(
1267            f"{label}: {_count(len(self.src_comments), 'comment')}, "
1268            f"{_count(len(self.oneline_needs), 'oneline need')}, "
1269            f"{_count(len(self.need_id_refs), 'id-ref')}, "
1270            f"{_count(len(self.multiline_needs), 'multi-line need')}"
1271        )