mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(dynamic): add framework-aware route detection, improve Rust/Go handler resolution, and expand tests
This commit is contained in:
parent
baa9a36bc6
commit
43ab4aa469
9 changed files with 584 additions and 29 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<Node<'_>> = {
|
||||
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<String>,
|
||||
) -> 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<String> {
|
||||
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<String>,
|
||||
assignment_snippets: &mut Vec<String>,
|
||||
) {
|
||||
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<String> {
|
||||
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<String>,
|
||||
) -> 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<String> {
|
||||
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<String>,
|
||||
) -> 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<String> {
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,6 +289,36 @@ pub fn verb_from_ident(ident: &str) -> Option<HttpMethod> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<String> {
|
|||
/// 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<RustRouteAttributeFramework>,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let parent = func.parent()?;
|
||||
let mut cur = parent.walk();
|
||||
let children: Vec<Node<'_>> = 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<RustRouteAttributeFramework>,
|
||||
) -> 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<Node<'_>> = 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<RustRouteAttributeFramework>,
|
||||
) -> 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<String> {
|
||||
if let Some(literal) = rust_string_literal(node, bytes) {
|
||||
return Some(literal);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue