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