diff --git a/src/dynamic/framework/adapters/go_chi.rs b/src/dynamic/framework/adapters/go_chi.rs index aafb6035..d631cd79 100644 --- a/src/dynamic/framework/adapters/go_chi.rs +++ b/src/dynamic/framework/adapters/go_chi.rs @@ -19,8 +19,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::go_routes::{ - bind_go_path_params, collect_use_middleware, find_go_function, find_route_for_callee, - go_formal_names, source_imports_chi, + GoRouteFramework, bind_go_path_params, collect_use_middleware, find_go_function, + find_route_for_callee_in_framework, go_formal_names, source_imports_chi, }; pub struct GoChiAdapter; @@ -45,7 +45,12 @@ impl FrameworkAdapter for GoChiAdapter { if !source_imports_chi(file_bytes) { return None; } - let (method, path) = find_route_for_callee(ast, file_bytes, &summary.name)?; + let (method, path) = find_route_for_callee_in_framework( + ast, + file_bytes, + &summary.name, + GoRouteFramework::Chi, + )?; let request_params = find_go_function(ast, file_bytes, &summary.name) .map(|func| { let formals = go_formal_names(func, file_bytes); diff --git a/src/dynamic/framework/adapters/go_echo.rs b/src/dynamic/framework/adapters/go_echo.rs index 99f1eb17..a18514c9 100644 --- a/src/dynamic/framework/adapters/go_echo.rs +++ b/src/dynamic/framework/adapters/go_echo.rs @@ -19,8 +19,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::go_routes::{ - bind_go_path_params, collect_use_middleware, find_go_function, find_route_for_callee, - go_formal_names, source_imports_echo, + GoRouteFramework, bind_go_path_params, collect_use_middleware, find_go_function, + find_route_for_callee_in_framework, go_formal_names, source_imports_echo, }; pub struct GoEchoAdapter; @@ -45,7 +45,12 @@ impl FrameworkAdapter for GoEchoAdapter { if !source_imports_echo(file_bytes) { return None; } - let (method, path) = find_route_for_callee(ast, file_bytes, &summary.name)?; + let (method, path) = find_route_for_callee_in_framework( + ast, + file_bytes, + &summary.name, + GoRouteFramework::Echo, + )?; let request_params = find_go_function(ast, file_bytes, &summary.name) .map(|func| { let formals = go_formal_names(func, file_bytes); diff --git a/src/dynamic/framework/adapters/go_fiber.rs b/src/dynamic/framework/adapters/go_fiber.rs index a74e77d4..ecb06f63 100644 --- a/src/dynamic/framework/adapters/go_fiber.rs +++ b/src/dynamic/framework/adapters/go_fiber.rs @@ -20,8 +20,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::go_routes::{ - bind_go_path_params, collect_use_middleware, find_go_function, find_route_for_callee, - go_formal_names, source_imports_fiber, + GoRouteFramework, bind_go_path_params, collect_use_middleware, find_go_function, + find_route_for_callee_in_framework, go_formal_names, source_imports_fiber, }; pub struct GoFiberAdapter; @@ -46,7 +46,12 @@ impl FrameworkAdapter for GoFiberAdapter { if !source_imports_fiber(file_bytes) { return None; } - let (method, path) = find_route_for_callee(ast, file_bytes, &summary.name)?; + let (method, path) = find_route_for_callee_in_framework( + ast, + file_bytes, + &summary.name, + GoRouteFramework::Fiber, + )?; let request_params = find_go_function(ast, file_bytes, &summary.name) .map(|func| { let formals = go_formal_names(func, file_bytes); diff --git a/src/dynamic/framework/adapters/go_gin.rs b/src/dynamic/framework/adapters/go_gin.rs index 8bbd89d3..99dce7ac 100644 --- a/src/dynamic/framework/adapters/go_gin.rs +++ b/src/dynamic/framework/adapters/go_gin.rs @@ -22,8 +22,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::go_routes::{ - bind_go_path_params, collect_use_middleware, find_go_function, find_route_for_callee, - go_formal_names, source_imports_gin, + GoRouteFramework, bind_go_path_params, collect_use_middleware, find_go_function, + find_route_for_callee_in_framework, go_formal_names, source_imports_gin, }; pub struct GoGinAdapter; @@ -48,7 +48,12 @@ impl FrameworkAdapter for GoGinAdapter { if !source_imports_gin(file_bytes) { return None; } - let (method, path) = find_route_for_callee(ast, file_bytes, &summary.name)?; + let (method, path) = find_route_for_callee_in_framework( + ast, + file_bytes, + &summary.name, + GoRouteFramework::Gin, + )?; let request_params = find_go_function(ast, file_bytes, &summary.name) .map(|func| { let formals = go_formal_names(func, file_bytes); diff --git a/src/dynamic/framework/adapters/go_routes.rs b/src/dynamic/framework/adapters/go_routes.rs index 1c5bbf0f..529631b7 100644 --- a/src/dynamic/framework/adapters/go_routes.rs +++ b/src/dynamic/framework/adapters/go_routes.rs @@ -19,6 +19,7 @@ use crate::dynamic::framework::auth_markers; use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource}; use crate::symbol::Lang; +use std::collections::HashSet; use tree_sitter::Node; /// True when `bytes` carries any of the well-known gin markers. @@ -229,6 +230,57 @@ fn is_implicit_formal(name: &str) -> bool { matches!(name, "c" | "ctx" | "w" | "r" | "req" | "res" | "rw") } +/// Go router family whose route-registration receiver can be checked +/// from local source context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoRouteFramework { + Gin, + Echo, + Fiber, + Chi, +} + +impl GoRouteFramework { + fn marker_comment(self) -> &'static str { + match self { + Self::Gin => "// nyx-shape: gin", + Self::Echo => "// nyx-shape: echo", + Self::Fiber => "// nyx-shape: fiber", + Self::Chi => "// nyx-shape: chi", + } + } + + fn constructor_markers(self) -> &'static [&'static str] { + match self { + Self::Gin => &["gin.Default(", "gin.New("], + Self::Echo => &["echo.New("], + Self::Fiber => &["fiber.New("], + Self::Chi => &["chi.NewRouter("], + } + } + + fn type_markers(self) -> &'static [&'static str] { + match self { + Self::Gin => &[ + "*gin.Engine", + "gin.Engine", + "*gin.RouterGroup", + "gin.RouterGroup", + ], + Self::Echo => &["*echo.Echo", "echo.Echo", "*echo.Group", "echo.Group"], + Self::Fiber => &["*fiber.App", "fiber.App", "*fiber.Group", "fiber.Group"], + Self::Chi => &["chi.Router", "*chi.Mux", "chi.Mux"], + } + } + + fn grouping_methods(self) -> &'static [&'static str] { + match self { + Self::Gin | Self::Echo | Self::Fiber => &["Group"], + Self::Chi => &["Group", "Route", "With"], + } + } +} + /// Parse Go verb-method names: `GET`, `POST`, `PUT`, `PATCH`, /// `DELETE`, `HEAD`, `OPTIONS` (case-insensitive — gin uses upper, /// echo / chi use upper, fiber uses pascal-cased like `Get`, @@ -355,28 +407,64 @@ pub fn find_route_for_callee<'a>( target: &str, ) -> Option<(HttpMethod, String)> { let mut hit: Option<(HttpMethod, String)> = None; - walk_routes(root, bytes, target, &mut hit); + walk_routes(root, bytes, target, None, &mut hit); hit } +/// Receiver-aware sibling of [`find_route_for_callee`]. +/// +/// A file can import a framework while also using ordinary objects with +/// route-like method names, for example `cache.Get(key)` or +/// `repo.Post(message)`. The broad helper above intentionally keeps +/// the old name-only behavior for legacy callers and unit tests; the +/// framework adapters call this variant so the registration receiver +/// must be a locally recognised router/app value. +pub fn find_route_for_callee_in_framework<'a>( + root: Node<'a>, + bytes: &'a [u8], + target: &str, + framework: GoRouteFramework, +) -> Option<(HttpMethod, String)> { + let receivers = collect_framework_receivers(root, bytes, framework); + let marker_fallback = receivers.is_empty() + && std::str::from_utf8(bytes) + .map(|s| s.contains(framework.marker_comment())) + .unwrap_or(false); + let filter = RouteReceiverFilter { + framework, + receivers: &receivers, + marker_fallback, + }; + let mut hit: Option<(HttpMethod, String)> = None; + walk_routes(root, bytes, target, Some(&filter), &mut hit); + hit +} + +struct RouteReceiverFilter<'a> { + framework: GoRouteFramework, + receivers: &'a HashSet, + marker_fallback: bool, +} + fn walk_routes<'a>( node: Node<'a>, bytes: &'a [u8], target: &str, + receiver_filter: Option<&RouteReceiverFilter<'_>>, out: &mut Option<(HttpMethod, String)>, ) { if out.is_some() { return; } if node.kind() == "call_expression" - && let Some(found) = try_route_call(node, bytes, target) + && let Some(found) = try_route_call(node, bytes, target, receiver_filter) { *out = Some(found); return; } let mut cur = node.walk(); for child in node.children(&mut cur) { - walk_routes(child, bytes, target, out); + walk_routes(child, bytes, target, receiver_filter, out); } } @@ -384,6 +472,7 @@ fn try_route_call<'a>( call: Node<'a>, bytes: &'a [u8], target: &str, + receiver_filter: Option<&RouteReceiverFilter<'_>>, ) -> Option<(HttpMethod, String)> { let callee = call.child_by_field_name("function")?; if callee.kind() != "selector_expression" { @@ -391,6 +480,11 @@ fn try_route_call<'a>( } let verb_node = callee.child_by_field_name("field")?.utf8_text(bytes).ok()?; let method = verb_from_method(verb_node)?; + if let Some(filter) = receiver_filter + && !route_receiver_matches(callee, bytes, filter) + { + return None; + } let args = call.child_by_field_name("arguments")?; let positional: Vec> = { let mut cur = args.walk(); @@ -408,6 +502,199 @@ fn try_route_call<'a>( Some((method, path)) } +fn route_receiver_matches( + selector: Node<'_>, + bytes: &[u8], + filter: &RouteReceiverFilter<'_>, +) -> bool { + let Some(receiver) = selector.child_by_field_name("operand") else { + return filter.marker_fallback; + }; + let Ok(expr) = receiver.utf8_text(bytes) else { + return filter.marker_fallback; + }; + receiver_expr_matches_framework(expr.trim(), filter.framework, filter.receivers) + || filter.marker_fallback +} + +fn receiver_expr_matches_framework( + expr: &str, + framework: GoRouteFramework, + receivers: &HashSet, +) -> bool { + let expr = trim_wrapping_parens(expr.trim()); + if receivers.contains(expr) { + return true; + } + if framework + .constructor_markers() + .iter() + .any(|marker| expr.starts_with(marker)) + { + return true; + } + rhs_uses_known_router(expr, framework, receivers) +} + +fn collect_framework_receivers( + root: Node<'_>, + bytes: &[u8], + framework: GoRouteFramework, +) -> HashSet { + let mut receivers = HashSet::new(); + let mut assignment_snippets = Vec::new(); + collect_receiver_snippets( + root, + bytes, + framework, + &mut receivers, + &mut assignment_snippets, + ); + + let mut changed = true; + while changed { + changed = false; + for snippet in &assignment_snippets { + if assignment_rhs_matches_framework(snippet, framework, &receivers) { + for ident in assignment_lhs_identifiers(snippet) { + changed |= receivers.insert(ident); + } + } + } + } + + receivers +} + +fn collect_receiver_snippets( + node: Node<'_>, + bytes: &[u8], + framework: GoRouteFramework, + receivers: &mut HashSet, + assignment_snippets: &mut Vec, +) { + match node.kind() { + "parameter_declaration" | "var_spec" => { + if let Ok(text) = node.utf8_text(bytes) { + for ident in typed_decl_identifiers(text, framework) { + receivers.insert(ident); + } + if node.kind() == "var_spec" { + assignment_snippets.push(text.to_owned()); + } + } + } + "short_var_declaration" | "assignment_statement" => { + if let Ok(text) = node.utf8_text(bytes) { + assignment_snippets.push(text.to_owned()); + } + } + _ => {} + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + collect_receiver_snippets(child, bytes, framework, receivers, assignment_snippets); + } +} + +fn typed_decl_identifiers(text: &str, framework: GoRouteFramework) -> Vec { + let Some(marker_pos) = framework + .type_markers() + .iter() + .filter_map(|marker| text.find(marker)) + .min() + else { + return Vec::new(); + }; + identifiers_from_list(strip_var_prefix(&text[..marker_pos])) +} + +fn assignment_rhs_matches_framework( + text: &str, + framework: GoRouteFramework, + receivers: &HashSet, +) -> bool { + let Some((_, rhs)) = split_assignment(text) else { + return false; + }; + let rhs = rhs.trim(); + framework + .constructor_markers() + .iter() + .any(|marker| rhs.contains(marker)) + || rhs_uses_known_router(rhs, framework, receivers) +} + +fn assignment_lhs_identifiers(text: &str) -> Vec { + let Some((lhs, _)) = split_assignment(text) else { + return Vec::new(); + }; + identifiers_from_list(strip_var_prefix(lhs)) +} + +fn split_assignment(text: &str) -> Option<(&str, &str)> { + text.split_once(":=").or_else(|| text.split_once('=')) +} + +fn strip_var_prefix(text: &str) -> &str { + let text = text.trim(); + text.strip_prefix("var ").unwrap_or(text).trim() +} + +fn rhs_uses_known_router( + rhs: &str, + framework: GoRouteFramework, + receivers: &HashSet, +) -> bool { + let rhs = trim_wrapping_parens(rhs.trim()); + for receiver in receivers { + let Some(rest) = rhs.strip_prefix(receiver).and_then(|s| s.strip_prefix('.')) else { + continue; + }; + if framework + .grouping_methods() + .iter() + .any(|method| rest.starts_with(&format!("{method}("))) + { + return true; + } + } + false +} + +fn identifiers_from_list(text: &str) -> Vec { + text.split(',') + .filter_map(|part| { + let token = part.split_whitespace().next()?.trim(); + if is_go_identifier(token) { + Some(token.to_owned()) + } else { + None + } + }) + .collect() +} + +fn is_go_identifier(s: &str) -> bool { + let mut chars = s.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +fn trim_wrapping_parens(mut s: &str) -> &str { + loop { + let trimmed = s.trim(); + if trimmed.starts_with('(') && trimmed.ends_with(')') && trimmed.len() > 2 { + s = &trimmed[1..trimmed.len() - 1]; + } else { + return trimmed; + } + } +} + /// Read a Go interpreted_string_literal's content, dropping the /// surrounding `"` quotes. Returns `None` if `node` is not a string /// literal. @@ -527,6 +814,67 @@ mod tests { assert_eq!(path, "/x"); } + #[test] + fn receiver_aware_route_accepts_framework_constructor_receiver() { + let src: &[u8] = + b"package main\nfunc init() { r := gin.New(); r.GET(\"/x\", Show) }\nfunc Show(c interface{}) {}\n"; + let tree = parse(src); + let (method, path) = find_route_for_callee_in_framework( + tree.root_node(), + src, + "Show", + GoRouteFramework::Gin, + ) + .expect("hit"); + assert_eq!(method, HttpMethod::GET); + assert_eq!(path, "/x"); + } + + #[test] + fn receiver_aware_route_rejects_cache_get_collision() { + let src: &[u8] = b"package main\nimport \"github.com/gin-gonic/gin\"\n\ + func init() { r := gin.New(); _ = r; cache.Get(\"/x\", Show) }\n\ + func Show(c interface{}) {}\n"; + let tree = parse(src); + assert!( + find_route_for_callee_in_framework( + tree.root_node(), + src, + "Show", + GoRouteFramework::Gin, + ) + .is_none() + ); + } + + #[test] + fn receiver_aware_route_accepts_typed_param_receiver() { + let src: &[u8] = b"package main\nfunc register(r *gin.Engine) { r.GET(\"/x\", Show) }\nfunc Show(c interface{}) {}\n"; + let tree = parse(src); + let (_, path) = find_route_for_callee_in_framework( + tree.root_node(), + src, + "Show", + GoRouteFramework::Gin, + ) + .expect("hit"); + assert_eq!(path, "/x"); + } + + #[test] + fn receiver_aware_route_accepts_group_receiver_assignment() { + let src: &[u8] = b"package main\nfunc init() { r := chi.NewRouter(); auth := r.With(AuthMiddleware); auth.Get(\"/x\", Show) }\nfunc Show(w interface{}, r interface{}) {}\n"; + let tree = parse(src); + let (_, path) = find_route_for_callee_in_framework( + tree.root_node(), + src, + "Show", + GoRouteFramework::Chi, + ) + .expect("hit"); + assert_eq!(path, "/x"); + } + #[test] fn formal_names_skip_types() { let src: &[u8] = b"package main\nfunc Show(c *gin.Context, id string) {}\n"; diff --git a/src/dynamic/framework/adapters/rust_actix.rs b/src/dynamic/framework/adapters/rust_actix.rs index 7d8f02ba..9102adb8 100644 --- a/src/dynamic/framework/adapters/rust_actix.rs +++ b/src/dynamic/framework/adapters/rust_actix.rs @@ -19,8 +19,9 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::rust_routes::{ - bind_rust_path_params, collect_rust_middleware, find_actix_route_chain, find_method_attribute, - find_rust_function, rust_formal_names, source_imports_actix, + RustRouteAttributeFramework, bind_rust_path_params, collect_rust_middleware, + find_actix_route_chain, find_method_attribute_for_framework, find_rust_function, + rust_formal_names, source_imports_actix, }; pub struct RustActixAdapter; @@ -46,8 +47,12 @@ 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) - .or_else(|| find_actix_route_chain(ast, file_bytes, &summary.name))?; + let (method, path) = find_method_attribute_for_framework( + func, + file_bytes, + RustRouteAttributeFramework::Actix, + ) + .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); let middleware = collect_rust_middleware(ast, file_bytes); @@ -122,6 +127,27 @@ mod tests { ); } + #[test] + fn skips_rocket_get_macro_in_actix_file() { + let src: &[u8] = b"use actix_web::HttpResponse;\nuse rocket::get;\n#[get(\"/u\")]\nasync fn show() -> HttpResponse { HttpResponse::Ok().finish() }\n"; + let tree = parse(src); + assert!( + RustActixAdapter + .detect(&summary("show"), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn accepts_scoped_actix_get_macro() { + let src: &[u8] = b"use actix_web::HttpResponse;\n#[actix_web::get(\"/u\")]\nasync fn show() -> HttpResponse { HttpResponse::Ok().finish() }\n"; + let tree = parse(src); + let binding = RustActixAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().path, "/u"); + } + #[test] fn skips_when_attribute_missing() { let src: &[u8] = b"use actix_web::App;\nfn helper(x: String) {}\n"; diff --git a/src/dynamic/framework/adapters/rust_rocket.rs b/src/dynamic/framework/adapters/rust_rocket.rs index 92c9a394..9002155b 100644 --- a/src/dynamic/framework/adapters/rust_rocket.rs +++ b/src/dynamic/framework/adapters/rust_rocket.rs @@ -23,8 +23,9 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::rust_routes::{ - bind_rust_path_params, collect_rust_middleware, find_method_attribute, find_rust_function, - rust_formal_names, source_imports_rocket, + RustRouteAttributeFramework, bind_rust_path_params, collect_rust_middleware, + find_method_attribute_for_framework, find_rust_function, rust_formal_names, + source_imports_rocket, }; pub struct RustRocketAdapter; @@ -50,7 +51,11 @@ impl FrameworkAdapter for RustRocketAdapter { 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_for_framework( + func, + file_bytes, + RustRouteAttributeFramework::Rocket, + )?; let formals = rust_formal_names(func, file_bytes); let request_params = bind_rust_path_params(&formals, &path); let middleware = collect_rust_middleware(ast, file_bytes); @@ -138,4 +143,26 @@ mod tests { .is_none() ); } + + #[test] + fn skips_actix_get_macro_in_rocket_file() { + let src: &[u8] = b"use rocket::routes;\nuse actix_web::get;\n#[get(\"/u\")]\nfn show() -> &'static str { \"ok\" }\n"; + let tree = parse(src); + assert!( + RustRocketAdapter + .detect(&summary("show"), tree.root_node(), src) + .is_none() + ); + } + + #[test] + fn accepts_scoped_rocket_get_macro() { + let src: &[u8] = + b"use rocket::routes;\n#[rocket::get(\"/u\")]\nfn show() -> &'static str { \"ok\" }\n"; + let tree = parse(src); + let binding = RustRocketAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().path, "/u"); + } } diff --git a/src/dynamic/framework/adapters/rust_routes.rs b/src/dynamic/framework/adapters/rust_routes.rs index 523ff9cd..b5f3ad58 100644 --- a/src/dynamic/framework/adapters/rust_routes.rs +++ b/src/dynamic/framework/adapters/rust_routes.rs @@ -289,6 +289,36 @@ pub fn verb_from_ident(ident: &str) -> Option { } } +/// Framework that owns a bare or scoped Rust route attribute macro. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RustRouteAttributeFramework { + Actix, + Rocket, +} + +impl RustRouteAttributeFramework { + fn scoped_prefix(self) -> &'static str { + match self { + Self::Actix => "actix_web::", + Self::Rocket => "rocket::", + } + } + + fn marker_comment(self) -> &'static str { + match self { + Self::Actix => "// nyx-shape: actix", + Self::Rocket => "// nyx-shape: rocket", + } + } + + fn import_roots(self) -> &'static [&'static str] { + match self { + Self::Actix => &["use actix_web::"], + Self::Rocket => &["use rocket::", "#[macro_use] extern crate rocket"], + } + } +} + /// Walk every method-chain call in the file whose field name is one /// of the known middleware-attach verbs and collect argument /// expressions whose names match a known Rust middleware marker (see @@ -461,6 +491,28 @@ pub fn rust_string_literal(node: Node<'_>, bytes: &[u8]) -> Option { /// Returns `(method, path)` on first match. Used by both actix-web /// (`#[get("/path")]`) and rocket (same syntax). pub fn find_method_attribute<'a>(func: Node<'a>, bytes: &'a [u8]) -> Option<(HttpMethod, String)> { + find_method_attribute_inner(func, bytes, None) +} + +/// Framework-aware sibling of [`find_method_attribute`]. +/// +/// Actix and Rocket share bare `#[get("/x")]` / `#[post("/x")]` +/// macro names. This variant rejects a bare attribute unless the +/// source imports the matching framework's macro, and it rejects a +/// scoped attribute unless the scope belongs to that framework. +pub fn find_method_attribute_for_framework<'a>( + func: Node<'a>, + bytes: &'a [u8], + framework: RustRouteAttributeFramework, +) -> Option<(HttpMethod, String)> { + find_method_attribute_inner(func, bytes, Some(framework)) +} + +fn find_method_attribute_inner<'a>( + func: Node<'a>, + bytes: &'a [u8], + framework: Option, +) -> Option<(HttpMethod, String)> { let parent = func.parent()?; let mut cur = parent.walk(); let children: Vec> = parent.children(&mut cur).collect(); @@ -469,7 +521,7 @@ pub fn find_method_attribute<'a>(func: Node<'a>, bytes: &'a [u8]) -> Option<(Htt // function declaration. for child in children[..pos].iter().rev() { if child.kind() == "attribute_item" { - if let Some(hit) = read_route_attribute(*child, bytes) { + if let Some(hit) = read_route_attribute(*child, bytes, framework) { return Some(hit); } continue; @@ -492,7 +544,7 @@ pub fn find_method_attribute<'a>(func: Node<'a>, bytes: &'a [u8]) -> Option<(Htt let mut cur = func.walk(); for c in func.children(&mut cur) { if c.kind() == "attribute_item" - && let Some(hit) = read_route_attribute(c, bytes) + && let Some(hit) = read_route_attribute(c, bytes, framework) { return Some(hit); } @@ -500,7 +552,11 @@ pub fn find_method_attribute<'a>(func: Node<'a>, bytes: &'a [u8]) -> Option<(Htt None } -fn read_route_attribute(attr: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, String)> { +fn read_route_attribute( + attr: Node<'_>, + bytes: &[u8], + framework: Option, +) -> Option<(HttpMethod, String)> { let mut cur = attr.walk(); let attribute = attr .named_children(&mut cur) @@ -513,11 +569,13 @@ fn read_route_attribute(attr: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, Str let mut ac = attribute.walk(); let children: Vec> = attribute.named_children(&mut ac).collect(); let head = children.first()?; - let verb_text = match head.kind() { - "identifier" => head.utf8_text(bytes).ok()?.to_owned(), + let (verb_text, scoped_head) = match head.kind() { + "identifier" => (head.utf8_text(bytes).ok()?.to_owned(), None), "scoped_identifier" => { + let full = head.utf8_text(bytes).ok()?.to_owned(); let mut sc = head.walk(); - head.named_children(&mut sc) + let leaf = head + .named_children(&mut sc) .filter_map(|c| { if c.kind() == "identifier" { c.utf8_text(bytes).ok() @@ -526,11 +584,15 @@ fn read_route_attribute(attr: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, Str } }) .last()? - .to_owned() + .to_owned(); + (leaf, Some(full)) } _ => return None, }; let method = verb_from_ident(&verb_text)?; + if !route_attribute_belongs_to_framework(&verb_text, scoped_head.as_deref(), bytes, framework) { + return None; + } for child in &children[1..] { if child.kind() == "token_tree" { // Recurse to find the first string_literal under the @@ -547,6 +609,64 @@ fn read_route_attribute(attr: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, Str None } +fn route_attribute_belongs_to_framework( + verb: &str, + scoped_head: Option<&str>, + bytes: &[u8], + framework: Option, +) -> bool { + let Some(framework) = framework else { + return true; + }; + if let Some(head) = scoped_head { + return head.starts_with(framework.scoped_prefix()); + } + bare_route_attribute_imported_from_framework(bytes, verb, framework) +} + +fn bare_route_attribute_imported_from_framework( + bytes: &[u8], + verb: &str, + framework: RustRouteAttributeFramework, +) -> bool { + let Ok(source) = std::str::from_utf8(bytes) else { + return false; + }; + if source.contains(framework.marker_comment()) { + return true; + } + for line in source.lines().map(str::trim) { + for root in framework.import_roots() { + if *root == "#[macro_use] extern crate rocket" { + if line.contains(root) { + return true; + } + continue; + } + if !line.contains(root) { + continue; + } + if line.contains(&format!("{root}{verb};")) + || line.contains(&format!("{root}{verb} as ")) + { + return true; + } + if let Some((_, imports)) = line.split_once('{') { + let imports = imports.split('}').next().unwrap_or(imports); + if imports + .split(',') + .map(str::trim) + .filter_map(|part| part.split_ascii_whitespace().next()) + .any(|name| name == verb) + { + return true; + } + } + } + } + false +} + fn first_string_in(node: Node<'_>, bytes: &[u8]) -> Option { if let Some(literal) = rust_string_literal(node, bytes) { return Some(literal); diff --git a/tests/go_frameworks_corpus.rs b/tests/go_frameworks_corpus.rs index 5dcddcb3..3895f436 100644 --- a/tests/go_frameworks_corpus.rs +++ b/tests/go_frameworks_corpus.rs @@ -128,3 +128,17 @@ fn gin_adapter_ignores_unrelated_function() { let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Go); assert!(binding.is_none()); } + +#[test] +fn gin_adapter_rejects_cache_get_receiver_collision() { + let src: &[u8] = b"package main\nimport \"github.com/gin-gonic/gin\"\n\ + func init() { r := gin.New(); _ = r; cache.Get(\"/run\", Run) }\n\ + func Run(c interface{}) {}\n"; + let tree = parse_go(src); + let summary = summary_for("Run", "synthetic/gin_cache_collision.go"); + let binding = detect_binding(&summary, tree.root_node(), src, Lang::Go); + assert!( + binding.is_none(), + "cache.Get must not be treated as a gin route registration" + ); +}