mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-12 21:02:11 +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 tree_sitter::Node;
|
||||||
|
|
||||||
use super::go_routes::{
|
use super::go_routes::{
|
||||||
bind_go_path_params, collect_use_middleware, find_go_function, find_route_for_callee,
|
GoRouteFramework, bind_go_path_params, collect_use_middleware, find_go_function,
|
||||||
go_formal_names, source_imports_chi,
|
find_route_for_callee_in_framework, go_formal_names, source_imports_chi,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct GoChiAdapter;
|
pub struct GoChiAdapter;
|
||||||
|
|
@ -45,7 +45,12 @@ impl FrameworkAdapter for GoChiAdapter {
|
||||||
if !source_imports_chi(file_bytes) {
|
if !source_imports_chi(file_bytes) {
|
||||||
return None;
|
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)
|
let request_params = find_go_function(ast, file_bytes, &summary.name)
|
||||||
.map(|func| {
|
.map(|func| {
|
||||||
let formals = go_formal_names(func, file_bytes);
|
let formals = go_formal_names(func, file_bytes);
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::go_routes::{
|
use super::go_routes::{
|
||||||
bind_go_path_params, collect_use_middleware, find_go_function, find_route_for_callee,
|
GoRouteFramework, bind_go_path_params, collect_use_middleware, find_go_function,
|
||||||
go_formal_names, source_imports_echo,
|
find_route_for_callee_in_framework, go_formal_names, source_imports_echo,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct GoEchoAdapter;
|
pub struct GoEchoAdapter;
|
||||||
|
|
@ -45,7 +45,12 @@ impl FrameworkAdapter for GoEchoAdapter {
|
||||||
if !source_imports_echo(file_bytes) {
|
if !source_imports_echo(file_bytes) {
|
||||||
return None;
|
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)
|
let request_params = find_go_function(ast, file_bytes, &summary.name)
|
||||||
.map(|func| {
|
.map(|func| {
|
||||||
let formals = go_formal_names(func, file_bytes);
|
let formals = go_formal_names(func, file_bytes);
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::go_routes::{
|
use super::go_routes::{
|
||||||
bind_go_path_params, collect_use_middleware, find_go_function, find_route_for_callee,
|
GoRouteFramework, bind_go_path_params, collect_use_middleware, find_go_function,
|
||||||
go_formal_names, source_imports_fiber,
|
find_route_for_callee_in_framework, go_formal_names, source_imports_fiber,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct GoFiberAdapter;
|
pub struct GoFiberAdapter;
|
||||||
|
|
@ -46,7 +46,12 @@ impl FrameworkAdapter for GoFiberAdapter {
|
||||||
if !source_imports_fiber(file_bytes) {
|
if !source_imports_fiber(file_bytes) {
|
||||||
return None;
|
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)
|
let request_params = find_go_function(ast, file_bytes, &summary.name)
|
||||||
.map(|func| {
|
.map(|func| {
|
||||||
let formals = go_formal_names(func, file_bytes);
|
let formals = go_formal_names(func, file_bytes);
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ use crate::symbol::Lang;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
use super::go_routes::{
|
use super::go_routes::{
|
||||||
bind_go_path_params, collect_use_middleware, find_go_function, find_route_for_callee,
|
GoRouteFramework, bind_go_path_params, collect_use_middleware, find_go_function,
|
||||||
go_formal_names, source_imports_gin,
|
find_route_for_callee_in_framework, go_formal_names, source_imports_gin,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct GoGinAdapter;
|
pub struct GoGinAdapter;
|
||||||
|
|
@ -48,7 +48,12 @@ impl FrameworkAdapter for GoGinAdapter {
|
||||||
if !source_imports_gin(file_bytes) {
|
if !source_imports_gin(file_bytes) {
|
||||||
return None;
|
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)
|
let request_params = find_go_function(ast, file_bytes, &summary.name)
|
||||||
.map(|func| {
|
.map(|func| {
|
||||||
let formals = go_formal_names(func, file_bytes);
|
let formals = go_formal_names(func, file_bytes);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
use crate::dynamic::framework::auth_markers;
|
use crate::dynamic::framework::auth_markers;
|
||||||
use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource};
|
use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource};
|
||||||
use crate::symbol::Lang;
|
use crate::symbol::Lang;
|
||||||
|
use std::collections::HashSet;
|
||||||
use tree_sitter::Node;
|
use tree_sitter::Node;
|
||||||
|
|
||||||
/// True when `bytes` carries any of the well-known gin markers.
|
/// 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")
|
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`,
|
/// Parse Go verb-method names: `GET`, `POST`, `PUT`, `PATCH`,
|
||||||
/// `DELETE`, `HEAD`, `OPTIONS` (case-insensitive — gin uses upper,
|
/// `DELETE`, `HEAD`, `OPTIONS` (case-insensitive — gin uses upper,
|
||||||
/// echo / chi use upper, fiber uses pascal-cased like `Get`,
|
/// echo / chi use upper, fiber uses pascal-cased like `Get`,
|
||||||
|
|
@ -355,28 +407,64 @@ pub fn find_route_for_callee<'a>(
|
||||||
target: &str,
|
target: &str,
|
||||||
) -> Option<(HttpMethod, String)> {
|
) -> Option<(HttpMethod, String)> {
|
||||||
let mut hit: Option<(HttpMethod, String)> = None;
|
let mut hit: Option<(HttpMethod, String)> = None;
|
||||||
walk_routes(root, bytes, target, &mut hit);
|
walk_routes(root, bytes, target, None, &mut hit);
|
||||||
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>(
|
fn walk_routes<'a>(
|
||||||
node: Node<'a>,
|
node: Node<'a>,
|
||||||
bytes: &'a [u8],
|
bytes: &'a [u8],
|
||||||
target: &str,
|
target: &str,
|
||||||
|
receiver_filter: Option<&RouteReceiverFilter<'_>>,
|
||||||
out: &mut Option<(HttpMethod, String)>,
|
out: &mut Option<(HttpMethod, String)>,
|
||||||
) {
|
) {
|
||||||
if out.is_some() {
|
if out.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if node.kind() == "call_expression"
|
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);
|
*out = Some(found);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut cur = node.walk();
|
let mut cur = node.walk();
|
||||||
for child in node.children(&mut cur) {
|
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>,
|
call: Node<'a>,
|
||||||
bytes: &'a [u8],
|
bytes: &'a [u8],
|
||||||
target: &str,
|
target: &str,
|
||||||
|
receiver_filter: Option<&RouteReceiverFilter<'_>>,
|
||||||
) -> Option<(HttpMethod, String)> {
|
) -> Option<(HttpMethod, String)> {
|
||||||
let callee = call.child_by_field_name("function")?;
|
let callee = call.child_by_field_name("function")?;
|
||||||
if callee.kind() != "selector_expression" {
|
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 verb_node = callee.child_by_field_name("field")?.utf8_text(bytes).ok()?;
|
||||||
let method = verb_from_method(verb_node)?;
|
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 args = call.child_by_field_name("arguments")?;
|
||||||
let positional: Vec<Node<'_>> = {
|
let positional: Vec<Node<'_>> = {
|
||||||
let mut cur = args.walk();
|
let mut cur = args.walk();
|
||||||
|
|
@ -408,6 +502,199 @@ fn try_route_call<'a>(
|
||||||
Some((method, path))
|
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
|
/// Read a Go interpreted_string_literal's content, dropping the
|
||||||
/// surrounding `"` quotes. Returns `None` if `node` is not a string
|
/// surrounding `"` quotes. Returns `None` if `node` is not a string
|
||||||
/// literal.
|
/// literal.
|
||||||
|
|
@ -527,6 +814,67 @@ mod tests {
|
||||||
assert_eq!(path, "/x");
|
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]
|
#[test]
|
||||||
fn formal_names_skip_types() {
|
fn formal_names_skip_types() {
|
||||||
let src: &[u8] = b"package main\nfunc Show(c *gin.Context, id string) {}\n";
|
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 tree_sitter::Node;
|
||||||
|
|
||||||
use super::rust_routes::{
|
use super::rust_routes::{
|
||||||
bind_rust_path_params, collect_rust_middleware, find_actix_route_chain, find_method_attribute,
|
RustRouteAttributeFramework, bind_rust_path_params, collect_rust_middleware,
|
||||||
find_rust_function, rust_formal_names, source_imports_actix,
|
find_actix_route_chain, find_method_attribute_for_framework, find_rust_function,
|
||||||
|
rust_formal_names, source_imports_actix,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct RustActixAdapter;
|
pub struct RustActixAdapter;
|
||||||
|
|
@ -46,8 +47,12 @@ impl FrameworkAdapter for RustActixAdapter {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let func = find_rust_function(ast, file_bytes, &summary.name)?;
|
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(
|
||||||
.or_else(|| find_actix_route_chain(ast, file_bytes, &summary.name))?;
|
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 formals = rust_formal_names(func, file_bytes);
|
||||||
let request_params = bind_rust_path_params(&formals, &path);
|
let request_params = bind_rust_path_params(&formals, &path);
|
||||||
let middleware = collect_rust_middleware(ast, file_bytes);
|
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]
|
#[test]
|
||||||
fn skips_when_attribute_missing() {
|
fn skips_when_attribute_missing() {
|
||||||
let src: &[u8] = b"use actix_web::App;\nfn helper(x: String) {}\n";
|
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 tree_sitter::Node;
|
||||||
|
|
||||||
use super::rust_routes::{
|
use super::rust_routes::{
|
||||||
bind_rust_path_params, collect_rust_middleware, find_method_attribute, find_rust_function,
|
RustRouteAttributeFramework, bind_rust_path_params, collect_rust_middleware,
|
||||||
rust_formal_names, source_imports_rocket,
|
find_method_attribute_for_framework, find_rust_function, rust_formal_names,
|
||||||
|
source_imports_rocket,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct RustRocketAdapter;
|
pub struct RustRocketAdapter;
|
||||||
|
|
@ -50,7 +51,11 @@ impl FrameworkAdapter for RustRocketAdapter {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let func = find_rust_function(ast, file_bytes, &summary.name)?;
|
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 formals = rust_formal_names(func, file_bytes);
|
||||||
let request_params = bind_rust_path_params(&formals, &path);
|
let request_params = bind_rust_path_params(&formals, &path);
|
||||||
let middleware = collect_rust_middleware(ast, file_bytes);
|
let middleware = collect_rust_middleware(ast, file_bytes);
|
||||||
|
|
@ -138,4 +143,26 @@ mod tests {
|
||||||
.is_none()
|
.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
|
/// Walk every method-chain call in the file whose field name is one
|
||||||
/// of the known middleware-attach verbs and collect argument
|
/// of the known middleware-attach verbs and collect argument
|
||||||
/// expressions whose names match a known Rust middleware marker (see
|
/// 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
|
/// Returns `(method, path)` on first match. Used by both actix-web
|
||||||
/// (`#[get("/path")]`) and rocket (same syntax).
|
/// (`#[get("/path")]`) and rocket (same syntax).
|
||||||
pub fn find_method_attribute<'a>(func: Node<'a>, bytes: &'a [u8]) -> Option<(HttpMethod, String)> {
|
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 parent = func.parent()?;
|
||||||
let mut cur = parent.walk();
|
let mut cur = parent.walk();
|
||||||
let children: Vec<Node<'_>> = parent.children(&mut cur).collect();
|
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.
|
// function declaration.
|
||||||
for child in children[..pos].iter().rev() {
|
for child in children[..pos].iter().rev() {
|
||||||
if child.kind() == "attribute_item" {
|
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);
|
return Some(hit);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -492,7 +544,7 @@ pub fn find_method_attribute<'a>(func: Node<'a>, bytes: &'a [u8]) -> Option<(Htt
|
||||||
let mut cur = func.walk();
|
let mut cur = func.walk();
|
||||||
for c in func.children(&mut cur) {
|
for c in func.children(&mut cur) {
|
||||||
if c.kind() == "attribute_item"
|
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);
|
return Some(hit);
|
||||||
}
|
}
|
||||||
|
|
@ -500,7 +552,11 @@ pub fn find_method_attribute<'a>(func: Node<'a>, bytes: &'a [u8]) -> Option<(Htt
|
||||||
None
|
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 mut cur = attr.walk();
|
||||||
let attribute = attr
|
let attribute = attr
|
||||||
.named_children(&mut cur)
|
.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 mut ac = attribute.walk();
|
||||||
let children: Vec<Node<'_>> = attribute.named_children(&mut ac).collect();
|
let children: Vec<Node<'_>> = attribute.named_children(&mut ac).collect();
|
||||||
let head = children.first()?;
|
let head = children.first()?;
|
||||||
let verb_text = match head.kind() {
|
let (verb_text, scoped_head) = match head.kind() {
|
||||||
"identifier" => head.utf8_text(bytes).ok()?.to_owned(),
|
"identifier" => (head.utf8_text(bytes).ok()?.to_owned(), None),
|
||||||
"scoped_identifier" => {
|
"scoped_identifier" => {
|
||||||
|
let full = head.utf8_text(bytes).ok()?.to_owned();
|
||||||
let mut sc = head.walk();
|
let mut sc = head.walk();
|
||||||
head.named_children(&mut sc)
|
let leaf = head
|
||||||
|
.named_children(&mut sc)
|
||||||
.filter_map(|c| {
|
.filter_map(|c| {
|
||||||
if c.kind() == "identifier" {
|
if c.kind() == "identifier" {
|
||||||
c.utf8_text(bytes).ok()
|
c.utf8_text(bytes).ok()
|
||||||
|
|
@ -526,11 +584,15 @@ fn read_route_attribute(attr: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, Str
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.last()?
|
.last()?
|
||||||
.to_owned()
|
.to_owned();
|
||||||
|
(leaf, Some(full))
|
||||||
}
|
}
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let method = verb_from_ident(&verb_text)?;
|
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..] {
|
for child in &children[1..] {
|
||||||
if child.kind() == "token_tree" {
|
if child.kind() == "token_tree" {
|
||||||
// Recurse to find the first string_literal under the
|
// 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
|
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> {
|
fn first_string_in(node: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||||
if let Some(literal) = rust_string_literal(node, bytes) {
|
if let Some(literal) = rust_string_literal(node, bytes) {
|
||||||
return Some(literal);
|
return Some(literal);
|
||||||
|
|
|
||||||
|
|
@ -128,3 +128,17 @@ fn gin_adapter_ignores_unrelated_function() {
|
||||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Go);
|
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Go);
|
||||||
assert!(binding.is_none());
|
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