#!/usr/bin/env python3 # file: scripts/audit_rust_item_spacing.py # version: 1 """Audit exact spacing between mechanically detectable Rust items.""" from __future__ import annotations import argparse import dataclasses import pathlib import re import sys from _rust_audit_common import line_depths, mask_source, selected_files @dataclasses.dataclass(frozen=True) class RustItem: """One mechanically detected Rust item and its source span.""" kind: str visibility: str start: int declaration: int end: int depth: int braced: bool def _visibility(stripped: str) -> str: if stripped.startswith("pub(crate) "): return "pub(crate)" if stripped.startswith("pub "): return "pub" return "private" def _kind(stripped: str) -> str | None: prefix = r"(?:(?:pub|pub\(crate\))\s+)?" qualifiers = r"(?:(?:async|const|unsafe)\s+)*" if re.match(rf"^{prefix}{qualifiers}fn\b", stripped): return "fn" for kind in ("struct", "enum", "union", "trait", "impl", "const", "static", "type"): if re.match(rf"^{prefix}{qualifiers}{kind}\b", stripped): return kind return None def _leading_line(lines: list[str], declaration: int) -> int: cursor = declaration - 2 while cursor >= 0: stripped = lines[cursor].strip() if stripped.startswith("///") or stripped.startswith("#["): cursor -= 1 continue break return cursor + 2 def _end_line(masked_lines: list[str], depths: list[int], declaration: int, kind: str) -> tuple[int, bool]: start_depth = depths[declaration - 1] if kind in {"const", "static", "type"}: brackets: list[str] = [] closing = {"}": "{", ")": "(", "]": "["} for line_index in range(declaration - 1, len(masked_lines)): for char in masked_lines[line_index]: if char in "{([": brackets.append(char) elif char in "})]" and brackets and brackets[-1] == closing[char]: brackets.pop() elif char == ";" and not brackets: return line_index + 1, False return declaration, False body_started = False for line_index in range(declaration - 1, len(masked_lines)): masked_line = masked_lines[line_index] if not body_started and ";" in masked_line and "{" not in masked_line: return line_index + 1, False if "{" in masked_line: body_started = True if body_started: end_depth = depths[line_index] + masked_line.count("{") - masked_line.count("}") if end_depth == start_depth: return line_index + 1, True return declaration, body_started def _items(lines: list[str], masked_lines: list[str], depths: list[int]) -> list[RustItem]: result: list[RustItem] = [] for number, line in enumerate(lines, 1): stripped = line.strip() kind = _kind(stripped) if kind is None: continue end, braced = _end_line(masked_lines, depths, number, kind) result.append(RustItem(kind, _visibility(stripped), _leading_line(lines, number), number, end, depths[number - 1], braced)) return result def _parent(item: RustItem, items: list[RustItem]) -> RustItem | None: parents = [candidate for candidate in items if candidate.braced and candidate.declaration < item.declaration <= candidate.end and candidate.depth < item.depth] if not parents: return None return max(parents, key=lambda candidate: candidate.depth) def audit_file(path: pathlib.Path) -> list[str]: """Require exact separation between adjacent items in one Rust scope.""" text = path.read_text(encoding="utf-8") lines = text.splitlines() masked, _ = mask_source(text) masked_lines = masked.splitlines() depths = line_depths(masked) items = _items(lines, masked_lines, depths) siblings: dict[tuple[int, int] | None, list[RustItem]] = {} for item in items: parent = _parent(item, items) key = None if parent is None else (parent.declaration, parent.end) siblings.setdefault(key, []).append(item) errors: list[str] = [] for group in siblings.values(): group.sort(key=lambda item: item.declaration) for previous, current in zip(group, group[1:]): between = lines[previous.end:current.start - 1] # Unknown syntax or a macro between items owns its own layout; do not infer across it. if any(candidate.strip() for candidate in between): continue blank_count = sum(1 for candidate in between if not candidate.strip()) compact = previous.kind == current.kind and previous.kind in {"const", "static", "type"} and previous.visibility == current.visibility if compact: # Compact declaration blocks have a dedicated owner when a project selects one. continue if blank_count != 1: errors.append(f"{path}:{current.start}: expected exactly one blank line between Rust items; found {blank_count}") return errors def main() -> int: """Run the read-only item-spacing audit on explicitly selected Rust files.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("paths", nargs="+", help="Rust files or directories to inspect") args = parser.parse_args() files, errors = selected_files(args.paths) if errors or not files: for error in errors or ["no Rust files selected"]: print(f"Rust item-spacing audit: {error}", file=sys.stderr) return 2 for path in files: try: errors.extend(audit_file(path)) except (OSError, UnicodeError) as error: print(f"Rust item-spacing audit: cannot read {path}: {error}", file=sys.stderr) return 2 for error in errors: print(error, file=sys.stderr) if errors: print(f"Rust item-spacing audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) return 1 print(f"Rust item-spacing audit: clean ({len(files)} file(s))") return 0 if __name__ == "__main__": raise SystemExit(main())