3"""Syntax-aware comment extraction for the suppression inventory."""
5from __future__
import annotations
10from dataclasses
import dataclass
11from pathlib
import Path
13from suppression_catalog
import language
14from suppression_hash_lex
import hash_lines
15from suppression_model
import Finding
18@dataclass(frozen=True)
20 """One lexical comment with its source location."""
29 """Cross-line lexical state for C comments, strings, and raw strings."""
31 in_block_comment: bool =
False
32 in_line_comment: bool =
False
37def _python_comments(text: str) -> tuple[list[Comment], list[Finding]]:
38 """Extract real Python comments while ignoring strings and docstrings."""
39 comments: list[Comment] = []
41 tokens = tokenize.generate_tokens(io.StringIO(text).readline)
43 Comment(token.start[0], token.start[1] + 1, token.string[1:])
45 if token.type == tokenize.COMMENT
47 except (IndentationError, tokenize.TokenError)
as exc:
48 return comments, [Finding(
"python-tokenize", str(exc))]
52def _raw_string_end(raw: str, index: int) -> tuple[str, int] |
None:
53 """Return a C++ raw-string terminator and body start at one source offset."""
54 if index > 0
and (raw[index - 1].isalnum()
or raw[index - 1] ==
"_"):
56 match = re.match(
r'(?:u8|u|U|L)?R"([^ ()\\\t]{0,16})\(', raw[index:])
59 return ")" + match.group(1) +
'"', index + match.end()
62def _continue_block_comment(
63 raw: str, line_no: int, index: int, state: CLexState, comments: list[Comment]
65 """Consume one line fragment of an active C block comment."""
66 end = raw.find(
"*/", index)
67 stop = len(raw)
if end < 0
else end
68 comments.append(Comment(line_no, index + 1, raw[index:stop]))
71 state.in_block_comment =
False
75def _continue_raw_string(raw: str, index: int, state: CLexState) -> int:
76 """Consume one line fragment of an active C++ raw string."""
77 end = raw.find(state.raw_end, index)
80 index = end + len(state.raw_end)
85def _continue_quoted_string(raw: str, index: int, state: CLexState) -> int:
86 """Consume one token from an active ordinary C string or character literal."""
90 if char == state.quote:
100 comments: list[Comment],
101) -> tuple[int, bool]:
102 """Consume an active block comment, raw string, or ordinary string state."""
103 if state.in_block_comment:
104 return _continue_block_comment(raw, line_no, index, state, comments),
True
106 return _continue_raw_string(raw, index, state),
True
108 return _continue_quoted_string(raw, index, state),
True
112def _scan_c_line(raw: str, line_no: int, state: CLexState) -> list[Comment]:
113 """Extract C/C++ comments from one line and carry block-comment state."""
114 comments: list[Comment] = []
115 if state.in_line_comment:
116 comments.append(Comment(line_no, 1, raw))
117 state.in_line_comment = raw.endswith(
"\\")
120 while index < len(raw):
121 index, handled = _continue_c_state(raw, line_no, index, state, comments)
125 raw_string = _raw_string_end(raw, index)
126 if raw_string
is not None:
127 state.raw_end, index = raw_string
128 elif char
in {
'"',
"'"}:
131 elif raw.startswith(
"//", index):
132 comments.append(Comment(line_no, index + 1, raw[index + 2 :]))
133 state.in_line_comment = raw.endswith(
"\\")
135 elif raw.startswith(
"/*", index):
136 state.in_block_comment =
True
143def _c_comments(text: str) -> tuple[list[Comment], list[Finding]]:
144 """Extract lexical C/C++ comments without matching string literals."""
145 comments: list[Comment] = []
147 for line_no, raw
in enumerate(text.splitlines(), start=1):
148 comments.extend(_scan_c_line(raw, line_no, state))
149 if state.quote
and not raw.endswith(
"\\"):
151 findings: list[Finding] = []
152 if state.in_block_comment:
153 findings.append(Finding(
"unterminated-comment",
"unterminated C block comment"))
154 if state.in_line_comment:
155 findings.append(Finding(
"unterminated-line-comment-splice",
"line splice reaches EOF"))
157 findings.append(Finding(
"unterminated-string",
"unterminated C++ raw string"))
159 findings.append(Finding(
"unterminated-string",
"unterminated backslash-spliced string"))
160 return comments, findings
163def _hash_comments(path: str, text: str) -> tuple[list[Comment], list[Finding]]:
164 """Extract comments from syntax-aware shell, CMake, YAML, and config lines."""
165 lines, findings = hash_lines(path, text)
167 Comment(line.line, line.comment_column, line.comment)
for line
in lines
if line.comment
169 return comments, findings
172def _html_comments(text: str) -> tuple[list[Comment], list[Finding]]:
173 """Extract single- and multi-line HTML comments used by policy markers."""
174 comments: list[Comment] = []
175 buffer: list[str] = []
178 for line_no, raw
in enumerate(text.splitlines(), start=1):
180 while index < len(raw):
182 end = raw.find(
"-->", index)
184 buffer.append(raw[index:])
186 buffer.append(raw[index:end])
187 comments.append(Comment(start_line, start_column,
" ".join(buffer)))
191 start = raw.find(
"<!--", index)
194 end = raw.find(
"-->", start + 4)
196 comments.append(Comment(line_no, start + 1, raw[start + 4 : end]))
200 start_column = start + 1
201 buffer = [raw[start + 4 :]]
205 message = f
"unterminated HTML comment starting at line {start_line}"
206 findings.append(Finding(
"unterminated-html-comment", message, line=start_line))
207 return comments, findings
210def _mask_markdown_code(text: str) -> str:
211 """Blank fenced and inline code while preserving source coordinates."""
212 masked: list[str] = []
215 for raw
in text.splitlines(keepends=
True):
216 content = raw.rstrip(
"\r\n")
217 newline = raw[len(content) :]
218 fence_match = re.match(
r"^ {0,3}(`{3,}|~{3,})", content)
220 closes = re.match(rf
"^ {{0,3}}{re.escape(fence[0])}{{{len(fence)},}}\s*$", content)
223 masked.append(
" " * len(content) + newline)
225 if not inline_ticks
and fence_match:
226 fence = fence_match.group(1)
227 masked.append(
" " * len(content) + newline)
229 chars = list(content)
231 while index < len(content):
233 closing =
"`" * inline_ticks
234 end = content.find(closing, index)
235 limit = len(content)
if end < 0
else end + inline_ticks
236 chars[index:limit] =
" " * (limit - index)
241 if content[index] !=
"`":
245 while end < len(content)
and content[end] ==
"`":
247 inline_ticks = end - index
248 chars[index:end] =
" " * inline_ticks
250 masked.append(
"".join(chars) + newline)
251 return "".join(masked)
254def extract_comments(path: str, text: str) -> tuple[list[Comment], list[Finding]]:
255 """Dispatch to the lexical comment extractor appropriate for one file."""
256 first_line = text.partition(
"\n")[0]
257 kind = language(path, first_line)
259 comments, findings = _python_comments(text)
260 elif kind ==
"c-family":
261 comments, findings = _c_comments(text)
263 comments, findings = _hash_comments(path, text)
265 comments, findings = [], []
267 html_source = _mask_markdown_code(text)
if Path(path).suffix ==
".md" else text
268 html_comments, html_findings = _html_comments(html_source)
269 comments.extend(html_comments)
270 findings.extend(html_findings)
271 return comments, findings