3"""Lexical helpers for languages whose ordinary comment marker is ``#``."""
5from __future__
import annotations
8from dataclasses
import dataclass, field
9from pathlib
import Path
11from suppression_catalog
import is_shell_control
12from suppression_model
import Finding
15@dataclass(frozen=True)
17 """Active code and optional comment from one hash-comment language line."""
22 comment_column: int = 0
27 """Cross-line quote and queued-heredoc state for shell source."""
30 arithmetic_depth: int = 0
31 heredocs: list[tuple[str, bool]] = field(default_factory=list)
34def _comment_index(raw: str, *, shell_words: bool =
False) -> int |
None:
35 """Return the first unquoted hash-comment column on a shell-like line."""
38 for index, char
in enumerate(raw):
41 elif char ==
"\\" and quote !=
"'":
43 elif quote
and char == quote:
45 elif not quote
and char
in {
'"',
"'"}:
53 or raw[index - 1].isspace()
54 or raw[index - 1]
in ";&|()<>"
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))
69 lines.append(HashLexLine(line_no, raw[:column], raw[column + 1 :], column + 1))
73def _heredoc_word(raw: str, start: int) -> tuple[str, int, str]:
74 """Parse one shell heredoc word, removing delimiter-only quoting."""
78 while index < len(raw):
83 elif char ==
"\\" and quote !=
"'" and index + 1 < len(raw):
85 word.append(raw[index])
88 elif char
in {
'"',
"'"}:
90 elif char ==
"\\" and index + 1 < len(raw):
92 word.append(raw[index])
93 elif char.isspace()
or char
in ";&|()<>":
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
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
109 if not state.arithmetic_depth
and raw.startswith(
"((", index):
110 state.arithmetic_depth += 1
112 if state.arithmetic_depth
and raw.startswith(
"))", index):
113 state.arithmetic_depth -= 1
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):
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":
129 delimiter, end, error = _heredoc_word(raw, word_start)
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
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."""
139 findings: list[Finding] = []
140 while index < len(raw):
143 if char == state.quote:
145 elif char ==
"\\" and state.quote !=
"'" and index + 1 < len(raw):
149 if char
in {
'"',
"'",
"`"}:
156 arithmetic_index = _arithmetic_index(raw, index, state)
157 if arithmetic_index
is not None:
158 index = arithmetic_index
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
169 return raw,
"", 0, findings
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):
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,
""))
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)
189 delimiters =
", ".join(item[0]
for item
in state.heredocs)
190 findings.append(Finding(
"unterminated-heredoc", delimiters))
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
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,
""))
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
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."""
223 bracket_end = bracket_state[1:]
224 end = raw.find(bracket_end)
226 return "",
"", 0, bracket_state
227 index = end + len(bracket_end)
230 while index < len(raw):
234 if char ==
"\\" and index + 1 < len(raw):
236 code.append(raw[index])
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())
251 return "".join(code),
"", 0,
"A" + bracket_end
252 index = end + len(bracket_end)
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())
260 return "".join(code),
"", 0,
"C" + bracket_end
261 index = end + len(bracket_end)
263 return "".join(code), raw[index + 1 :], index + 1, bracket_state
266 return "".join(code),
"", 0, bracket_state
269def _cmake_lines(text: str) -> tuple[list[HashLexLine], list[Finding]]:
270 """Split CMake comments while excluding balanced bracket regions."""
271 lines: list[HashLexLine] = []
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))
278 kind =
"comment" if bracket_end.startswith(
"C")
else "argument"
281 "unterminated-cmake-bracket",
282 f
"unterminated CMake bracket {kind}; expected {bracket_end[1:]}",
285 return lines, findings
288def hash_lines(path: str, text: str) -> tuple[list[HashLexLine], list[Finding]]:
289 """Return syntax-aware code/comment lines for one hash-comment language."""
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), []