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