ferret/scripts/audit_brackets.py

152 lines
4.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Audit Rust bracket balance with a proper tokenizer.
Handles:
- Line comments (//...)
- Block comments (/* ... */)
- String literals ("..." with escapes)
- Raw string literals (r"...", r#"..."#)
- Char literals ('...' with escapes)
- Lifetime parameters ('ident)
Reports any file where (), {}, or [] are unbalanced.
"""
import sys
from pathlib import Path
def audit(path: Path) -> list[str]:
text = path.read_text()
issues = []
stack: list[tuple[str, int, int]] = [] # (char, line, col)
line, col = 1, 0
i = 0
n = len(text)
openers = {'(': ')', '{': '}', '[': ']'}
closers = set(openers.values())
while i < n:
c = text[i]
if c == '\n':
line += 1
col = 0
i += 1
continue
col += 1
# Line comment
if c == '/' and i + 1 < n and text[i + 1] == '/':
while i < n and text[i] != '\n':
i += 1
continue
# Block comment
if c == '/' and i + 1 < n and text[i + 1] == '*':
i += 2
while i + 1 < n and not (text[i] == '*' and text[i + 1] == '/'):
if text[i] == '\n':
line += 1
col = 0
else:
col += 1
i += 1
i += 2
continue
# Raw string r"..." or r#"..."#
if c == 'r' and i + 1 < n and text[i + 1] == '"':
j = i + 2
hashes = 0
while j < n and text[j] == '#':
hashes += 1
j += 1
if j < n and text[j] == '"':
close = '"' + '#' * hashes
start = j + 1
end = text.find(close, start)
if end == -1:
issues.append(f"{path}:{line}: unterminated raw string")
return issues
for ch in text[i:end + len(close)]:
if ch == '\n':
line += 1
col = 0
else:
col += 1
i = end + len(close)
continue
# Regular string
if c == '"':
j = i + 1
while j < n:
if text[j] == '\\' and j + 1 < n:
j += 2
continue
if text[j] == '"':
break
j += 1
if j >= n:
issues.append(f"{path}:{line}: unterminated string")
return issues
i = j + 1
continue
# Char literal or lifetime
if c == "'":
if i + 1 < n and (text[i + 1].isalpha() or text[i + 1] == '_'):
j = i + 1
while j < n and (text[j].isalnum() or text[j] == '_'):
j += 1
if j < n and text[j] != "'":
i = j
continue
j = i + 1
while j < n:
if text[j] == '\\' and j + 1 < n:
j += 2
continue
if text[j] == "'":
break
j += 1
if j >= n:
i += 1
continue
i = j + 1
continue
if c in openers:
stack.append((c, line, col))
i += 1
continue
if c in closers:
if not stack:
issues.append(f"{path}:{line}:{col}: unexpected '{c}'")
i += 1
continue
top, tline, tcol = stack.pop()
if openers[top] != c:
issues.append(
f"{path}:{line}:{col}: '{c}' does not match '{top}' "
f"opened at {tline}:{tcol}"
)
i += 1
continue
i += 1
for top, tline, tcol in stack:
issues.append(f"{path}: unclosed '{top}' opened at {tline}:{tcol}")
return issues
def main():
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('.')
rs_files = sorted(root.rglob('*.rs'))
total_issues = 0
for f in rs_files:
issues = audit(f)
for iss in issues:
print(iss)
total_issues += 1
print(f"\n{total_issues} issue(s) across {len(rs_files)} files")
if __name__ == '__main__':
main()