1"""Parse a C/C++ TU via libclang and yield ACTIVE comment tokens only."""
2
3from __future__ import annotations
4
5from dataclasses import dataclass
6import os
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) -> None:
29 self.text: bytes = text
30 self.start_point = _Point(row)
31
32
33def _group_skipped(skipped: list[SkippedRange]) -> dict[str, list[tuple[int, int]]]:
34 """Group skipped ranges by normalised file path, built once per TU.
35
36 A per-comment membership test then scans only its own file's ranges instead
37 of every range in the translation unit (headers included). Normalising the
38 key (``os.path.normpath``) also fixes clang spelling the same file two ways
39 (``./x`` vs ``x``, ``a/../b``), which a naive compare would miss.
40 """
41 grouped: dict[str, list[tuple[int, int]]] = {}
42 for sr in skipped:
43 if sr.file is not None:
44 grouped.setdefault(os.path.normpath(str(sr.file)), []).append(
45 (sr.start_line, sr.end_line)
46 )
47 return grouped
48
49
50def _is_in_skipped(
51 file_path: str, line: int, grouped: dict[str, list[tuple[int, int]]]
52) -> bool:
53 return any(
54 start <= line <= end
55 for start, end in grouped.get(os.path.normpath(file_path), ())
56 )
57
58
[docs] 59# @Drop comments inside inactive preprocessor branches, IMPL_PREPROC_2, impl, [FE_PREPROC]
60def extract_active_comments(file_path: Path, args: list[str]) -> list[LibclangComment]:
61 """Return one LibclangComment per ACTIVE comment token in ``file_path``.
62
63 Comments inside preprocessor-skipped (inactive) ranges are dropped.
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 = _group_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(str(loc.file.name), loc.line, skipped):
91 continue # inactive branch -> 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 out.append(LibclangComment(spelling.encode("utf-8"), loc.line - 1))
102 return out