153 lines
5.6 KiB
Python
Executable File
153 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Audit Rust match arms for missing `*` dereferences.
|
|
|
|
When a function takes `&Cmd` and matches on it, every captured field is
|
|
a reference. Forwarding that reference to a context expecting a value
|
|
(without `*`) is a compile error. This script finds such cases by:
|
|
|
|
1. Finding functions whose signature takes `&EnumType` parameters.
|
|
2. Finding `match <param> { Enum::Variant(capture) => body }` blocks.
|
|
3. Checking if `capture` appears in `body` without a leading `*`
|
|
in a value-expecting position (function arg, struct field, etc.).
|
|
|
|
Conservative: flags potential issues for manual review.
|
|
"""
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def find_ref_params(text: str) -> dict[str, str]:
|
|
"""Find function params of form `name: &EnumType`. Returns {param: type}."""
|
|
params = {}
|
|
# fn foo(... name: &SomeType, ...)
|
|
for m in re.finditer(r'(\w+)\s*:\s*&(\w+)', text):
|
|
param_name = m.group(1)
|
|
type_name = m.group(2)
|
|
# Heuristic: only flag types that start with uppercase (enums/structs)
|
|
if type_name[0].isupper():
|
|
params[param_name] = type_name
|
|
return params
|
|
|
|
|
|
def find_match_on_param(text: str, param: str) -> list[tuple[int, str, list[str]]]:
|
|
"""Find `match <param> { ... }` blocks and extract arm captures.
|
|
|
|
Returns list of (line, variant, [captures]).
|
|
"""
|
|
results = []
|
|
# Find `match <param> {` — then scan arms until matching `}`
|
|
for m in re.finditer(rf'match\s+{re.escape(param)}\s*\{{', text):
|
|
block_start = m.end()
|
|
# Find matching close brace (naive depth counting)
|
|
depth = 1
|
|
i = block_start
|
|
while i < len(text) and depth > 0:
|
|
if text[i] == '{':
|
|
depth += 1
|
|
elif text[i] == '}':
|
|
depth -= 1
|
|
i += 1
|
|
block_end = i
|
|
block = text[block_start:block_end]
|
|
|
|
# Find arm patterns: Enum::Variant(captures) =>
|
|
for arm in re.finditer(
|
|
r'(\w+)::(\w+)\s*\(([^)]*)\)\s*=>',
|
|
block,
|
|
):
|
|
arm_line = text[:block_start + arm.start()].count('\n') + 1
|
|
variant = f"{arm.group(1)}::{arm.group(2)}"
|
|
captures_raw = arm.group(3).strip()
|
|
if not captures_raw:
|
|
continue
|
|
captures = []
|
|
for c in captures_raw.split(','):
|
|
c = c.strip()
|
|
# Strip type annotations: `deg: u16` -> `deg`
|
|
if ':' in c:
|
|
c = c.split(':')[0].strip()
|
|
if c and (c[0].isalpha() or c[0] == '_'):
|
|
captures.append(c)
|
|
if captures:
|
|
results.append((arm_line, variant, captures))
|
|
return results
|
|
|
|
|
|
def check_deref_in_arm(text: str, arm_line: int, ident: str) -> bool:
|
|
"""Check if `ident` is used without `*` deref in the arm body (next ~15 lines).
|
|
|
|
Returns True if a potential issue is found.
|
|
"""
|
|
lines = text.split('\n')
|
|
# Scan from arm_line+1 for up to 15 lines
|
|
for offset in range(1, 16):
|
|
idx = arm_line - 1 + offset # 0-indexed
|
|
if idx >= len(lines):
|
|
break
|
|
line = lines[idx]
|
|
# Stop at next arm (line containing `=>` at start, or `}` ending block)
|
|
stripped = line.strip()
|
|
if stripped.startswith('}') or stripped.startswith('_') and '=>' in stripped:
|
|
break
|
|
# Find usages of `ident` not preceded by `*` or `&` or `.`
|
|
for m in re.finditer(rf'(?<![*.&>])\b{re.escape(ident)}\b', line):
|
|
# Skip if followed by `.` (method/field access — fine on references)
|
|
after_idx = m.end()
|
|
if after_idx < len(line) and line[after_idx] == '.':
|
|
continue
|
|
# Skip if it's in a closure binding: |ident|
|
|
before = line[:m.start()]
|
|
if before.endswith('|'):
|
|
continue
|
|
# Skip if it's a pattern match itself: Some(ident)
|
|
# (preceded by `(` or `,` and followed by `)` or `,`)
|
|
if before.endswith('(') or before.endswith(','):
|
|
after = line[after_idx:] if after_idx < len(line) else ''
|
|
if after.startswith(')') or after.startswith(','):
|
|
continue
|
|
# Skip if explicitly dereferenced with `*`
|
|
if before.endswith('*'):
|
|
continue
|
|
# Found a usage that might need `*`
|
|
return True
|
|
return False
|
|
|
|
|
|
def audit_file(path: Path) -> list[str]:
|
|
text = path.read_text()
|
|
issues = []
|
|
ref_params = find_ref_params(text)
|
|
for param, type_name in ref_params.items():
|
|
arms = find_match_on_param(text, param)
|
|
for arm_line, variant, captures in arms:
|
|
for ident in captures:
|
|
if ident.startswith('_'):
|
|
continue
|
|
if check_deref_in_arm(text, arm_line, ident):
|
|
issues.append(
|
|
f" {path}:{arm_line}: `{ident}` captured from "
|
|
f"`{variant}` (matching `&{type_name} {param}`) — "
|
|
f"verify `*{ident}` is used where a value is expected"
|
|
)
|
|
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_file(f)
|
|
if issues:
|
|
print(f"\n{f}:")
|
|
for iss in issues:
|
|
print(iss)
|
|
total_issues += 1
|
|
print(f"\n{total_issues} potential issue(s) across {len(rs_files)} files")
|
|
print("(Each flag requires manual review — this is a heuristic.)")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|