1"""Parse a C/C++ TU via libclang and yield ACTIVE comment tokens only."""
  2
  3from __future__ import annotations
  4
  5import os
  6from dataclasses import dataclass
  7from pathlib import Path
  8
  9from sphinx_codelinks.analyse.preproc import loader
 10from sphinx_codelinks.analyse.preproc.loader import SkippedRange
 11
 12
 13@dataclass
 14class _Point:
 15    row: int
 16
 17
 18class LibclangComment:
 19    """Minimal stand-in for a tree-sitter comment node.
 20
 21    Exposes exactly the two attributes the existing extract_* chain reads:
 22    ``.text`` (bytes) and ``.start_point.row`` (0-indexed). ``is_libclang``
 23    lets extract_marked_content skip tree-sitter scope association.
 24    """
 25
 26    is_libclang = True
 27
 28    def __init__(self, text: bytes, row: int, column: int = 0) -> None:
 29        self.text: bytes = text
 30        self.start_point = _Point(row)
 31        # The column, in characters, at which the comment starts on its line.
 32        self.column: int = column
 33
 34
 35def _group_skipped(skipped: list[SkippedRange]) -> dict[str, list[tuple[int, int]]]:
 36    """Group skipped ranges by normalised file path, built once per TU.
 37
 38    A per-comment membership test then scans only its own file's ranges instead
 39    of every range in the translation unit (headers included). Normalising the
 40    key (``os.path.normpath``) also fixes clang spelling the same file two ways
 41    (``./x`` vs ``x``, ``a/../b``), which a naive compare would miss.
 42    """
 43    grouped: dict[str, list[tuple[int, int]]] = {}
 44    for sr in skipped:
 45        if sr.file is not None:
 46            grouped.setdefault(os.path.normpath(str(sr.file)), []).append(
 47                (sr.start_line, sr.end_line)
 48            )
 49    return grouped
 50
 51
 52def _is_in_skipped(
 53    file_path: str, line: int, grouped: dict[str, list[tuple[int, int]]]
 54) -> bool:
 55    return any(
 56        start <= line <= end
 57        for start, end in grouped.get(os.path.normpath(file_path), ())
 58    )
 59
 60
[docs] 61# @Drop comments inside inactive preprocessor branches, IMPL_PREPROC_2, impl, [FE_PREPROC]
 62def extract_active_comments(file_path: Path, args: list[str]) -> list[LibclangComment]:
 63    """Return one LibclangComment per ACTIVE comment token in ``file_path``.
 64
 65    Comments inside preprocessor-skipped (inactive) ranges are dropped.
 66    """
 67    cx = loader.load_clang_cindex()
 68    index = cx.Index.create()
 69    tu = index.parse(str(file_path), args=args, options=loader.PARSE_OPTIONS)
 70    skipped = _group_skipped(loader.get_all_skipped_ranges(tu))
 71
 72    # Read the raw source bytes once. We derive the token extent from them
 73    # (lossily, so a non-UTF-8 byte can't raise and abort the run) AND slice each
 74    # comment's text out of them by byte offset below — never via ``tok.spelling``,
 75    # which decodes the comment as strict UTF-8 and raises UnicodeDecodeError on a
 76    # non-UTF-8 byte inside a comment.
 77    raw = file_path.read_bytes()
 78    line_count = len(raw.decode("utf-8", errors="replace").splitlines())
 79    main = tu.get_file(str(file_path))
 80    extent = cx.SourceRange.from_locations(
 81        cx.SourceLocation.from_position(tu, main, 1, 1),
 82        cx.SourceLocation.from_position(tu, main, line_count + 1, 1),
 83    )
 84
 85    out: list[LibclangComment] = []
 86    for tok in tu.get_tokens(extent=extent):
 87        if tok.kind != cx.TokenKind.COMMENT:
 88            continue
 89        loc = tok.location
 90        if loc.file is None:
 91            continue
 92        if _is_in_skipped(str(loc.file.name), loc.line, skipped):
 93            continue  # inactive branch -> excluded
 94        # Slice the comment text from the raw bytes by offset and decode lossily
 95        # (not via tok.spelling, which raises on a non-UTF-8 byte in the comment).
 96        # Then normalize CRLF/CR -> LF, matching get_src_strings on the tree-sitter
 97        # path: a multi-line block comment (e.g. a reST block) from a CRLF-saved
 98        # file otherwise carries embedded \r into the extracted marker text.
 99        text = raw[tok.extent.start.offset : tok.extent.end.offset].decode(
100            "utf-8", errors="replace"
101        )
102        spelling = text.replace("\r\n", "\n").replace("\r", "\n")
103        start = tok.extent.start.offset
104        line_start = max(raw.rfind(b"\n", 0, start), raw.rfind(b"\r", 0, start)) + 1
105        column = len(raw[line_start:start].decode("utf-8", errors="replace"))
106        out.append(LibclangComment(spelling.encode("utf-8"), loc.line - 1, column))
107    return out