From ba0f83a85522143ba5f3d1bad3a609ecb0675b72 Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 21 May 2026 08:33:26 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0014 (20260520T233019Z-6958) --- src/dynamic/framework/adapters/rust_actix.rs | 54 ++++++- src/dynamic/framework/adapters/rust_routes.rs | 150 ++++++++++++++++++ 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/src/dynamic/framework/adapters/rust_actix.rs b/src/dynamic/framework/adapters/rust_actix.rs index cf6a6aa9..e2b47442 100644 --- a/src/dynamic/framework/adapters/rust_actix.rs +++ b/src/dynamic/framework/adapters/rust_actix.rs @@ -19,8 +19,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::rust_routes::{ - bind_rust_path_params, find_method_attribute, find_rust_function, rust_formal_names, - source_imports_actix, + bind_rust_path_params, find_actix_route_chain, find_method_attribute, find_rust_function, + rust_formal_names, source_imports_actix, }; pub struct RustActixAdapter; @@ -46,7 +46,8 @@ impl FrameworkAdapter for RustActixAdapter { return None; } let func = find_rust_function(ast, file_bytes, &summary.name)?; - let (method, path) = find_method_attribute(func, file_bytes)?; + let (method, path) = find_method_attribute(func, file_bytes) + .or_else(|| find_actix_route_chain(ast, file_bytes, &summary.name))?; let formals = rust_formal_names(func, file_bytes); let request_params = bind_rust_path_params(&formals, &path); Some(FrameworkBinding { @@ -126,4 +127,51 @@ mod tests { .detect(&summary("helper"), tree.root_node(), src) .is_none()); } + + #[test] + fn fires_on_app_new_route_chain() { + let src: &[u8] = b"use actix_web::{App, web};\n\ + fn build() -> App<()> { App::new().route(\"/u/{id}\", web::get().to(show)) }\n\ + async fn show(id: String) -> String { id }\n"; + let tree = parse(src); + let binding = RustActixAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "rust-actix"); + let route = binding.route.expect("route"); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/u/{id}"); + let id = binding + .request_params + .iter() + .find(|p| p.name == "id") + .unwrap(); + assert!(matches!(id.source, ParamSource::PathSegment(_))); + } + + #[test] + fn fires_on_web_resource_route_chain() { + let src: &[u8] = b"use actix_web::{App, web};\n\ + fn build() -> App<()> { App::new().service(web::resource(\"/save\").route(web::post().to(save))) }\n\ + async fn save(body: String) -> String { body }\n"; + let tree = parse(src); + let binding = RustActixAdapter + .detect(&summary("save"), tree.root_node(), src) + .expect("binding"); + let route = binding.route.expect("route"); + assert_eq!(route.method, HttpMethod::POST); + assert_eq!(route.path, "/save"); + } + + #[test] + fn chained_builder_requires_handler_match() { + let src: &[u8] = b"use actix_web::{App, web};\n\ + fn build() -> App<()> { App::new().route(\"/x\", web::get().to(other)) }\n\ + async fn show() -> String { String::new() }\n\ + async fn other() -> String { String::new() }\n"; + let tree = parse(src); + assert!(RustActixAdapter + .detect(&summary("show"), tree.root_node(), src) + .is_none()); + } } diff --git a/src/dynamic/framework/adapters/rust_routes.rs b/src/dynamic/framework/adapters/rust_routes.rs index 59e4ac47..dde0c11c 100644 --- a/src/dynamic/framework/adapters/rust_routes.rs +++ b/src/dynamic/framework/adapters/rust_routes.rs @@ -493,6 +493,156 @@ fn axum_callable_matches(node: Node<'_>, bytes: &[u8], target: &str) -> bool { } } +/// Walk `root` looking for an actix-web chained-builder route registration +/// (`App::new().route("/path", web::get().to(handler))` or +/// `web::resource("/path").route(web::get().to(handler))`) that wires +/// `target` as the handler. Returns `(method, path)` on first match. +pub fn find_actix_route_chain<'a>( + root: Node<'a>, + bytes: &'a [u8], + target: &str, +) -> Option<(HttpMethod, String)> { + let mut hit: Option<(HttpMethod, String)> = None; + walk_actix_chain(root, bytes, target, &mut hit); + hit +} + +fn walk_actix_chain<'a>( + node: Node<'a>, + bytes: &'a [u8], + target: &str, + out: &mut Option<(HttpMethod, String)>, +) { + if out.is_some() { + return; + } + if node.kind() == "call_expression" + && let Some(found) = try_actix_route_call(node, bytes, target) + { + *out = Some(found); + return; + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk_actix_chain(child, bytes, target, out); + } +} + +fn try_actix_route_call<'a>( + call: Node<'a>, + bytes: &'a [u8], + target: &str, +) -> Option<(HttpMethod, String)> { + let func = call.child_by_field_name("function")?; + if func.kind() != "field_expression" { + return None; + } + let field = func.child_by_field_name("field")?.utf8_text(bytes).ok()?; + if field != "route" { + return None; + } + let args = call.child_by_field_name("arguments")?; + let positional: Vec> = { + let mut cur = args.walk(); + args.named_children(&mut cur) + .filter(|c| !matches!(c.kind(), "line_comment" | "block_comment")) + .collect() + }; + let (path, verb_node) = match positional.len() { + 2 => { + let path = rust_string_literal(positional[0], bytes)?; + (path, positional[1]) + } + 1 => { + let receiver = func.child_by_field_name("value")?; + let path = find_actix_resource_path(receiver, bytes)?; + (path, positional[0]) + } + _ => return None, + }; + let (method, handler) = parse_actix_web_verb_to(verb_node, bytes)?; + if !axum_callable_matches(handler, bytes, target) { + return None; + } + Some((method, path)) +} + +/// Parse `web::get().to(handler)` / `web::post().to(handler)` / +/// `web::method(Method::PATCH).to(handler)` shapes. Returns +/// `(method, handler_node)` on the first matching `.to(...)` call. +fn parse_actix_web_verb_to<'a>( + node: Node<'a>, + bytes: &'a [u8], +) -> Option<(HttpMethod, Node<'a>)> { + if node.kind() != "call_expression" { + return None; + } + let func = node.child_by_field_name("function")?; + if func.kind() != "field_expression" { + return None; + } + let field = func.child_by_field_name("field")?.utf8_text(bytes).ok()?; + if field != "to" { + return None; + } + let args = node.child_by_field_name("arguments")?; + let handler = { + let mut cur = args.walk(); + args.named_children(&mut cur) + .find(|c| !matches!(c.kind(), "line_comment" | "block_comment"))? + }; + let recv = func.child_by_field_name("value")?; + if recv.kind() != "call_expression" { + return None; + } + let recv_func = recv.child_by_field_name("function")?; + let leaf = match recv_func.kind() { + "scoped_identifier" => recv_func + .child_by_field_name("name")? + .utf8_text(bytes) + .ok()?, + "identifier" => recv_func.utf8_text(bytes).ok()?, + _ => return None, + }; + let method = verb_from_ident(leaf)?; + Some((method, handler)) +} + +/// Walk a receiver-chain backwards looking for the first +/// `web::resource(path)` / `web::scope(path)` call. Used when an actix +/// route is registered via `web::resource("/x").route(web::get().to(h))` +/// (no path argument on the `route` call itself). +fn find_actix_resource_path(node: Node<'_>, bytes: &[u8]) -> Option { + let mut cur = node; + loop { + if cur.kind() == "call_expression" { + let func = cur.child_by_field_name("function")?; + let leaf = match func.kind() { + "scoped_identifier" => func + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + .unwrap_or(""), + "identifier" => func.utf8_text(bytes).ok().unwrap_or(""), + "field_expression" => { + cur = func.child_by_field_name("value")?; + continue; + } + _ => "", + }; + if matches!(leaf, "resource" | "scope") { + let args = cur.child_by_field_name("arguments")?; + let mut cur_arg = args.walk(); + let first = args + .named_children(&mut cur_arg) + .find(|c| !matches!(c.kind(), "line_comment" | "block_comment"))?; + return rust_string_literal(first, bytes); + } + return None; + } + return None; + } +} + /// Walk `root` looking for a `warp::path!("users" / u32)` macro /// invocation that bridges to `target` via `.map(target)` / /// `.and_then(target)`. Returns `(method, path)` on first match.