Coverage for src/pullapprove/trust/linescan.py: 100%
41 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-28 14:54 -0500
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-28 14:54 -0500
1"""String-literal-aware scanning of a single source line.
3Helpers that normalize a line's whitespace for comparison (`collapse_ws`,
4`collapse_ws_outside_strings`) or skip past a string literal (`consume_string`)
5without being fooled by a quote's contents. They live apart from the rules so
6each rule module stays readable rather than mixing rules with char-scanners.
8Conservative by design: `collapse_ws_outside_strings` leaves string interiors
9intact, so a real edit inside a literal can't masquerade as a harmless reflow.
10"""
12from __future__ import annotations
15def collapse_ws(text: str) -> str:
16 """Collapse all whitespace runs to single spaces and trim."""
17 return " ".join(text.split())
20def collapse_ws_outside_strings(text: str, quotes: str = "\"'`") -> str:
21 """Collapse whitespace runs, but leave the contents of string literals intact.
23 Used by wrap detection: re-wrapping code only moves whitespace *between*
24 tokens, never inside a string. Collapsing string interiors too would let a
25 real edit like `"a b"` -> `"a b"` look like a harmless reflow.
26 """
27 out: list[str] = []
28 i, n = 0, len(text)
29 pending_space = False
30 while i < n:
31 ch = text[i]
32 if ch in quotes:
33 close = consume_string(text, i)
34 out.append(text[i:close])
35 i = close
36 pending_space = False
37 elif ch.isspace():
38 if out and not pending_space:
39 out.append(" ")
40 pending_space = True
41 i += 1
42 else:
43 out.append(ch)
44 pending_space = False
45 i += 1
46 return "".join(out).rstrip()
49def scan_string(text: str, i: int) -> int | None:
50 """Index just past the string opened at `text[i]`, or None if it is never
51 closed. Escapes are skipped forward (`\\` consumes the next char), so an
52 escaped quote or backslash right before the close (`"a\\\\"`) can't end the
53 string early. This is the single place the escape rule lives — both
54 `consume_string` (below) and the tokenizer build on it."""
55 quote = text[i]
56 # A triple-quoted string (`'''…'''` / `\"\"\"…\"\"\"`) is one delimiter, not three:
57 # pairwise scanning would close on the first apostrophe inside
58 # (`'''don't'''`) and desync everything after it — a `#` later in the
59 # literal would read as a comment. An unclosed triple continues on another
60 # line, which a single-line scanner can't judge, so it stays None (callers
61 # decline).
62 if quote in "\"'" and text[i : i + 3] == quote * 3:
63 close = text.find(quote * 3, i + 3)
64 return None if close == -1 else close + 3
65 i += 1
66 n = len(text)
67 while i < n:
68 if text[i] == "\\":
69 i += 2 # skip an escaped char (e.g. \\ or \")
70 continue
71 if text[i] == quote:
72 return i + 1
73 i += 1
74 return None
77def consume_string(text: str, i: int) -> int:
78 """Given `i` at an opening quote, return the index just past its close, or the
79 end of the text if the string is unterminated — the form scanners want when
80 they treat an unterminated literal as running to end-of-line (vs the
81 tokenizer, which uses `scan_string` directly to bail on a `None`)."""
82 close = scan_string(text, i)
83 return len(text) if close is None else close