588 lines
26 KiB
Python
Executable File
588 lines
26 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# file: scripts/audit_rust_general_rules.py
|
|
# version: 1
|
|
|
|
"""Audit mechanically verifiable Rust normalization rules used by games.sasedev."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Violation:
|
|
"""One mechanically detected general Rust rule violation."""
|
|
|
|
code: str
|
|
path: str
|
|
line: int
|
|
message: str
|
|
|
|
|
|
def rust_files(root: pathlib.Path) -> list[pathlib.Path]:
|
|
"""Return tracked-style Rust sources outside generated directories."""
|
|
|
|
crates = root / "crates"
|
|
return sorted(path for path in crates.rglob("*.rs") if "target" not in path.parts and ".git" not in path.parts)
|
|
|
|
|
|
def mask_rust_source(text: str) -> str:
|
|
"""Mask comments and literals while preserving braces, newlines and offsets."""
|
|
|
|
output = list(text)
|
|
index = 0
|
|
state = "code"
|
|
block_depth = 0
|
|
raw_hashes = 0
|
|
while index < len(text):
|
|
char = text[index]
|
|
nxt = text[index + 1] if index + 1 < len(text) else ""
|
|
if state == "code":
|
|
if char == "/" and nxt == "/":
|
|
output[index] = " "
|
|
output[index + 1] = " "
|
|
index += 2
|
|
state = "line_comment"
|
|
continue
|
|
if char == "/" and nxt == "*":
|
|
output[index] = " "
|
|
output[index + 1] = " "
|
|
index += 2
|
|
state = "block_comment"
|
|
block_depth = 1
|
|
continue
|
|
if char == '"':
|
|
output[index] = " "
|
|
index += 1
|
|
state = "string"
|
|
continue
|
|
if char == "'":
|
|
# Treat a quote as a char literal only when it can close shortly;
|
|
# lifetimes such as `'a` remain code.
|
|
probe = index + 1
|
|
escaped = False
|
|
found = False
|
|
while probe < min(len(text), index + 8) and text[probe] != "\n":
|
|
if not escaped and text[probe] == "'":
|
|
found = True
|
|
break
|
|
if not escaped and text[probe] == "\\":
|
|
escaped = True
|
|
else:
|
|
escaped = False
|
|
probe += 1
|
|
if found:
|
|
output[index] = " "
|
|
index += 1
|
|
state = "char"
|
|
continue
|
|
if char == "r":
|
|
probe = index + 1
|
|
hashes = 0
|
|
while probe < len(text) and text[probe] == "#":
|
|
hashes += 1
|
|
probe += 1
|
|
if probe < len(text) and text[probe] == '"':
|
|
for masked in range(index, probe + 1):
|
|
output[masked] = " "
|
|
index = probe + 1
|
|
raw_hashes = hashes
|
|
state = "raw_string"
|
|
continue
|
|
index += 1
|
|
continue
|
|
if state == "line_comment":
|
|
if char == "\n":
|
|
state = "code"
|
|
else:
|
|
output[index] = " "
|
|
index += 1
|
|
continue
|
|
if state == "block_comment":
|
|
if char == "/" and nxt == "*":
|
|
output[index] = " "
|
|
output[index + 1] = " "
|
|
block_depth += 1
|
|
index += 2
|
|
continue
|
|
if char == "*" and nxt == "/":
|
|
output[index] = " "
|
|
output[index + 1] = " "
|
|
block_depth -= 1
|
|
index += 2
|
|
if block_depth == 0:
|
|
state = "code"
|
|
continue
|
|
if char != "\n":
|
|
output[index] = " "
|
|
index += 1
|
|
continue
|
|
if state == "string":
|
|
if char == "\\" and index + 1 < len(text):
|
|
output[index] = " "
|
|
if text[index + 1] != "\n":
|
|
output[index + 1] = " "
|
|
index += 2
|
|
continue
|
|
if char == '"':
|
|
output[index] = " "
|
|
state = "code"
|
|
elif char != "\n":
|
|
output[index] = " "
|
|
index += 1
|
|
continue
|
|
if state == "char":
|
|
if char == "\\" and index + 1 < len(text):
|
|
output[index] = " "
|
|
if text[index + 1] != "\n":
|
|
output[index + 1] = " "
|
|
index += 2
|
|
continue
|
|
if char == "'":
|
|
output[index] = " "
|
|
state = "code"
|
|
elif char != "\n":
|
|
output[index] = " "
|
|
index += 1
|
|
continue
|
|
if state == "raw_string":
|
|
if char == '"' and text.startswith("#" * raw_hashes, index + 1):
|
|
output[index] = " "
|
|
for masked in range(index + 1, index + 1 + raw_hashes):
|
|
output[masked] = " "
|
|
index += 1 + raw_hashes
|
|
state = "code"
|
|
continue
|
|
if char != "\n":
|
|
output[index] = " "
|
|
index += 1
|
|
continue
|
|
return "".join(output)
|
|
|
|
|
|
def line_depths(masked: str) -> list[int]:
|
|
"""Return brace depth at the start of every line."""
|
|
|
|
depths: list[int] = []
|
|
depth = 0
|
|
for line in masked.splitlines():
|
|
depths.append(depth)
|
|
depth += line.count("{") - line.count("}")
|
|
if depth < 0:
|
|
depth = 0
|
|
return depths
|
|
|
|
|
|
def preceding_doc_line(lines: list[str], index: int) -> bool:
|
|
"""Return whether one declaration has adjacent useful line rustdoc."""
|
|
|
|
cursor = index - 2
|
|
while cursor >= 0 and lines[cursor].strip().startswith("#["):
|
|
cursor -= 1
|
|
return cursor >= 0 and lines[cursor].lstrip().startswith("///")
|
|
|
|
|
|
def natural_key(value: str) -> tuple[tuple[int, object], ...]:
|
|
"""Return a natural case-sensitive ordering key."""
|
|
|
|
tokens = re.findall(r"[A-Za-z_]+|[0-9]+", value)
|
|
return tuple((1, int(token)) if token.isdigit() else (0, token) for token in tokens)
|
|
|
|
|
|
def public_declaration(stripped: str) -> re.Match[str] | None:
|
|
"""Match a public or crate-public item or associated item declaration."""
|
|
|
|
return re.match(
|
|
r"^pub(?:\(crate\))?\s+(?:(?:async|const|unsafe)\s+)*(?:const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)",
|
|
stripped,
|
|
)
|
|
|
|
|
|
def public_field(stripped: str) -> re.Match[str] | None:
|
|
"""Match a public or crate-public named field declaration."""
|
|
|
|
return re.match(r"^pub(?:\(crate\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*:", stripped)
|
|
|
|
|
|
def declaration_start(stripped: str, kind: str) -> bool:
|
|
"""Return whether a line starts a declaration of one requested kind."""
|
|
|
|
prefixes = r"(?:(?:pub|pub\(crate\))\s+)?(?:(?:async|const|unsafe)\s+)*"
|
|
return re.match(rf"^{prefixes}{kind}\b", stripped) is not None
|
|
|
|
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RustItem:
|
|
"""One mechanically detected Rust item used for spacing checks."""
|
|
|
|
kind: str
|
|
visibility: str
|
|
start: int
|
|
declaration: int
|
|
end: int
|
|
depth: int
|
|
braced: bool
|
|
|
|
|
|
def item_visibility(stripped: str) -> str:
|
|
"""Return normalized visibility for one item declaration."""
|
|
|
|
if stripped.startswith("pub(crate) "):
|
|
return "pub(crate)"
|
|
if stripped.startswith("pub "):
|
|
return "pub"
|
|
return "private"
|
|
|
|
|
|
def item_kind(stripped: str) -> str | None:
|
|
"""Return the normalized declaration kind for spacing checks."""
|
|
|
|
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 item_leading_line(lines: list[str], declaration_index: int) -> int:
|
|
"""Return the first rustdoc/attribute line attached to an item."""
|
|
|
|
cursor = declaration_index - 2
|
|
while cursor >= 0:
|
|
stripped = lines[cursor].strip()
|
|
if stripped.startswith("///") or stripped.startswith("#["):
|
|
cursor -= 1
|
|
continue
|
|
break
|
|
return cursor + 2
|
|
|
|
|
|
def item_end_line(masked_lines: list[str], depths: list[int], declaration_index: int, kind: str) -> tuple[int, bool]:
|
|
"""Return the end line and whether the item owns a braced body."""
|
|
|
|
start_depth = depths[declaration_index - 1]
|
|
if kind in {"const", "static", "type"}:
|
|
for line_index in range(declaration_index - 1, len(masked_lines)):
|
|
if ";" in masked_lines[line_index]:
|
|
return line_index + 1, False
|
|
return declaration_index, False
|
|
body_started = False
|
|
for line_index in range(declaration_index - 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_index, body_started
|
|
|
|
|
|
def rust_items(lines: list[str], masked_lines: list[str], depths: list[int]) -> list[RustItem]:
|
|
"""Return mechanically detected Rust items with source spans."""
|
|
|
|
result: list[RustItem] = []
|
|
for idx, line in enumerate(lines, 1):
|
|
stripped = line.strip()
|
|
kind = item_kind(stripped)
|
|
if kind is None:
|
|
continue
|
|
end, braced = item_end_line(masked_lines, depths, idx, kind)
|
|
result.append(RustItem(kind, item_visibility(stripped), item_leading_line(lines, idx), idx, end, depths[idx - 1], braced))
|
|
return result
|
|
|
|
|
|
def item_parent(item: RustItem, items: list[RustItem]) -> RustItem | None:
|
|
"""Return the smallest braced item that contains another item."""
|
|
|
|
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_item_spacing_and_nesting(relative: str, lines: list[str], masked_lines: list[str], depths: list[int]) -> list[Violation]:
|
|
"""Enforce exact item separation and reject declarations nested in functions."""
|
|
|
|
violations: list[Violation] = []
|
|
items = rust_items(lines, masked_lines, depths)
|
|
for item in items:
|
|
parent = item_parent(item, items)
|
|
if parent is not None and parent.kind == "fn":
|
|
body_depth = parent.depth + 1
|
|
preceding = lines[parent.declaration:item.declaration - 1]
|
|
preceding_depths = depths[parent.declaration:item.declaration - 1]
|
|
returned = any(depth == body_depth and re.match(r"^\s*return\b.*;\s*$", line) is not None for line, depth in zip(preceding, preceding_depths, strict=True))
|
|
if returned:
|
|
violations.append(Violation("RUST-FMT-110", relative, item.declaration, f"item `{item.kind}` follows an unconditional function-level return; probable misplaced closing brace"))
|
|
children: dict[tuple[int, int] | None, list[RustItem]] = {}
|
|
for item in items:
|
|
parent = item_parent(item, items)
|
|
key = None if parent is None else (parent.declaration, parent.end)
|
|
children.setdefault(key, []).append(item)
|
|
for siblings in children.values():
|
|
siblings.sort(key=lambda item: item.declaration)
|
|
for previous, current in zip(siblings, siblings[1:]):
|
|
# Do not infer spacing across unparsed syntax such as macro invocations.
|
|
between = lines[previous.end:current.start - 1]
|
|
if any(candidate.strip() for candidate in between):
|
|
continue
|
|
blank_count = sum(1 for candidate in between if not candidate.strip())
|
|
same_homogeneous_block = previous.kind == current.kind and previous.kind in {"const", "static", "type"} and previous.visibility == current.visibility
|
|
expected = 0 if same_homogeneous_block else 1
|
|
if blank_count != expected:
|
|
rule = "RUST-FMT-111" if expected == 1 else "RUST-FMT-112"
|
|
expectation = "exactly one blank line between Rust items" if expected == 1 else "no blank line inside one homogeneous declaration block"
|
|
violations.append(Violation(rule, relative, current.start, f"{expectation}; found {blank_count}"))
|
|
return violations
|
|
|
|
|
|
def audit_blank_lines_in_bodies(relative: str, lines: list[str], masked_lines: list[str], depths: list[int]) -> list[Violation]:
|
|
"""Reject blank lines inside functions/methods and struct/enum bodies."""
|
|
|
|
violations: list[Violation] = []
|
|
pending_kind: str | None = None
|
|
pending_depth = 0
|
|
body_kind: str | None = None
|
|
body_depth = 0
|
|
for idx, (line, masked_line) in enumerate(zip(lines, masked_lines, strict=True), 1):
|
|
stripped = line.strip()
|
|
if body_kind is not None:
|
|
if not stripped and depths[idx - 1] >= body_depth:
|
|
violations.append(Violation("RUST-FMT-101", relative, idx, f"blank line inside {body_kind} body"))
|
|
if depths[idx - 1] < body_depth:
|
|
body_kind = None
|
|
if body_kind is None and pending_kind is None:
|
|
if declaration_start(stripped, "fn"):
|
|
pending_kind = "function/method"
|
|
pending_depth = depths[idx - 1]
|
|
elif declaration_start(stripped, "struct"):
|
|
pending_kind = "struct"
|
|
pending_depth = depths[idx - 1]
|
|
elif declaration_start(stripped, "enum"):
|
|
pending_kind = "enum"
|
|
pending_depth = depths[idx - 1]
|
|
if pending_kind is not None and "{" in masked_line:
|
|
body_kind = pending_kind
|
|
body_depth = pending_depth + 1
|
|
pending_kind = None
|
|
elif pending_kind is not None and ";" in masked_line:
|
|
pending_kind = None
|
|
return violations
|
|
|
|
|
|
def audit_top_level_const_blocks(relative: str, lines: list[str], depths: list[int]) -> list[Violation]:
|
|
"""Audit visibility/order/spacing for mechanically homogeneous const blocks."""
|
|
|
|
violations: list[Violation] = []
|
|
const_pattern = re.compile(r"^(pub\s+|pub\(crate\)\s+)?const\s+([A-Za-z_][A-Za-z0-9_]*)")
|
|
previous: tuple[int, int, str] | None = None
|
|
# rank: public, crate-public, private
|
|
for idx, line in enumerate(lines, 1):
|
|
if depths[idx - 1] != 0:
|
|
continue
|
|
match = const_pattern.match(line.strip())
|
|
if match is None:
|
|
# Docs and attributes belong to the surrounding block and do not break it.
|
|
if line.strip() and not line.lstrip().startswith(("///", "#[")):
|
|
previous = None
|
|
continue
|
|
visibility = match.group(1) or ""
|
|
rank = 0 if visibility == "pub " else 1 if visibility == "pub(crate) " else 2
|
|
name = match.group(2)
|
|
if previous is not None:
|
|
previous_line, previous_rank, previous_name = previous
|
|
if rank < previous_rank:
|
|
violations.append(Violation("RUST-FMT-103", relative, idx, "const visibility order must be pub, pub(crate), then private"))
|
|
if rank == previous_rank and natural_key(name) < natural_key(previous_name):
|
|
violations.append(Violation("RUST-FMT-104", relative, idx, "const block is not alphabetically ordered"))
|
|
previous = (idx, rank, name)
|
|
return violations
|
|
|
|
|
|
|
|
|
|
def audit_module_order(relative: str, lines: list[str], depths: list[int]) -> list[Violation]:
|
|
"""Audit top-level module declaration ordering with test modules last."""
|
|
|
|
violations: list[Violation] = []
|
|
modules: list[tuple[int, bool, str]] = []
|
|
for idx, line in enumerate(lines, 1):
|
|
if depths[idx - 1] != 0:
|
|
continue
|
|
match = re.match(r"^mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:;|\{)", line.strip())
|
|
if match is None:
|
|
continue
|
|
cursor = idx - 2
|
|
is_test = False
|
|
while cursor >= 0 and lines[cursor].strip().startswith("#["):
|
|
if "cfg(test)" in lines[cursor].replace(" ", ""):
|
|
is_test = True
|
|
cursor -= 1
|
|
modules.append((idx, is_test, match.group(1)))
|
|
production = [(idx, name) for idx, is_test, name in modules if not is_test]
|
|
tests = [(idx, name) for idx, is_test, name in modules if is_test]
|
|
if tests and production and min(idx for idx, _ in tests) < max(idx for idx, _ in production):
|
|
first_test = min(idx for idx, _ in tests)
|
|
violations.append(Violation("RUST-FMT-108", relative, first_test, "test modules must follow production modules"))
|
|
for group, label in ((production, "production"), (tests, "test")):
|
|
names = [name for _, name in group]
|
|
if names != sorted(names, key=natural_key):
|
|
for idx, name in group:
|
|
expected = sorted(names, key=natural_key)
|
|
if names.index(name) != expected.index(name):
|
|
violations.append(Violation("RUST-FMT-109", relative, idx, f"{label} module declarations are not alphabetically ordered"))
|
|
break
|
|
return violations
|
|
|
|
def audit_file(root: pathlib.Path, path: pathlib.Path) -> list[Violation]:
|
|
"""Audit one Rust source against general normalization rules."""
|
|
|
|
relative = path.relative_to(root).as_posix()
|
|
text = path.read_text(encoding="utf-8")
|
|
lines = text.splitlines()
|
|
masked = mask_rust_source(text)
|
|
masked_lines = masked.splitlines()
|
|
depths = line_depths(masked)
|
|
violations: list[Violation] = []
|
|
expected_header = f"// file: {relative}"
|
|
if not lines or lines[0] != expected_header:
|
|
violations.append(Violation("RUST-BASE-101", relative, 1, f"expected `{expected_header}`"))
|
|
if len(lines) < 2 or re.fullmatch(r"// version: [1-9][0-9]*", lines[1]) is None:
|
|
violations.append(Violation("RUST-BASE-102", relative, 2, "missing positive file version"))
|
|
if not text.endswith("\n") or text.endswith("\n\n"):
|
|
violations.append(Violation("RUST-FMT-100", relative, max(len(lines), 1), "file must end with exactly one newline"))
|
|
local_modules = {match.group(1) for line in lines if (match := re.fullmatch(r"\s*mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;", line)) is not None}
|
|
seen_declaration_before_use = False
|
|
use_rows: list[tuple[int, str]] = []
|
|
for idx, line in enumerate(lines, 1):
|
|
stripped = line.strip()
|
|
depth = depths[idx - 1] if idx - 1 < len(depths) else 0
|
|
if re.match(r"^(?:pub\s+)?extern\s+crate\s+[A-Za-z_][A-Za-z0-9_]*\s+as\s+", stripped):
|
|
violations.append(Violation("RUST-IMPORT-104", relative, idx, "extern crate aliases are forbidden"))
|
|
if "pub(in " in stripped or "pub(super)" in stripped:
|
|
violations.append(Violation("RUST-API-102", relative, idx, "restricted visibility is forbidden; use private or pub(crate) with crate-root re-export"))
|
|
if stripped.startswith("pub mod "):
|
|
violations.append(Violation("RUST-API-101", relative, idx, "modules must remain private and APIs use explicit re-exports"))
|
|
if depth == 0 and re.match(r"^(?:pub(?:\(crate\))?\s+)?(?:const|static|type|struct|enum|trait|union|fn|impl)\b", stripped):
|
|
seen_declaration_before_use = True
|
|
use_match = re.match(r"^(pub(?:\(crate\))?\s+)?use\s+", stripped)
|
|
if use_match is not None:
|
|
is_export = stripped.startswith("pub use ") or stripped.startswith("pub(crate) use ")
|
|
if depth != 0:
|
|
violations.append(Violation("RUST-IMPORT-111", relative, idx, "use declarations must be at module scope, never inside a function/method/block"))
|
|
if not is_export:
|
|
use_rows.append((idx, stripped))
|
|
if seen_declaration_before_use:
|
|
violations.append(Violation("RUST-IMPORT-112", relative, idx, "trait imports must remain at the beginning of the module before declarations"))
|
|
if "rust-rules: trait-import" not in stripped:
|
|
violations.append(Violation("RUST-IMPORT-101", relative, idx, "ordinary use requires an explicit trait import justification"))
|
|
if "::*" in stripped:
|
|
violations.append(Violation("RUST-IMPORT-102", relative, idx, "glob imports are forbidden"))
|
|
if "{" in stripped or "}" in stripped:
|
|
violations.append(Violation("RUST-IMPORT-103", relative, idx, "grouped use/re-export declarations are forbidden"))
|
|
if re.search(r"\s+as\s+[A-Za-z_][A-Za-z0-9_]*", stripped):
|
|
violations.append(Violation("RUST-IMPORT-104", relative, idx, "use/re-export aliases are forbidden"))
|
|
if is_export and re.match(r"^pub(?:\(crate\))?\s+use\s+crate::", stripped):
|
|
violations.append(Violation("RUST-IMPORT-105", relative, idx, "internal re-export must start with self::"))
|
|
declaration = public_declaration(stripped)
|
|
field = public_field(stripped)
|
|
if declaration is not None or field is not None:
|
|
if not preceding_doc_line(lines, idx):
|
|
name = (declaration or field).group(1)
|
|
violations.append(Violation("RUST-DOC-101", relative, idx, f"public/crate-public `{name}` requires adjacent rustdoc"))
|
|
# The ordinary module import block is contiguous and alphabetic.
|
|
for (previous_idx, previous_line), (current_idx, current_line) in zip(use_rows, use_rows[1:]):
|
|
between = lines[previous_idx:current_idx - 1]
|
|
if any(not candidate.strip() for candidate in between):
|
|
violations.append(Violation("RUST-IMPORT-113", relative, current_idx, "module use block contains an empty line"))
|
|
previous_path = previous_line.split("use ", 1)[1].split(";", 1)[0]
|
|
current_path = current_line.split("use ", 1)[1].split(";", 1)[0]
|
|
if natural_key(current_path) < natural_key(previous_path):
|
|
violations.append(Violation("RUST-IMPORT-114", relative, current_idx, "module use block is not alphabetically ordered"))
|
|
# Crate façade constraints.
|
|
if path.name in {"lib.rs", "main.rs"}:
|
|
for attribute in ("#![warn(missing_docs)]", "#![deny(unreachable_pub)]", "#![forbid(unsafe_code)]"):
|
|
if attribute not in lines[:24]:
|
|
violations.append(Violation("RUST-BASE-103", relative, 1, f"missing `{attribute}`"))
|
|
exports: list[tuple[int, str]] = []
|
|
for idx, line in enumerate(lines, 1):
|
|
stripped = line.strip()
|
|
if stripped.startswith("pub use ") or stripped.startswith("pub(crate) use "):
|
|
visibility = "pub(crate)" if stripped.startswith("pub(crate) use ") else "pub"
|
|
exports.append((idx, visibility))
|
|
if not preceding_doc_line(lines, idx):
|
|
violations.append(Violation("RUST-DOC-102", relative, idx, "crate-root re-export requires adjacent rustdoc"))
|
|
source_match = re.match(r"^pub(?:\(crate\))?\s+use\s+(?:self::)?([A-Za-z_][A-Za-z0-9_]*)::", stripped)
|
|
if source_match is not None and source_match.group(1) in local_modules and not re.match(r"^pub(?:\(crate\))?\s+use\s+self::", stripped):
|
|
violations.append(Violation("RUST-IMPORT-106", relative, idx, "crate-root internal re-export must start with self::"))
|
|
public_exports = [item for item in exports if item[1] == "pub"]
|
|
crate_exports = [item for item in exports if item[1] == "pub(crate)"]
|
|
if public_exports and crate_exports:
|
|
public_end = public_exports[-1][0]
|
|
crate_start = crate_exports[0][0]
|
|
transition = lines[public_end:crate_start - 1]
|
|
blank_count = sum(1 for candidate in transition if not candidate.strip())
|
|
if blank_count != 1:
|
|
violations.append(Violation("RUST-FMT-113", relative, crate_start, f"pub use and pub(crate) use blocks require exactly one blank line; found {blank_count}"))
|
|
crate_seen = False
|
|
previous_by_visibility: dict[str, int] = {}
|
|
for idx, visibility in exports:
|
|
if visibility == "pub(crate)":
|
|
crate_seen = True
|
|
elif crate_seen:
|
|
violations.append(Violation("RUST-FMT-105", relative, idx, "pub use block must precede pub(crate) use block"))
|
|
previous_idx = previous_by_visibility.get(visibility)
|
|
if previous_idx is not None:
|
|
# One homogeneous block may contain rustdocs but no blank line.
|
|
# rustfmt is authoritative for intra-block re-export ordering;
|
|
# this audit must not impose a competing exported-symbol sort.
|
|
between = lines[previous_idx:idx - 1]
|
|
if any(not candidate.strip() for candidate in between):
|
|
violations.append(Violation("RUST-FMT-106", relative, idx, f"{visibility} use block contains an empty line"))
|
|
previous_by_visibility[visibility] = idx
|
|
violations.extend(audit_blank_lines_in_bodies(relative, lines, masked_lines, depths))
|
|
violations.extend(audit_item_spacing_and_nesting(relative, lines, masked_lines, depths))
|
|
violations.extend(audit_module_order(relative, lines, depths))
|
|
violations.extend(audit_top_level_const_blocks(relative, lines, depths))
|
|
return violations
|
|
|
|
|
|
def main() -> int:
|
|
"""Run the general Rust normalization audit."""
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--root", default=".")
|
|
parser.add_argument("--report-only", action="store_true")
|
|
arguments = parser.parse_args()
|
|
root = pathlib.Path(arguments.root).resolve()
|
|
violations = [item for path in rust_files(root) for item in audit_file(root, path)]
|
|
violations.sort(key=lambda item: (item.code, item.path, item.line, item.message))
|
|
if not violations:
|
|
sys.stdout.write("General Rust rule audit: clean\n")
|
|
return 0
|
|
sys.stdout.write(f"General Rust rule audit: {len(violations)} violation(s)\n")
|
|
counts: dict[str, int] = {}
|
|
for item in violations:
|
|
counts[item.code] = counts.get(item.code, 0) + 1
|
|
for code in sorted(counts):
|
|
sys.stdout.write(f"{code}: {counts[code]}\n")
|
|
for item in violations:
|
|
sys.stdout.write(f"{item.code} {item.path}:{item.line}: {item.message}\n")
|
|
return 0 if arguments.report_only else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|