Coverage for src/pullapprove/trust/formatting.py: 98%
188 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-02 11:01 -0500
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-02 11:01 -0500
1"""Trust rules: changes that don't alter meaning — empty new files, whitespace,
2line wrapping, inter-token spacing, and punctuation/quote style."""
4from __future__ import annotations
6from itertools import pairwise
8from ..diff import DiffCode, DiffFile, DiffHunk
9from .delta import paired_changed_lines, paired_token_delta
10from .helpers import extension, leading_indent
11from .labels import Trust
12from .linescan import collapse_ws_outside_strings, consume_string
13from .tokens import OP, STRING, Token
15# The deeply-tested core the content rules (`_style`, `_line_length`) share: the
16# JS/TS family + Python. These have free-form whitespace (so a reflow is neutral)
17# and use `'`/`"`/`;`/`,` interchangeably enough for `_style` — and they're the
18# ones we've actually validated. Other brace languages (Go, Rust, Java, C, …) are
19# plausible but unverified per-language — add them here only with a test corpus.
20# Data/markup (csv, tsv, yaml, md, …) stays out entirely: there a newline, comma,
21# or quote can be semantic, so these rules would hide real changes.
22_CORE_CONTENT_LANGUAGES = frozenset("js jsx ts tsx mjs mts cjs cts py".split())
25def _empty_file(file: DiffFile, hunk: DiffHunk) -> Trust | None:
26 # A brand-new file's hunk is all additions, so "empty" is just "all blank".
27 if not hunk.is_new_file:
28 return None
29 if all(not line.content.strip() for line in hunk.lines):
30 return Trust.EMPTY_FILE
31 return None
34def _whitespace(file: DiffFile, hunk: DiffHunk) -> Trust | None:
35 # Same language gate as the other content rules: in data/markup a blank line
36 # is often semantic (a Markdown paragraph break, a line inside a YAML block
37 # scalar), so only the validated code languages qualify.
38 if extension(file) not in _CORE_CONTENT_LANGUAGES:
39 return None
40 # KNOWN LIMITATION: a blank-line-only change *inside* a multi-line string
41 # literal (a `'''…'''` docstring, a template literal, a heredoc) changes the
42 # string's value, but reads as trivial here. We can't tell from a single hunk
43 # whether a blank line falls inside a literal — the opening delimiter is
44 # usually above the hunk, out of view — so this rare case is accepted rather
45 # than guarded with an unreliable heuristic.
46 changed = hunk.changed_lines
47 if changed and all(not line.content.strip() for line in changed):
48 return Trust.WHITESPACE
49 return None
52# A wrapped line "continues" to the next when it ends inside an open bracket,
53# with a `\`, or on an operator / opener / comma / dot / colon — never on an
54# identifier, value, closer, or `;`. That separates a real reflow from two
55# statements joined (a Python suite boundary, a JS ASI point), where the newline
56# is semantic and joining changes meaning.
57_CONTINUATION_CHARS = frozenset("=+-*/%<>&|^~,.:?([{")
60def _bracket_delta(text: str) -> int:
61 """Net bracket-depth change of `text`, ignoring brackets inside strings."""
62 depth = 0
63 i, n = 0, len(text)
64 while i < n:
65 ch = text[i]
66 if ch in "\"'`":
67 i = consume_string(text, i)
68 continue
69 if ch in "([{":
70 depth += 1
71 elif ch in ")]}":
72 depth -= 1
73 i += 1
74 return depth
77def _is_reflow(lines: list[DiffCode]) -> bool:
78 """True if `lines` is one statement wrapped across lines: every break (all but
79 the last) sits inside an open bracket, ends with `\\`, or ends on a
80 continuation operator. Otherwise the join crosses a statement boundary and the
81 newline is not neutral."""
82 depth = 0
83 for line in lines[:-1]:
84 stripped = line.content.rstrip()
85 depth += _bracket_delta(line.content)
86 if (
87 depth > 0
88 or stripped.endswith("\\")
89 or (stripped and stripped[-1] in _CONTINUATION_CHARS)
90 ):
91 continue
92 return False
93 return True
96def _contiguous_replacement(hunk: DiffHunk) -> bool:
97 """True when the hunk's changed lines form ONE deletions-then-additions block
98 with no context line inside it. A reflow rewraps one statement in place;
99 deletions that bracket a context line mean code crossed a statement boundary,
100 and joining the sides would compare a reordering as if it were a rewrap."""
101 seen_deletion = False
102 seen_addition = False
103 done = False
104 for line in hunk.lines:
105 if line.is_context():
106 if seen_deletion or seen_addition:
107 done = True
108 continue
109 if done:
110 return False # a second changed block — not one replacement
111 if line.is_deletion():
112 if seen_addition:
113 return False # deletions after additions — interleaved blocks
114 seen_deletion = True
115 else:
116 seen_addition = True
117 return True
120def _has_line_comment(text: str, prefix: str, quotes: str) -> bool:
121 """True if `text` starts a line comment outside a string literal. Unlike
122 `strip_inline_comment`, no whitespace-before-prefix requirement: for reflow
123 safety a glued comment (`foo()// note`) swallows a join just the same."""
124 i, n = 0, len(text)
125 while i < n:
126 if text[i] in quotes:
127 i = consume_string(text, i)
128 elif text.startswith(prefix, i):
129 return True
130 else:
131 i += 1
132 return False
135def _line_length(file: DiffFile, hunk: DiffHunk) -> Trust | None:
136 """Code wrapped/unwrapped across lines: identical content after joining."""
137 # In data/markup a newline separates records, so joining two lines isn't neutral.
138 ext = extension(file)
139 if ext not in _CORE_CONTENT_LANGUAGES:
140 return None
141 added, removed = hunk.added_lines, hunk.removed_lines
142 if not added or not removed:
143 return None
144 # Wrapping/unwrapping changes the line count; an equal-count edit is a
145 # same-shape change (handled by _style), not a reflow.
146 if len(added) == len(removed):
147 return None
148 # The joined comparison below flattens line positions, so it's only sound
149 # when the change is one contiguous replacement — deletions bracketing a
150 # context line would let moved code read as a neutral rewrap.
151 if not _contiguous_replacement(hunk):
152 return None
153 # Default-deny: a neutral reflow requires every break on BOTH sides to be a
154 # continuation (inside brackets / `\` / an operator). A break anywhere at
155 # statement level means the newline is semantic (a Python suite, a JS ASI
156 # point), so the "reflow" isn't neutral — decline rather than hide it.
157 if not (_is_reflow(added) and _is_reflow(removed)):
158 return None
159 # Indentation is semantic in Python (block scope); a reflow that also shifts
160 # the statement's own indent is a dedent/indent, not a neutral wrap. Only the
161 # FIRST line carries the statement's indent (continuation lines are naturally
162 # re-indented), and `collapse_ws_outside_strings` strips leading space — so
163 # without this guard an indent change would compare equal and be hidden.
164 if leading_indent(added[0].content) != leading_indent(removed[0].content):
165 return None
166 # A line comment runs to end-of-line, so joining a commented line with the
167 # next drags the next line's code INTO the comment (`foo(a, // why` + `b)`
168 # joins to a line where `b)` is comment text). Even when the joined texts
169 # compare equal, that join is never neutral — decline if any line but the
170 # last starts a line comment. A comment on the LAST line is fine (nothing is
171 # joined after it), and block comments are fine (`/* … */` spans the join
172 # unchanged either way).
173 prefix = "#" if ext == "py" else "//"
174 quotes = "\"'" if ext == "py" else "\"'`"
175 for side in (added, removed):
176 if any(_has_line_comment(line.content, prefix, quotes) for line in side[:-1]):
177 return None
178 # Collapse only the whitespace *between* tokens — preserving string interiors
179 # so a real edit inside a literal can't masquerade as a reflow.
180 joined_added = collapse_ws_outside_strings(" ".join(line.content for line in added))
181 joined_removed = collapse_ws_outside_strings(
182 " ".join(line.content for line in removed)
183 )
184 if joined_added and joined_added == joined_removed:
185 return Trust.LINE_LENGTH
186 return None
189def _string_value(text: str) -> str:
190 """The value of a `'`/`"` string literal, delimiter-agnostic — so `'a'` and
191 `"a"` compare equal but a change to the *contents* does not. Delimiter escapes
192 are normalized (`'it\\'s'` and `"it's"` both decode to `it's`); a backtick
193 template literal is returned verbatim (its semantics aren't delimiter-style)."""
194 quote = text[0]
195 if quote not in "\"'":
196 return text
197 return text[1:-1].replace("\\" + quote, quote)
200def _style_key(
201 tokens: list[Token], collapse_comma: bool, collapse_semicolon: bool
202) -> list[object]:
203 """A token key in which only *stylistic* differences collapse: quote-delimiter
204 style (strings compare by decoded value), plus a trailing `,`/`;` when
205 `collapse_comma`/`collapse_semicolon` say it is stylistic here. Everything else
206 — identifiers, operators, numbers, string *contents* — is compared verbatim, so
207 a real change survives.
209 Trailing punctuation isn't always stylistic: a Python trailing comma builds a
210 tuple (`(x,)`, `x = a,`, `a[1,]`), and a JS/TS trailing semicolon can be
211 ASI-load-bearing. In those cases the flag is False and the punctuation stays in
212 the key, so the change is never hidden as style."""
213 key: list[object] = []
214 n = len(tokens)
215 for i, tok in enumerate(tokens):
216 if collapse_semicolon and tok.kind == OP and tok.text == ";" and i == n - 1:
217 continue # trailing semicolon
218 if (
219 collapse_comma
220 and tok.kind == OP
221 and tok.text == ","
222 and (
223 i == n - 1 or (tokens[i + 1].kind == OP and tokens[i + 1].text in ")]}")
224 )
225 ):
226 continue # trailing comma (line end, or before a closer)
227 # Tag string values so a literal can't collide with an identifier of the
228 # same text (a `'foo'` -> `foo` change must stay visible).
229 key.append(("str", _string_value(tok.text)) if tok.kind == STRING else tok.text)
230 return key
233def _style_delta(
234 old_tokens: list[Token],
235 new_tokens: list[Token],
236 collapse_comma: bool,
237 collapse_semicolon: bool,
238) -> bool | None:
239 """Compare one line pair: True if it differs ONLY in style (semicolons /
240 trailing commas / quote delimiters), False if token-identical, None to
241 decline (a non-style difference remains)."""
242 old_key = _style_key(old_tokens, collapse_comma, collapse_semicolon)
243 new_key = _style_key(new_tokens, collapse_comma, collapse_semicolon)
244 if old_key != new_key:
245 return None # a non-style difference remains
246 # Style-only iff the tokens themselves differ (a quote delimiter or trailing
247 # punctuation). If the raw tokens match, any remaining difference is in a
248 # comment or whitespace the tokenizer drops — not this rule's job (leave it
249 # for _comments), so don't claim it as style.
250 return [t.text for t in old_tokens] != [t.text for t in new_tokens]
253# Line-leading characters that, under JS/TS Automatic Semicolon Insertion,
254# continue the previous statement rather than start a new one. A leading `/` is
255# also a continuation (regex literal or division) but is handled separately, since
256# `//` and `/*` start comments, not statements.
257_ASI_LEADERS = frozenset(("(", "[", "`", "+", "-"))
260def _continues_previous_line(text: str) -> bool:
261 """True if `text` begins with a token that, under JS/TS ASI, attaches to the
262 previous line instead of starting a new statement."""
263 stripped = text.lstrip()
264 if not stripped:
265 return False
266 if stripped[0] in _ASI_LEADERS:
267 return True
268 # A leading `/` is a regex literal or division that continues the line above —
269 # unless it opens a comment (`//`, `/*`), which is inert.
270 return stripped[0] == "/" and not stripped.startswith(("//", "/*"))
273def _asi_hazard(hunk: DiffHunk) -> bool:
274 """True if the hunk's new-file side has a statement line that continues the
275 previous non-blank line under JS/TS Automatic Semicolon Insertion. There,
276 adding or removing a trailing `;` on the line above merges or splits two
277 statements — a real behavior change — so `_style` must not treat that `;` as
278 stylistic. ASI ignores blank lines, so we compare against the last non-blank
279 line, not the strictly-adjacent one.
281 When only blank lines are visible after the last changed line, the statement
282 ASI would merge with sits past the context window — the scan can't prove the
283 `;` inert, so that counts as a hazard too (default-deny). A hunk that ENDS on
284 the changed line is different: git always emits trailing context when lines
285 exist, so that's the end of the file and there is nothing to merge with."""
286 prev_nonblank = ""
287 last_nonblank_changed = False
288 blanks_after = False
289 for line in hunk.lines:
290 if line.is_deletion():
291 continue
292 if prev_nonblank and _continues_previous_line(line.content):
293 return True
294 if line.content.strip():
295 prev_nonblank = line.content
296 last_nonblank_changed = line.is_addition()
297 blanks_after = False
298 else:
299 blanks_after = True
300 return last_nonblank_changed and blanks_after
303def _style(file: DiffFile, hunk: DiffHunk) -> Trust | None:
304 """Paired lines that differ only in semicolons, quotes, or trailing commas.
306 Python tokenizes under the `py` dialect and the JS family under `ts` — they
307 can't share one dialect, because `//` is a line comment in JS but floor
308 DIVISION in Python (tokenizing `x // 2` as `ts` would drop `// 2` as a comment
309 and hide a divisor change). The two semantic splits — a Python trailing comma
310 builds a tuple, a JS/TS trailing semicolon can be ASI-load-bearing — are
311 handled by `collapse_comma`/`collapse_semicolon`."""
312 ext = extension(file)
313 if ext not in _CORE_CONTENT_LANGUAGES:
314 return None # elsewhere ';'/','/quotes may be semantic (data, char literals)
315 # The positional pairing gate runs first, so the O(hunk-lines) ASI scan
316 # below never runs for hunks the pairing would reject anyway.
317 pairs = paired_changed_lines(hunk)
318 if pairs is None:
319 return None
320 dialect = "py" if ext == "py" else "ts"
321 collapse_comma = ext != "py" # a Python trailing comma builds a tuple
322 # Python has no ASI, so its `;` is always stylistic; in JS/TS a trailing `;` is
323 # only stylistic when no following line continues the statement.
324 collapse_semicolon = ext == "py" or not _asi_hazard(hunk)
325 return (
326 Trust.STYLE
327 if paired_token_delta(
328 pairs,
329 dialect,
330 lambda old, new, o, n: _style_delta(
331 o, n, collapse_comma, collapse_semicolon
332 ),
333 )
334 else None
335 )
338def _whitespace_gaps_only(stripped: str, tokens: list[Token]) -> bool:
339 """True if everything between consecutive tokens is whitespace. The tokenizer
340 silently skips block comments, so without this check a mid-line `/* … */`
341 edit would compare token-identical and read as a spacing change."""
342 pos = 0
343 for tok in tokens:
344 if stripped[pos : tok.start].strip():
345 return False
346 pos = tok.end
347 return True
350def _line_tail(stripped: str, tokens: list[Token]) -> str:
351 """The text after the last token — a trailing line comment, or empty."""
352 return stripped[tokens[-1].end :].strip() if tokens else stripped
355def _spacing_delta(
356 old: DiffCode,
357 new: DiffCode,
358 old_tokens: list[Token],
359 new_tokens: list[Token],
360 dialect: str,
361) -> bool | None:
362 """Compare one line pair: True if only the whitespace between or after the
363 tokens moved, False if the lines are identical, None to decline (something
364 other than inter-token whitespace changed)."""
365 if [t.text for t in old_tokens] != [t.text for t in new_tokens]:
366 return None # a token changed — not a whitespace-only edit
367 # Token offsets index into the stripped line (what `paired_token_delta`
368 # tokenized), so the gap/tail checks scan that same text.
369 old_stripped, new_stripped = old.content.strip(), new.content.strip()
370 if not _whitespace_gaps_only(old_stripped, old_tokens):
371 return None # a mid-line block comment sits in a gap
372 if not _whitespace_gaps_only(new_stripped, new_tokens):
373 return None
374 if _line_tail(old_stripped, old_tokens) != _line_tail(new_stripped, new_tokens):
375 return None # the trailing comment changed — _comments' territory
376 for (o1, o2), (n1, n2) in zip(pairwise(old_tokens), pairwise(new_tokens)):
377 if (o1.end == o2.start) == (n1.end == n2.start):
378 continue # this pair's grouping didn't change
379 if (o1.kind == OP) == (o2.kind == OP):
380 return None # regrouped punctuation / word+string — semantic
381 if dialect == "ts" and (o1.text in "/<>" or o2.text in "/<>"):
382 return None # regex / generic ambiguity — spacing decides parse
383 return old.content != new.content
386def _spacing(file: DiffFile, hunk: DiffHunk) -> Trust | None:
387 """Paired lines whose tokens are identical and only the whitespace between or
388 after them moved — `x=1` -> `x = 1`, `f( a )` -> `f(a)`, trailing-whitespace
389 trims, alignment shifts.
391 Whitespace inside a string literal is a value change and never qualifies
392 (string tokens compare verbatim). Two default-deny guards beyond the shared
393 pairing/indent/tokenize gates in `delta.py`:
395 - Regrouping punctuation is semantic even with identical token texts
396 (`a + +b` vs `a ++ b` in JS, `a ** b` vs `a * * b` in Python), and so is
397 gluing a word to a string (`f "x"` vs f-string `f"x"`) — a glue change is
398 only trusted when exactly one side of the touching pair is punctuation.
399 In JS/TS, `/`, `<`, and `>` never qualify at all: spacing decides whether
400 they read as regex-vs-division or generic-vs-comparison.
401 - The tokenizer skips comments, so the text after the last token (a trailing
402 line comment) must match exactly and interior gaps must be pure whitespace;
403 otherwise a comment edit would read as spacing. Comment-only changes stay
404 `_comments`' job (this rule declines and falls through).
405 """
406 ext = extension(file)
407 if ext not in _CORE_CONTENT_LANGUAGES:
408 return None
409 pairs = paired_changed_lines(hunk)
410 if pairs is None:
411 return None
412 dialect = "py" if ext == "py" else "ts"
413 return (
414 Trust.SPACING
415 if paired_token_delta(
416 pairs,
417 dialect,
418 lambda old, new, o, n: _spacing_delta(old, new, o, n, dialect),
419 )
420 else None
421 )