use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use quote::ToTokens;
use syn::visit::Visit;
const INDEX_ALLOWLIST: &[(&str, usize)] = &[
("calculus/z_transform.rs", 1),
("poly/polybridge.rs", 3),
("transforms/evalf/quad.rs", 4),
];
const ARENA_ORDER_ALLOWLIST: &[(&str, usize)] = &[
("transforms/evalf/extended.rs", 2),
("transforms/subs.rs", 2),
];
const ORDERING_METHODS: &[&str] = &[
"sort_by",
"sort_by_key",
"sort_by_cached_key",
"sort_unstable_by",
"sort_unstable_by_key",
"min_by",
"min_by_key",
"max_by",
"max_by_key",
"binary_search_by",
"binary_search_by_key",
"select_nth_unstable_by",
"select_nth_unstable_by_key",
"is_sorted_by",
"is_sorted_by_key",
"partition_point",
];
#[derive(Default)]
struct Scan {
index_calls: usize,
arena_order: usize,
in_ordering_closure: usize,
violations: Vec<String>,
}
impl<'ast> Visit<'ast> for Scan {
fn visit_expr_method_call(&mut self, m: &'ast syn::ExprMethodCall) {
let name = m.method.to_string();
if name == "index" && m.args.is_empty() {
self.index_calls += 1;
if self.in_ordering_closure > 0 {
self.violations
.push(m.to_token_stream().to_string().replace(' ', ""));
}
}
if ORDERING_METHODS.contains(&name.as_str()) {
self.visit_expr(&m.receiver);
self.in_ordering_closure += 1;
for arg in &m.args {
self.visit_expr(arg);
}
self.in_ordering_closure -= 1;
} else {
syn::visit::visit_expr_method_call(self, m);
}
}
fn visit_macro(&mut self, m: &'ast syn::Macro) {
let parser = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
if let Ok(args) = m.parse_body_with(parser) {
for arg in &args {
self.visit_expr(arg);
}
}
}
fn visit_expr_call(&mut self, c: &'ast syn::ExprCall) {
if let syn::Expr::Path(p) = &*c.func
&& p.path
.segments
.last()
.is_some_and(|s| s.ident == "ArenaOrder")
{
self.arena_order += 1;
}
syn::visit::visit_expr_call(self, c);
}
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).expect("src/ is readable") {
let path = entry.expect("directory entry").path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
fn scan_file(path: &Path) -> Scan {
let text = fs::read_to_string(path).expect("source file is readable");
let file = syn::parse_file(&text)
.unwrap_or_else(|e| panic!("{}: does not parse as Rust: {e}", path.display()));
let mut scan = Scan::default();
scan.visit_file(&file);
scan
}
fn check_counts(
what: &str,
allowlist: &[(&str, usize)],
found: &BTreeMap<String, usize>,
problems: &mut Vec<String>,
) {
let allowed: BTreeMap<&str, usize> = allowlist.iter().copied().collect();
for (file, &n) in found {
let a = allowed.get(file.as_str()).copied().unwrap_or(0);
if n != a {
problems.push(format!(
"{file}: {n} {what}, allowlisted {a} — order ids with `Arena::canonical_cmp`; \
update the allowlist only for identity or hashing uses"
));
}
}
for (file, &a) in &allowed {
if !found.contains_key(*file) {
problems.push(format!(
"{file}: 0 {what}, allowlisted {a} — tighten the allowlist"
));
}
}
}
#[test]
fn ids_are_not_ordered_by_allocation_outside_base() {
let src = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src"));
let mut files = Vec::new();
collect_rs_files(src, &mut files);
files.sort();
let mut index_calls = BTreeMap::new();
let mut arena_order = BTreeMap::new();
let mut problems = Vec::new();
for path in &files {
let rel = path
.strip_prefix(src)
.expect("file is under src/")
.to_string_lossy()
.replace('\\', "/");
if rel.starts_with("base/") {
continue;
}
let scan = scan_file(path);
if scan.index_calls > 0 {
index_calls.insert(rel.clone(), scan.index_calls);
}
if scan.arena_order > 0 {
arena_order.insert(rel.clone(), scan.arena_order);
}
for v in scan.violations {
problems.push(format!("{rel}: `{v}` orders by allocation number"));
}
}
check_counts(
"`ExprId::index()` call(s)",
INDEX_ALLOWLIST,
&index_calls,
&mut problems,
);
check_counts(
"`ArenaOrder` construction(s)",
ARENA_ORDER_ALLOWLIST,
&arena_order,
&mut problems,
);
assert!(
problems.is_empty(),
"id-order ratchet:\n{}",
problems.join("\n")
);
}
#[test]
fn ordering_closure_detection() {
let text = r#"fn f(v: &mut Vec<ExprId>, a: &Arena) {
v.sort_by_key(|id| id.index());
v.sort_by(|&x, &y| a.canonical_cmp(x, y));
let h = v[0].index();
v.dedup_by_key(|id| ArenaOrder(*id));
let name = format!("s{}", v[1].index());
}"#;
let file = syn::parse_file(text).expect("parses");
let mut scan = Scan::default();
scan.visit_file(&file);
assert_eq!(scan.index_calls, 3);
assert_eq!(scan.violations.len(), 1);
assert_eq!(scan.arena_order, 1);
}