ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
extract_line_citations.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""extract_line_citations.py -- Emit a CSV of in-tree line citations.
5
6Companion to scripts/checks/check_line_citations.py. Walks the same set
7of files (libs/, src/, tests/, examples/, port/), finds every
8`<file>.<ext>:<line>` token inside C/C++ comments, and emits one row
9per violation:
10
11 file,line_number,matched_citation,enclosing_function,
12 suggested_replacement,full_comment_snippet
13
14`enclosing_function` is the most recent function definition signature
15above the citation in the same file (or "(file scope)" if none).
16
17`suggested_replacement` resolves the citation target -- opens
18`<file>` at `<line>` and reports `<basename>::<func_name>` so the
19caller can rewrite `libs/foo/src/bar.c:776` as `bar.c::priv_foo`.
20
21Output: CSV on stdout. One-shot agent tool, not wired into pre-commit.
22"""
23
24from __future__ import annotations
25
26import csv
27import re
28import subprocess
29import sys
30from collections.abc import Iterator
31from pathlib import Path
32
33sys.path.insert(0, str(Path(__file__).resolve().parent))
34
35from line_citation_lex import (
36 CITATION_RE,
37 all_tracked_files,
38 find_comment_spans,
39 find_mcdc_reason_spans,
40 is_exempt,
41 line_of_offset,
42)
43from line_citation_lex import is_in_scope as _lex_in_scope
44
45EXCLUDE_PREFIXES = (
46 "libs/third_party/",
47 "apps/shared_libs/third_party/",
48)
49
50
51# Heuristic: a function definition has a return type, name, params, and
52# an opening brace either on the signature line or on the next line.
53FUNC_DEF_RE = re.compile(r"^[A-Za-z_][\w\s\*]*?\b([A-Za-z_]\w*)\s*\‍([^;]*?\‍)\s*\{?\s*$")
54# Common keywords that look like function defs but aren't.
55FUNC_DEF_DENYLIST = {
56 "if",
57 "for",
58 "while",
59 "switch",
60 "return",
61 "sizeof",
62 "static_assert",
63 "typeof",
64 "do",
65}
66
67
68def enclosing_function(lines: list[str], target_line: int) -> str:
69 """Walk backward from target_line looking for a function definition."""
70 for idx in range(min(target_line - 1, len(lines) - 1), -1, -1):
71 line = lines[idx]
72 # Skip lines inside comments / preprocessor / blank
73 stripped = line.strip()
74 if not stripped or stripped.startswith(("//", "/*", "*", "#")):
75 continue
76 m = FUNC_DEF_RE.match(line)
77 if not m:
78 continue
79 name = m.group(1)
80 if name in FUNC_DEF_DENYLIST:
81 continue
82 # Sanity: avoid matching variable declarations like `int x = foo();`
83 if "=" in line.split("(")[0]:
84 continue
85 return name
86 return "(file scope)"
87
88
89_FILE_CACHE: dict[Path, list[str]] = {}
90
91
92def file_lines(path: Path) -> list[str] | None:
93 """Cached line list for a file, or None when it cannot be read.
94
95 Cached because resolving the enclosing function for each citation re-reads
96 the same few files repeatedly; the cache turns that quadratic re-read into
97 one read per file.
98 """
99 if path in _FILE_CACHE:
100 return _FILE_CACHE[path]
101 try:
102 text = path.read_text(encoding="utf-8", errors="replace")
103 except OSError:
104 return None
105 lines = text.splitlines()
106 _FILE_CACHE[path] = lines
107 return lines
108
109
110def resolve_target(repo_root: Path, citation_path: str, citation_line: int) -> str:
111 """Try to find the target file and report basename::func."""
112 # Try as repo-relative
113 candidates: list[Path] = []
114 p = repo_root / citation_path
115 if p.is_file():
116 candidates.append(p)
117 else:
118 # Search by basename across tracked files
119 base = Path(citation_path).name
120 for f in all_tracked_files():
121 if Path(f).name == base and is_in_scope(f):
122 candidates.append(repo_root / f)
123 break
124 if not candidates:
125 return f"{Path(citation_path).name}::(unknown)"
126 target = candidates[0]
127 lines = file_lines(target)
128 if lines is None:
129 return f"{target.name}::(unreadable)"
130 func = enclosing_function(lines, citation_line)
131 return f"{target.name}::{func}"
132
133
134def is_in_scope(path: str) -> bool:
135 """Whether a source path is subject to the ban, with this tool's exclusions."""
136 return _lex_in_scope(path, EXCLUDE_PREFIXES)
137
138
139def _rows_for_file(repo_root: Path, rel: str) -> Iterator[list[object]]:
140 """Yield one CSV row per non-exempt line-citation in ``rel``.
141
142 Exemptions come from line_citation_lex.is_exempt -- the same predicate the
143 gate applies -- so this aid cannot list work the gate does not want, nor
144 miss work it does. It used to carry its own hand-copied cascade.
145 """
146 path = repo_root / rel
147 if not path.is_file():
148 return
149 try:
150 text = path.read_text(encoding="utf-8", errors="replace")
151 except OSError:
152 return
153 lines = text.splitlines()
154 seen: set[tuple[int, str]] = set()
155 # Comments AND RA8_MCDC_DEACTIVATED(...) reason strings, so this aid lists
156 # exactly the set check_line_citations flags -- the gate scans both, and the
157 # two tools must not describe different trees (#547).
158 for start, end in find_comment_spans(text) + find_mcdc_reason_spans(text):
159 for m in CITATION_RE.finditer(text[start:end]):
160 line_no = line_of_offset(text, start + m.start())
161 abs_off = start + m.start()
162 matched = m.group(0)
163 line = lines[line_no - 1] if 1 <= line_no <= len(lines) else ""
164 line_start = text.rfind("\n", 0, abs_off) + 1
165 if is_exempt(matched, line, abs_off - line_start):
166 continue
167 if (line_no, matched) in seen:
168 continue
169 seen.add((line_no, matched))
170 yield [
171 rel,
172 line_no,
173 matched,
174 enclosing_function(lines, line_no),
175 resolve_target(repo_root, m.group(1), int(m.group(2))),
176 line.strip(),
177 ]
178
179
180def main() -> int:
181 """List every in-tree ``file:line`` citation with the function that encloses it.
182
183 A migration aid, not a gate: check_line_citations is what FAILS on these,
184 and this prints the same set together with the symbol name each one should
185 be rewritten to cite. Exits 0 regardless of what it finds.
186 """
187 repo_root = Path(
188 subprocess.run(
189 ["git", "rev-parse", "--show-toplevel"], # noqa: S607 # trusted: fixed git argv
190 check=True,
191 capture_output=True,
192 text=True,
193 ).stdout.strip()
194 )
195
196 files = [f for f in all_tracked_files() if is_in_scope(f)]
197
198 writer = csv.writer(sys.stdout)
199 writer.writerow(
200 [
201 "file",
202 "line_number",
203 "matched_citation",
204 "enclosing_function",
205 "suggested_replacement",
206 "full_comment_snippet",
207 ]
208 )
209
210 rows = 0
211 for f in files:
212 for row in _rows_for_file(repo_root, f):
213 writer.writerow(row)
214 rows += 1
215
216 print(f"# {rows} rows", file=sys.stderr)
217 return 0
218
219
220if __name__ == "__main__":
221 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157