use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone)]
pub struct RouteEntry {
pub name: &'static str,
pub path: &'static str,
pub method: &'static str,
}
static REGISTRY: OnceLock<Mutex<Vec<RouteEntry>>> = OnceLock::new();
fn registry() -> &'static Mutex<Vec<RouteEntry>> {
REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
}
pub fn register_runtime_route(name: &'static str, path: &'static str, method: &'static str) {
let mut lock = registry().lock().expect("route registry poisoned");
lock.push(RouteEntry { name, path, method });
}
pub fn registered_routes() -> Vec<RouteEntry> {
registry().lock().expect("route registry poisoned").clone()
}
struct Action {
path: &'static str,
method: &'static str,
}
#[derive(Default)]
struct Node {
children: BTreeMap<String, Node>,
leaf: Option<Action>,
}
fn build_tree() -> Node {
let mut root = Node::default();
for entry in registered_routes() {
let mut node = &mut root;
let segments: Vec<&str> = entry.name.split('.').collect();
for (i, seg) in segments.iter().enumerate() {
node = node.children.entry((*seg).to_string()).or_default();
if i == segments.len() - 1 {
node.leaf = Some(Action {
path: entry.path,
method: entry.method,
});
}
}
}
root
}
pub(super) fn render_routes() -> String {
let root = build_tree();
let mut s = String::new();
s.push_str("export const routes = ");
write_node(&mut s, &root, 0);
s.push_str(";\n");
s
}
pub(super) fn render_per_controller() -> BTreeMap<String, String> {
let root = build_tree();
let mut groups: BTreeMap<String, Node> = BTreeMap::new();
for (top, sub) in root.children {
if sub.leaf.is_some() && sub.children.is_empty() {
groups
.entry("_root".to_string())
.or_default()
.children
.insert(top, sub);
} else {
groups.insert(top, sub);
}
}
groups
.into_iter()
.map(|(name, node)| {
let mut s = String::new();
for (action_name, child) in &node.children {
let _ = write!(s, "export const {} = ", js_ident(action_name));
write_node(&mut s, child, 0);
s.push_str(";\n");
}
(name, s)
})
.collect()
}
fn write_node(s: &mut String, node: &Node, depth: usize) {
if let Some(action) = &node.leaf {
write_leaf(s, action, depth);
return;
}
let pad = " ".repeat(depth);
let inner_pad = " ".repeat(depth + 1);
s.push_str("{\n");
let len = node.children.len();
for (i, (name, child)) in node.children.iter().enumerate() {
let _ = write!(s, "{}{}: ", inner_pad, js_key(name));
write_node(s, child, depth + 1);
if i + 1 < len {
s.push(',');
}
s.push('\n');
}
let _ = write!(s, "{pad}}}");
}
fn write_leaf(s: &mut String, action: &Action, depth: usize) {
let pad = " ".repeat(depth);
let inner_pad = " ".repeat(depth + 1);
let params = parse_params(action.path);
s.push_str("Object.assign(\n");
let _ = write!(s, "{inner_pad}");
write_arrow(s, ¶ms, |s| {
s.push_str("({ url: ");
write_url_template(s, action.path, ¶ms);
let _ = write!(s, ", method: {:?} }} as const)", action.method);
});
s.push_str(",\n");
let _ = writeln!(s, "{inner_pad}{{");
let helper_pad = " ".repeat(depth + 2);
let _ = write!(s, "{helper_pad}url: ");
write_arrow(s, ¶ms, |s| {
write_url_template(s, action.path, ¶ms);
});
s.push_str(",\n");
let _ = write!(s, "{helper_pad}form: ");
write_arrow(s, ¶ms, |s| {
s.push_str("({ action: ");
write_url_template(s, action.path, ¶ms);
let _ = write!(s, ", method: {:?} }} as const)", action.method);
});
s.push_str(",\n");
let _ = writeln!(s, "{inner_pad}}},");
let _ = write!(s, "{pad})");
}
fn write_arrow<F: FnOnce(&mut String)>(s: &mut String, params: &[&str], body: F) {
if params.is_empty() {
s.push_str("() => ");
} else {
s.push_str("(params: { ");
for (i, p) in params.iter().enumerate() {
if i > 0 {
s.push_str("; ");
}
let _ = write!(s, "{p}: string | number");
}
s.push_str(" }) => ");
}
body(s);
}
fn write_url_template(s: &mut String, path: &str, params: &[&str]) {
if params.is_empty() {
let _ = write!(s, "{path:?}");
return;
}
s.push('`');
for seg in path.split('/') {
if seg.is_empty() {
continue;
}
s.push('/');
if let Some(name) = seg.strip_prefix(':') {
let _ = write!(s, "${{params.{name}}}");
} else if let Some(name) = seg.strip_prefix('*') {
let _ = write!(s, "${{params.{name}}}");
} else {
s.push_str(seg);
}
}
s.push('`');
}
fn parse_params(path: &str) -> Vec<&str> {
path.split('/')
.filter_map(|seg| seg.strip_prefix(':').or_else(|| seg.strip_prefix('*')))
.collect()
}
fn is_ident(name: &str) -> bool {
!name.is_empty()
&& name.chars().next().unwrap().is_ascii_alphabetic()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
const JS_RESERVED: &[&str] = &[
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"import",
"in",
"instanceof",
"new",
"null",
"return",
"super",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
"let",
"static",
"implements",
"interface",
"package",
"private",
"protected",
"public",
"await",
"async",
];
fn js_key(name: &str) -> String {
if is_ident(name) && !JS_RESERVED.contains(&name) {
name.to_string()
} else {
format!("{name:?}")
}
}
fn js_ident(name: &str) -> String {
let base: String = if is_ident(name) {
name.to_string()
} else {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
};
if JS_RESERVED.contains(&base.as_str()) {
format!("{base}_")
} else {
base
}
}