use crate::models::{ConstantInfo, FunctionInfo};
use std::collections::HashSet;
mod django;
mod fastapi;
mod flask;
#[derive(Debug)]
pub struct RouteNode {
pub id: String,
pub name: String,
pub path: String,
pub method: String,
pub framework: String,
pub file_path: String,
pub line_number: u32,
}
#[derive(Debug)]
pub struct RouteEdge {
pub route_id: String,
pub function_qname: String,
}
pub fn build_routes(
functions: &[FunctionInfo],
constants: &[ConstantInfo],
) -> (Vec<RouteNode>, Vec<RouteEdge>) {
let mut nodes = Vec::new();
let mut edges = Vec::new();
for (det_nodes, det_edges) in [
flask::detect(functions),
fastapi::detect(functions),
django::detect(constants, functions),
] {
nodes.extend(det_nodes);
edges.extend(det_edges);
}
let mut node_ids = HashSet::new();
nodes.retain(|node| node_ids.insert(node.id.clone()));
let mut edge_ids = HashSet::new();
edges.retain(|edge| edge_ids.insert((edge.route_id.clone(), edge.function_qname.clone())));
(nodes, edges)
}
pub(super) fn split_decorator(raw: &str) -> Option<(&str, &str)> {
let open = raw.find('(')?;
let close = raw.rfind(')')?;
if close < open {
return None;
}
Some((raw[..open].trim(), &raw[open + 1..close]))
}
pub(super) fn first_string_literal(args: &str) -> Option<String> {
let bytes = args.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b' ' || b == b'\t' {
i += 1;
continue;
}
if b == b'\'' || b == b'"' {
let quote = b;
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j] != quote {
if bytes[j] == b'\\' && j + 1 < bytes.len() {
j += 2;
continue;
}
j += 1;
}
if j < bytes.len() {
return Some(std::str::from_utf8(&bytes[start..j]).ok()?.to_string());
}
return None;
}
return None;
}
None
}
pub(super) fn keyword_arg<'a>(args: &'a str, key: &str) -> Option<&'a str> {
let pat = format!("{key}=");
let mut start = 0;
while let Some(rel) = args[start..].find(&pat) {
let abs = start + rel;
let preceding_ok = abs == 0 || {
let b = args.as_bytes()[abs - 1];
b == b' ' || b == b','
};
if !preceding_ok {
start = abs + 1;
continue;
}
let after = abs + pat.len();
let bytes = args.as_bytes();
let mut depth = 0i32;
let mut in_quote: Option<u8> = None;
let mut j = after;
while j < bytes.len() {
let c = bytes[j];
if let Some(q) = in_quote {
if c == b'\\' && j + 1 < bytes.len() {
j += 2;
continue;
}
if c == q {
in_quote = None;
}
} else {
match c {
b'\'' | b'"' => in_quote = Some(c),
b'[' | b'(' | b'{' => depth += 1,
b']' | b')' | b'}' => depth -= 1,
b',' if depth == 0 => break,
_ => {}
}
}
j += 1;
}
return Some(args[after..j].trim());
}
None
}
pub(super) fn parse_methods_list(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
let inner = trimmed
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.or_else(|| trimmed.strip_prefix('(').and_then(|s| s.strip_suffix(')')))
.unwrap_or(trimmed);
let mut out = Vec::new();
for piece in inner.split(',') {
let p = piece.trim().trim_matches(['\'', '"']);
if !p.is_empty() {
out.push(p.to_ascii_uppercase());
}
}
out
}
pub(super) fn make_route_id(framework: &str, method: &str, path: &str, file_path: &str) -> String {
format!("{framework}::{method}::{path}::{file_path}")
}
#[cfg(test)]
mod tests {
use super::{build_routes, parse_methods_list};
use crate::models::FunctionInfo;
fn handler(qname: &str, file: &str, line: u32, decorator: &str) -> FunctionInfo {
FunctionInfo {
name: qname.rsplit('.').next().unwrap_or(qname).to_string(),
qualified_name: qname.to_string(),
file_path: file.to_string(),
line_number: line,
decorators: vec![decorator.to_string()],
..FunctionInfo::default()
}
}
#[test]
fn same_path_in_two_files_is_two_registrations() {
let functions = vec![
handler("a.index", "a.py", 3, "app.route('/')"),
handler("b.index", "b.py", 7, "app.route('/')"),
];
let (nodes, edges) = build_routes(&functions, &[]);
assert_eq!(
nodes.len(),
2,
"one Route node per registration, got {:?}",
nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
);
assert_ne!(nodes[0].id, nodes[1].id, "registrations need distinct ids");
let mut located: Vec<(String, u32)> = nodes
.iter()
.map(|n| (n.file_path.clone(), n.line_number))
.collect();
located.sort();
assert_eq!(
located,
vec![("a.py".to_string(), 3), ("b.py".to_string(), 7)],
"each Route must carry its own truthful source location"
);
for n in &nodes {
assert_eq!(n.path, "/");
assert_eq!(n.method, "ANY");
assert_eq!(n.framework, "flask");
}
let mut handled: Vec<(String, String)> = edges
.iter()
.map(|e| {
let node = nodes
.iter()
.find(|n| n.id == e.route_id)
.expect("edge references a retained node");
(node.file_path.clone(), e.function_qname.clone())
})
.collect();
handled.sort();
assert_eq!(
handled,
vec![
("a.py".to_string(), "a.index".to_string()),
("b.py".to_string(), "b.index".to_string()),
]
);
}
#[test]
fn duplicate_registrations_within_one_file_stay_one_node() {
let functions = vec![
handler("a.index", "a.py", 3, "app.route('/')"),
handler("a.alias", "a.py", 9, "app.route('/')"),
];
let (nodes, edges) = build_routes(&functions, &[]);
assert_eq!(nodes.len(), 1, "same file + method + path = one node");
assert_eq!(edges.len(), 2, "both handlers keep a HANDLES edge");
}
#[test]
fn method_and_path_still_separate_registrations_in_one_file() {
let functions = vec![
handler(
"a.get_it",
"a.py",
3,
"app.route('/x', methods=['GET','POST'])",
),
handler("a.other", "a.py", 9, "app.route('/y')"),
];
let (nodes, _) = build_routes(&functions, &[]);
let mut ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
ids.sort();
assert_eq!(
ids,
vec![
"flask::ANY::/y::a.py",
"flask::GET::/x::a.py",
"flask::POST::/x::a.py",
]
);
}
#[test]
fn list_form() {
assert_eq!(parse_methods_list("['GET', 'POST']"), vec!["GET", "POST"]);
assert_eq!(
parse_methods_list(r#"["GET", "POST"]"#),
vec!["GET", "POST"]
);
}
#[test]
fn tuple_form() {
assert_eq!(
parse_methods_list(r#"("GET", "POST")"#),
vec!["GET", "POST"]
);
assert_eq!(parse_methods_list("('get', 'post')"), vec!["GET", "POST"]);
}
#[test]
fn bare_string() {
assert_eq!(parse_methods_list("'GET'"), vec!["GET"]);
}
#[test]
fn lowercase_uppercased() {
assert_eq!(parse_methods_list("['get', 'post']"), vec!["GET", "POST"]);
}
}