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

1"""String-literal-aware scanning of a single source line. 

2 

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. 

7 

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""" 

11 

12from __future__ import annotations 

13 

14 

15def collapse_ws(text: str) -> str: 

16 """Collapse all whitespace runs to single spaces and trim.""" 

17 return " ".join(text.split()) 

18 

19 

20def collapse_ws_outside_strings(text: str, quotes: str = "\"'`") -> str: 

21 """Collapse whitespace runs, but leave the contents of string literals intact. 

22 

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() 

47 

48 

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 

75 

76 

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