ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_hash_lex.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Lexical helpers for languages whose ordinary comment marker is ``#``."""
4
5from __future__ import annotations
6
7import re
8from dataclasses import dataclass, field
9from pathlib import Path
10
11from suppression_catalog import is_shell_control
12from suppression_model import Finding
13
14
15@dataclass(frozen=True)
16class HashLexLine:
17 """Active code and optional comment from one hash-comment language line."""
18
19 line: int
20 code: str
21 comment: str = ""
22 comment_column: int = 0
23
24
25@dataclass
26class ShellLexState:
27 """Cross-line quote and queued-heredoc state for shell source."""
28
29 quote: str = ""
30 arithmetic_depth: int = 0
31 heredocs: list[tuple[str, bool]] = field(default_factory=list)
32
33
34def _comment_index(raw: str, *, shell_words: bool = False) -> int | None:
35 """Return the first unquoted hash-comment column on a shell-like line."""
36 quote = ""
37 escaped = False
38 for index, char in enumerate(raw):
39 if escaped:
40 escaped = False
41 elif char == "\\" and quote != "'":
42 escaped = True
43 elif quote and char == quote:
44 quote = ""
45 elif not quote and char in {'"', "'"}:
46 quote = char
47 elif (
48 not quote
49 and char == "#"
50 and (
51 not shell_words
52 or index == 0
53 or raw[index - 1].isspace()
54 or raw[index - 1] in ";&|()<>"
55 )
56 ):
57 return index
58 return None
59
60
61def _basic_lines(text: str, *, shell_words: bool) -> list[HashLexLine]:
62 """Split ordinary hash-comment files into active code and comments."""
63 lines: list[HashLexLine] = []
64 for line_no, raw in enumerate(text.splitlines(), start=1):
65 column = _comment_index(raw, shell_words=shell_words)
66 if column is None or (line_no == 1 and raw.startswith("#!")):
67 lines.append(HashLexLine(line_no, raw))
68 else:
69 lines.append(HashLexLine(line_no, raw[:column], raw[column + 1 :], column + 1))
70 return lines
71
72
73def _heredoc_word(raw: str, start: int) -> tuple[str, int, str]:
74 """Parse one shell heredoc word, removing delimiter-only quoting."""
75 index = start
76 word: list[str] = []
77 quote = ""
78 while index < len(raw):
79 char = raw[index]
80 if quote:
81 if char == quote:
82 quote = ""
83 elif char == "\\" and quote != "'" and index + 1 < len(raw):
84 index += 1
85 word.append(raw[index])
86 else:
87 word.append(char)
88 elif char in {'"', "'"}:
89 quote = char
90 elif char == "\\" and index + 1 < len(raw):
91 index += 1
92 word.append(raw[index])
93 elif char.isspace() or char in ";&|()<>":
94 break
95 else:
96 word.append(char)
97 index += 1
98 error = "unterminated quote in heredoc delimiter" if quote else ""
99 if not word and not error:
100 error = "empty heredoc delimiter"
101 return "".join(word), index, error
102
103
104def _arithmetic_index(raw: str, index: int, state: ShellLexState) -> int | None:
105 """Consume an arithmetic opener/closer, or report no state transition."""
106 if raw.startswith("$((", index):
107 state.arithmetic_depth += 1
108 return index + 3
109 if not state.arithmetic_depth and raw.startswith("((", index):
110 state.arithmetic_depth += 1
111 return index + 2
112 if state.arithmetic_depth and raw.startswith("))", index):
113 state.arithmetic_depth -= 1
114 return index + 2
115 return None
116
117
118def _heredoc_index(raw: str, index: int, state: ShellLexState) -> tuple[int | None, Finding | None]:
119 """Consume one real heredoc operator while ignoring shell here-strings."""
120 if raw.startswith("<<<", index):
121 return index + 3, None
122 if state.arithmetic_depth or not raw.startswith("<<", index):
123 return None, None
124 word_start = index + 2
125 strip_tabs = word_start < len(raw) and raw[word_start] == "-"
126 word_start += int(strip_tabs)
127 while word_start < len(raw) and raw[word_start] in " \t":
128 word_start += 1
129 delimiter, end, error = _heredoc_word(raw, word_start)
130 if error:
131 return max(end, word_start + 1), Finding("malformed-heredoc", error)
132 state.heredocs.append((delimiter, strip_tabs))
133 return max(end, word_start + 1), None
134
135
136def _shell_code(raw: str, state: ShellLexState) -> tuple[str, str, int, list[Finding]]:
137 """Split one active shell line and queue quote-aware heredoc delimiters."""
138 index = 0
139 findings: list[Finding] = []
140 while index < len(raw):
141 char = raw[index]
142 if state.quote:
143 if char == state.quote:
144 state.quote = ""
145 elif char == "\\" and state.quote != "'" and index + 1 < len(raw):
146 index += 1
147 index += 1
148 continue
149 if char in {'"', "'", "`"}:
150 state.quote = char
151 index += 1
152 continue
153 if char == "\\":
154 index += 2
155 continue
156 arithmetic_index = _arithmetic_index(raw, index, state)
157 if arithmetic_index is not None:
158 index = arithmetic_index
159 continue
160 if char == "#" and (index == 0 or raw[index - 1].isspace() or raw[index - 1] in ";&|()<>"):
161 return raw[:index], raw[index + 1 :], index + 1, findings
162 heredoc_index, finding = _heredoc_index(raw, index, state)
163 if heredoc_index is not None:
164 if finding is not None:
165 findings.append(finding)
166 index = heredoc_index
167 continue
168 index += 1
169 return raw, "", 0, findings
170
171
172def _shell_lines(text: str) -> tuple[list[HashLexLine], list[Finding]]:
173 """Split shell comments and fail closed on malformed heredoc/quote state."""
174 lines: list[HashLexLine] = []
175 findings: list[Finding] = []
176 state = ShellLexState()
177 for line_no, raw in enumerate(text.splitlines(), start=1):
178 if state.heredocs:
179 delimiter, strip_tabs = state.heredocs[0]
180 candidate = raw.lstrip("\t") if strip_tabs else raw
181 if candidate == delimiter:
182 state.heredocs.pop(0)
183 lines.append(HashLexLine(line_no, ""))
184 continue
185 code, comment, column, line_findings = _shell_code(raw, state)
186 lines.append(HashLexLine(line_no, code, comment, column))
187 findings.extend(Finding(item.code, item.message, line=line_no) for item in line_findings)
188 if state.heredocs:
189 delimiters = ", ".join(item[0] for item in state.heredocs)
190 findings.append(Finding("unterminated-heredoc", delimiters))
191 if state.quote:
192 findings.append(Finding("unterminated-shell-quote", state.quote))
193 if state.arithmetic_depth:
194 findings.append(Finding("unterminated-shell-arithmetic", str(state.arithmetic_depth)))
195 return lines, findings
196
197
198def _yaml_lines(text: str) -> list[HashLexLine]:
199 """Split YAML comments without treating block-scalar payload as YAML syntax."""
200 lines: list[HashLexLine] = []
201 block_indent: int | None = None
202 for line_no, raw in enumerate(text.splitlines(), start=1):
203 indent = len(raw) - len(raw.lstrip(" "))
204 if block_indent is not None and (not raw.strip() or indent > block_indent):
205 lines.append(HashLexLine(line_no, ""))
206 continue
207 block_indent = None
208 column = _comment_index(raw, shell_words=True)
209 code = raw if column is None else raw[:column]
210 comment = "" if column is None else raw[column + 1 :]
211 lines.append(HashLexLine(line_no, code, comment, 0 if column is None else column + 1))
212 indicator = r"[>|](?:[+-]?[1-9]?|[1-9][+-]?)\s*$"
213 if re.search(rf"(?:^\s*-\s+|:\s*){indicator}", code):
214 block_indent = indent
215 return lines
216
217
218def _cmake_code_line(raw: str, bracket_state: str) -> tuple[str, str, int, str]:
219 """Mask CMake bracket arguments and return code, comment, column, and state."""
220 code: list[str] = []
221 index = 0
222 if bracket_state:
223 bracket_end = bracket_state[1:]
224 end = raw.find(bracket_end)
225 if end < 0:
226 return "", "", 0, bracket_state
227 index = end + len(bracket_end)
228 bracket_state = ""
229 quote = ""
230 while index < len(raw):
231 char = raw[index]
232 if quote:
233 code.append(char)
234 if char == "\\" and index + 1 < len(raw):
235 index += 1
236 code.append(raw[index])
237 elif char == quote:
238 quote = ""
239 index += 1
240 continue
241 if char == '"':
242 quote = char
243 code.append(char)
244 index += 1
245 continue
246 match = re.match(r"\‍[(=*)\‍[", raw[index:])
247 if match is not None:
248 bracket_end = "]" + match.group(1) + "]"
249 end = raw.find(bracket_end, index + match.end())
250 if end < 0:
251 return "".join(code), "", 0, "A" + bracket_end
252 index = end + len(bracket_end)
253 continue
254 if char == "#":
255 match = re.match(r"#\‍[(=*)\‍[", raw[index:])
256 if match is not None:
257 bracket_end = "]" + match.group(1) + "]"
258 end = raw.find(bracket_end, index + match.end())
259 if end < 0:
260 return "".join(code), "", 0, "C" + bracket_end
261 index = end + len(bracket_end)
262 continue
263 return "".join(code), raw[index + 1 :], index + 1, bracket_state
264 code.append(char)
265 index += 1
266 return "".join(code), "", 0, bracket_state
267
268
269def _cmake_lines(text: str) -> tuple[list[HashLexLine], list[Finding]]:
270 """Split CMake comments while excluding balanced bracket regions."""
271 lines: list[HashLexLine] = []
272 bracket_end = ""
273 for line_no, raw in enumerate(text.splitlines(), start=1):
274 code, comment, column, bracket_end = _cmake_code_line(raw, bracket_end)
275 lines.append(HashLexLine(line_no, code, comment, column))
276 findings = []
277 if bracket_end:
278 kind = "comment" if bracket_end.startswith("C") else "argument"
279 findings.append(
280 Finding(
281 "unterminated-cmake-bracket",
282 f"unterminated CMake bracket {kind}; expected {bracket_end[1:]}",
283 )
284 )
285 return lines, findings
286
287
288def hash_lines(path: str, text: str) -> tuple[list[HashLexLine], list[Finding]]:
289 """Return syntax-aware code/comment lines for one hash-comment language."""
290 item = Path(path)
291 first_line = text.partition("\n")[0]
292 if is_shell_control(path, first_line):
293 return _shell_lines(text)
294 if item.suffix.lower() in {".yaml", ".yml"}:
295 return _yaml_lines(text), []
296 if item.name == "CMakeLists.txt" or item.suffix.lower() == ".cmake":
297 return _cmake_lines(text)
298 return _basic_lines(text, shell_words=False), []