use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::str::FromStr as _;
use proc_macro2::{Delimiter, TokenStream, TokenTree};
pub const EDGE_GUARD_ATTRS: &[&str] = &["secured", "authorize", "step_up", "throttle", "intercept"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EdgeFn {
pub name: String,
pub file: String,
pub line: usize,
pub guards: Vec<String>,
}
impl EdgeFn {
#[must_use]
pub fn location(&self) -> String {
format!("{} @ {}:{}", self.name, self.file, self.line)
}
}
#[derive(Debug, Clone, Default)]
pub struct EdgeScan {
pub functions: Vec<EdgeFn>,
pub registered: BTreeSet<String>,
pub registrations: usize,
pub files_scanned: usize,
}
impl EdgeScan {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.functions.is_empty()
}
#[must_use]
pub fn names(&self) -> Vec<&str> {
self.functions.iter().map(|f| f.name.as_str()).collect()
}
#[must_use]
pub fn unregistered(&self) -> Vec<&EdgeFn> {
self.functions
.iter()
.filter(|f| !self.registered.contains(&f.name))
.collect()
}
#[must_use]
pub fn registered_fns(&self) -> Vec<&EdgeFn> {
self.functions
.iter()
.filter(|f| self.registered.contains(&f.name))
.collect()
}
#[must_use]
pub fn guarded(&self) -> Vec<&EdgeFn> {
self.functions
.iter()
.filter(|f| !f.guards.is_empty())
.collect()
}
}
#[must_use]
pub fn scan_sources(sources: &[(&str, &str)]) -> EdgeScan {
let mut scan = EdgeScan::default();
for (file, src) in sources {
scan_source(file, src, &mut scan);
scan.files_scanned += 1;
}
scan
}
#[must_use]
pub fn resolve_edge_scan(project_root: &Path) -> EdgeScan {
let mut files = Vec::new();
collect_rs_files(&project_root.join("src"), &mut files);
files.sort();
let sources: Vec<(String, String)> = files
.iter()
.filter_map(|path| {
let src = std::fs::read_to_string(path).ok()?;
let rel = path
.strip_prefix(project_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
Some((rel, src))
})
.collect();
let borrowed: Vec<(&str, &str)> = sources
.iter()
.map(|(file, src)| (file.as_str(), src.as_str()))
.collect();
scan_sources(&borrowed)
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if file_type.is_dir() {
if name == "target" || name == "tests" || name.starts_with('.') {
continue;
}
collect_rs_files(&path, out);
} else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn scan_source(file: &str, src: &str, scan: &mut EdgeScan) {
if let Ok(ast) = syn::parse_file(src) {
scan_items(&ast.items, file, scan);
}
if let Ok(stream) = TokenStream::from_str(src) {
collect_registrations(&stream, scan);
}
}
fn scan_items(items: &[syn::Item], file: &str, scan: &mut EdgeScan) {
for item in items {
match item {
syn::Item::Fn(item_fn) => {
if let Some(found) = edge_fn(&item_fn.attrs, &item_fn.sig, file) {
scan.functions.push(found);
}
}
syn::Item::Mod(item_mod) => {
if let Some((_, inner)) = &item_mod.content {
scan_items(inner, file, scan);
}
}
_ => {}
}
}
}
fn attr_name(attr: &syn::Attribute) -> Option<String> {
attr.path().segments.last().map(|s| s.ident.to_string())
}
fn edge_fn(attrs: &[syn::Attribute], sig: &syn::Signature, file: &str) -> Option<EdgeFn> {
let names: Vec<String> = attrs.iter().filter_map(attr_name).collect();
if !names.iter().any(|n| n == "edge") {
return None;
}
let guards: Vec<String> = names
.into_iter()
.filter(|n| EDGE_GUARD_ATTRS.contains(&n.as_str()))
.collect();
Some(EdgeFn {
name: sig.ident.to_string(),
file: file.to_owned(),
line: sig.ident.span().start().line,
guards,
})
}
fn collect_registrations(stream: &TokenStream, scan: &mut EdgeScan) {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
for (index, tree) in trees.iter().enumerate() {
match tree {
TokenTree::Group(group) => collect_registrations(&group.stream(), scan),
TokenTree::Ident(ident) if ident == "edge_routes" => {
let bang =
matches!(trees.get(index + 1), Some(TokenTree::Punct(p)) if p.as_char() == '!');
if !bang {
continue;
}
let Some(TokenTree::Group(group)) = trees.get(index + 2) else {
continue;
};
if group.delimiter() == Delimiter::None {
continue;
}
scan.registrations += 1;
for name in registered_idents(&group.stream()) {
scan.registered.insert(name);
}
}
_ => {}
}
}
}
fn registered_idents(stream: &TokenStream) -> Vec<String> {
let mut out = Vec::new();
let mut current: Option<String> = None;
for tree in stream.clone() {
match tree {
TokenTree::Punct(p) if p.as_char() == ',' => {
out.extend(current.take());
}
TokenTree::Ident(ident) => current = Some(ident.to_string()),
_ => {}
}
}
out.extend(current);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn scan_one(src: &str) -> EdgeScan {
scan_sources(&[("src/main.rs", src)])
}
#[test]
fn bare_edge_attribute_is_found() {
let scan = scan_one(
r#"
#[get("/hello")]
#[edge]
pub async fn hello() -> &'static str { "hi" }
"#,
);
assert_eq!(scan.names(), vec!["hello"]);
assert_eq!(scan.functions[0].file, "src/main.rs");
assert!(scan.functions[0].guards.is_empty());
assert!(!scan.is_empty());
}
#[test]
fn qualified_edge_paths_are_recognized() {
let scan = scan_sources(&[
("src/a.rs", "#[autumn_web::edge]\nfn a() {}"),
("src/b.rs", "#[macros::edge]\nfn b() {}"),
("src/c.rs", "#[edge(needs(kv))]\nfn c() {}"),
]);
assert_eq!(scan.names(), vec!["a", "b", "c"]);
assert_eq!(scan.files_scanned, 3);
}
#[test]
fn unmarked_functions_are_ignored() {
let scan = scan_one("#[get(\"/x\")]\nfn plain() {}\n#[edgy]\nfn other() {}");
assert!(scan.is_empty(), "{:?}", scan.functions);
}
#[test]
fn guard_attributes_on_the_same_fn_are_recorded() {
let scan = scan_one(
r#"
#[get("/dash")]
#[edge]
#[secured]
fn dash() {}
#[get("/t")]
#[edge]
#[autumn_web::throttle(per_minute = 5)]
fn throttled() {}
#[get("/ok")]
#[edge]
fn ok() {}
"#,
);
let guarded: Vec<&str> = scan.guarded().iter().map(|f| f.name.as_str()).collect();
assert_eq!(guarded, vec!["dash", "throttled"]);
assert_eq!(scan.functions[0].guards, vec!["secured".to_owned()]);
assert_eq!(scan.functions[1].guards, vec!["throttle".to_owned()]);
}
#[test]
fn every_guard_attribute_is_detected() {
for guard in EDGE_GUARD_ATTRS {
let scan = scan_one(&format!("#[edge]\n#[{guard}]\nfn g() {{}}"));
assert_eq!(
scan.guarded().len(),
1,
"#[{guard}] must be detected next to #[edge]"
);
}
}
#[test]
fn registrations_collect_handler_idents() {
let scan = scan_one(
r"
#[edge]
fn greet() {}
#[edge]
fn note() {}
pub fn edge_routes() -> Vec<EdgeRoute> {
autumn_edge::edge_routes![handlers::greet, note,]
}
",
);
assert_eq!(scan.registrations, 1);
assert!(scan.registered.contains("greet"));
assert!(scan.registered.contains("note"));
assert!(scan.unregistered().is_empty());
assert_eq!(
scan.registered_fns()
.iter()
.map(|f| f.name.as_str())
.collect::<Vec<_>>(),
vec!["greet", "note"]
);
}
#[test]
fn a_function_named_edge_routes_is_not_a_registration() {
let scan = scan_one(
r"
#[edge]
fn greet() {}
pub fn edge_routes() -> Vec<EdgeRoute> { Vec::new() }
",
);
assert_eq!(scan.registrations, 0);
assert_eq!(
scan.unregistered()
.iter()
.map(|f| f.name.as_str())
.collect::<Vec<_>>(),
vec!["greet"]
);
}
#[test]
fn unregistered_lists_only_the_missing_handlers() {
let scan = scan_one(
r"
#[edge]
fn greet() {}
#[edge]
fn stats() {}
fn wire() { edge_routes![greet]; }
",
);
let missing: Vec<String> = scan.unregistered().iter().map(|f| f.location()).collect();
assert_eq!(missing.len(), 1);
assert!(
missing[0].starts_with("stats @ src/main.rs:"),
"{missing:?}"
);
}
#[test]
fn inline_modules_are_scanned() {
let scan = scan_one(
r"
mod routes {
#[edge]
pub fn nested() {}
}
",
);
assert_eq!(scan.names(), vec!["nested"]);
}
#[test]
fn line_numbers_point_at_the_declaration() {
let scan = scan_one("// header\n\n#[edge]\nfn marked() {}\n");
assert_eq!(scan.functions[0].line, 4);
}
#[test]
fn unparseable_source_is_skipped() {
let scan = scan_one("fn broken( {");
assert!(scan.is_empty());
assert_eq!(scan.registrations, 0);
}
#[test]
fn registrations_are_found_across_files() {
let scan = scan_sources(&[
("src/handlers.rs", "#[edge]\nfn greet() {}"),
(
"src/bin/edge-capsule.rs",
"fn main() { autumn_edge::serve(edge_routes![greet]); }",
),
]);
assert_eq!(scan.registrations, 1);
assert!(scan.unregistered().is_empty());
}
#[test]
fn resolve_reads_src_tree_relative_to_project_root() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src/routes")).unwrap();
std::fs::create_dir_all(dir.path().join("target")).unwrap();
std::fs::write(
dir.path().join("src/routes/home.rs"),
"#[edge]\npub fn home() {}\n",
)
.unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
std::fs::write(dir.path().join("target/gen.rs"), "#[edge]\nfn gen() {}\n").unwrap();
let scan = resolve_edge_scan(dir.path());
assert_eq!(scan.names(), vec!["home"]);
assert_eq!(scan.functions[0].file, "src/routes/home.rs");
assert_eq!(scan.files_scanned, 2);
}
#[test]
fn resolve_without_src_is_empty() {
let dir = tempfile::tempdir().unwrap();
let scan = resolve_edge_scan(dir.path());
assert!(scan.is_empty());
assert_eq!(scan.files_scanned, 0);
}
}