//! Shared Go-route adapter helpers (Phase 17 — Track L.15). //! //! The gin / echo / fiber / chi adapters all need the same handful //! of tree-sitter helpers: locate a `func` declaration by name, //! enumerate formal parameter names, walk the file looking for a //! `engine.GET("/path", handler)` / `router.Post("/x", handler)` call //! whose callable references a target function name, parse a path //! template into placeholder names, and bind formals to request //! slots. Centralising the helpers here keeps the four adapters //! terse and lets every framework share the same placeholder-binding //! semantics. //! //! Path placeholder vocabulary: //! - gin / echo / chi use `:id` and (chi) `{id}` interchangeably. //! - fiber uses `:id` and `+` / `*` greedy wildcards. //! //! [`extract_go_path_placeholders`] supports both syntaxes. use crate::dynamic::framework::auth_markers; use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource}; use crate::symbol::Lang; use tree_sitter::Node; /// True when `bytes` carries any of the well-known gin markers. pub fn source_imports_gin(bytes: &[u8]) -> bool { contains_any( bytes, &[ b"github.com/gin-gonic/gin", b"gin.Engine", b"gin.Default", b"gin.New", b"// nyx-shape: gin", ], ) } /// True when `bytes` carries any of the well-known echo markers. pub fn source_imports_echo(bytes: &[u8]) -> bool { contains_any( bytes, &[ b"github.com/labstack/echo", b"echo.Echo", b"echo.New", b"echo.Context", b"// nyx-shape: echo", ], ) } /// True when `bytes` carries any of the well-known fiber markers. pub fn source_imports_fiber(bytes: &[u8]) -> bool { contains_any( bytes, &[ b"github.com/gofiber/fiber", b"fiber.App", b"fiber.New", b"fiber.Ctx", b"// nyx-shape: fiber", ], ) } /// True when `bytes` carries any of the well-known chi markers. pub fn source_imports_chi(bytes: &[u8]) -> bool { contains_any( bytes, &[ b"github.com/go-chi/chi", b"chi.NewRouter", b"chi.Mux", b"chi.Router", b"// nyx-shape: chi", ], ) } fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool { needles .iter() .any(|n| haystack.windows(n.len()).any(|w| w == *n)) } /// Find a top-level `function_declaration` or a `method_declaration` /// whose name equals `target`. Returns the matching node. pub fn find_go_function<'a>(root: Node<'a>, bytes: &'a [u8], target: &str) -> Option> { let mut hit: Option> = None; walk_go(root, bytes, target, &mut hit); hit } fn walk_go<'a>(node: Node<'a>, bytes: &'a [u8], target: &str, out: &mut Option>) { if out.is_some() { return; } match node.kind() { "function_declaration" | "method_declaration" => { if let Some(name) = node.child_by_field_name("name") && let Ok(text) = name.utf8_text(bytes) && text == target { *out = Some(node); return; } } _ => {} } let mut cur = node.walk(); for child in node.children(&mut cur) { walk_go(child, bytes, target, out); } } /// Read formal parameter names from a `function_declaration` / /// `method_declaration` / `func_literal`. Drops the receiver /// parameter of a method (it is not part of the request surface). pub fn go_formal_names(func: Node<'_>, bytes: &[u8]) -> Vec { let mut out: Vec = Vec::new(); let Some(params) = func.child_by_field_name("parameters") else { return out; }; let mut cur = params.walk(); for p in params.named_children(&mut cur) { if p.kind() != "parameter_declaration" { continue; } let mut pc = p.walk(); for c in p.named_children(&mut pc) { if c.kind() == "identifier" && let Ok(text) = c.utf8_text(bytes) { out.push(text.to_owned()); } } } out } /// Extract placeholder names from a Go route path template. /// /// Supports: /// - gin / echo / fiber `:id` style: `/u/:id` → `id` /// - chi `{id}` style: `/u/{id}` → `id` /// - fiber `+` greedy: `/files/+rest` → `rest` /// - fiber/chi `*` wildcard: `/files/*rest` → `rest` pub fn extract_go_path_placeholders(path: &str) -> Vec { let mut out: Vec = Vec::new(); let mut push = |name: String| { if !name.is_empty() && !out.iter().any(|n| n == &name) { out.push(name); } }; let bytes = path.as_bytes(); let mut i = 0; while i < bytes.len() { match bytes[i] { b':' => { let start = i + 1; let mut j = start; while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { j += 1; } if j > start { push(path[start..j].to_owned()); i = j; continue; } } b'{' => { if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') { let inner = &path[i + 1..i + 1 + end]; let name = inner.split(':').next().unwrap_or(inner); push(name.to_owned()); i += end + 2; continue; } } b'*' | b'+' => { let start = i + 1; let mut j = start; while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { j += 1; } if j > start { push(path[start..j].to_owned()); i = j; continue; } } _ => {} } i += 1; } out } /// Bind formals to request slots given a Go route path template. /// /// `c` / `ctx` / `w` / `r` formals become [`ParamSource::Implicit`] /// (the framework context object or `http.ResponseWriter` / /// `*http.Request` pair). Names matching the path placeholder list /// become [`ParamSource::PathSegment`]. Every other formal falls /// back to a [`ParamSource::QueryParam`] of the same name. pub fn bind_go_path_params(formals: &[String], path: &str) -> Vec { let placeholders = extract_go_path_placeholders(path); formals .iter() .enumerate() .map(|(idx, name)| { let source = if is_implicit_formal(name) { ParamSource::Implicit } else if placeholders.iter().any(|p| p == name) { ParamSource::PathSegment(name.clone()) } else { ParamSource::QueryParam(name.clone()) }; ParamBinding { index: idx, name: name.clone(), source, } }) .collect() } fn is_implicit_formal(name: &str) -> bool { matches!(name, "c" | "ctx" | "w" | "r" | "req" | "res" | "rw") } /// 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`, /// `Post`). Returns `None` for unrelated identifiers. pub fn verb_from_method(method: &str) -> Option { let upper = method.to_ascii_uppercase(); match upper.as_str() { "GET" => Some(HttpMethod::GET), "POST" => Some(HttpMethod::POST), "PUT" => Some(HttpMethod::PUT), "PATCH" => Some(HttpMethod::PATCH), "DELETE" => Some(HttpMethod::DELETE), "HEAD" => Some(HttpMethod::HEAD), "OPTIONS" => Some(HttpMethod::OPTIONS), _ => None, } } /// Walk every `receiver.Use(...)` / `receiver.With(...)` call in the /// file and collect the argument expressions whose names match a /// known Go middleware marker (see /// [`crate::dynamic::framework::auth_markers::is_protective`]). /// /// gin / echo / fiber attach middleware via `r.Use(mw1, mw2, ...)`; /// chi attaches middleware inline via `r.Use(...)` or /// `r.With(...).Get(...)`. Both verbs are accepted; mid-chain /// `.With(...)` calls that follow a verb-method call (`.Get(...)` /// returns no router so chained `.With` is impossible) are /// conservatively still collected because tree-sitter has already /// flattened the chain into the same `call_expression` shape. /// /// Argument rendering: /// - bare identifier (`r.Use(authMiddleware)`) → `"authMiddleware"` /// - selector expression (`r.Use(middleware.JWT)`) → /// `"middleware.JWT"` /// - call expression (`r.Use(csrf.New(secret))`) → `"csrf.New"` /// (callee text, args dropped — the auth-markers table is keyed /// on the factory function, not the constructed instance) /// /// De-duplicates within a single file; preserves declaration order. /// Names the registry does not recognise are dropped silently — /// callers can re-walk with a wider predicate if they need broader /// inclusion. pub fn collect_use_middleware(root: Node<'_>, bytes: &[u8]) -> Vec { let mut raw: Vec = Vec::new(); walk_use_calls(root, bytes, &mut raw); let mut out: Vec = Vec::new(); for name in raw { if auth_markers::is_protective(Lang::Go, &name) && !out.iter().any(|m| m.name == name) { out.push(MiddlewareShape { name }); } } out } fn walk_use_calls(node: Node<'_>, bytes: &[u8], out: &mut Vec) { if node.kind() == "call_expression" { try_collect_use_call(node, bytes, out); } let mut cur = node.walk(); for child in node.children(&mut cur) { walk_use_calls(child, bytes, out); } } fn try_collect_use_call(call: Node<'_>, bytes: &[u8], out: &mut Vec) { let Some(callee) = call.child_by_field_name("function") else { return; }; if callee.kind() != "selector_expression" { return; } let Some(field) = callee.child_by_field_name("field") else { return; }; let Ok(verb) = field.utf8_text(bytes) else { return; }; if verb != "Use" && verb != "With" { return; } let Some(args) = call.child_by_field_name("arguments") else { return; }; let mut cur = args.walk(); for arg in args.named_children(&mut cur) { if arg.kind() == "comment" { continue; } if let Some(name) = middleware_arg_name(arg, bytes) { out.push(name); } } } fn middleware_arg_name(node: Node<'_>, bytes: &[u8]) -> Option { match node.kind() { "identifier" | "selector_expression" => { node.utf8_text(bytes).ok().map(|s| s.trim().to_owned()) } "call_expression" => { let callee = node.child_by_field_name("function")?; callee.utf8_text(bytes).ok().map(|s| s.trim().to_owned()) } _ => None, } } /// Locate the `(method, path)` of a `receiver.Verb("/path", target)` /// call expression registered against `target` in the file. Walks /// every `call_expression` in `root` and inspects each one whose /// callee is a `selector_expression` of the shape /// `.(, )`. Returns `None` when no /// such call references `target` directly. /// /// `target` matches against: /// - bare identifier callee (`r.GET("/x", handler)`) /// - qualified callee whose last segment equals `target` /// (`r.GET("/x", controllers.Show)`) /// - method-value callee (`r.GET("/x", (&UserController{}).Show)`) pub fn find_route_for_callee<'a>( root: Node<'a>, bytes: &'a [u8], target: &str, ) -> Option<(HttpMethod, String)> { let mut hit: Option<(HttpMethod, String)> = None; walk_routes(root, bytes, target, &mut hit); hit } fn walk_routes<'a>( node: Node<'a>, bytes: &'a [u8], target: &str, out: &mut Option<(HttpMethod, String)>, ) { if out.is_some() { return; } if node.kind() == "call_expression" && let Some(found) = try_route_call(node, bytes, target) { *out = Some(found); return; } let mut cur = node.walk(); for child in node.children(&mut cur) { walk_routes(child, bytes, target, out); } } fn try_route_call<'a>( call: Node<'a>, bytes: &'a [u8], target: &str, ) -> Option<(HttpMethod, String)> { let callee = call.child_by_field_name("function")?; if callee.kind() != "selector_expression" { return None; } let verb_node = callee.child_by_field_name("field")?.utf8_text(bytes).ok()?; let method = verb_from_method(verb_node)?; let args = call.child_by_field_name("arguments")?; let positional: Vec> = { let mut cur = args.walk(); args.named_children(&mut cur) .filter(|c| c.kind() != "comment") .collect() }; if positional.len() < 2 { return None; } let path = go_string_literal(positional[0], bytes)?; if !callable_matches(positional[1], bytes, target) { return None; } Some((method, path)) } /// Read a Go interpreted_string_literal's content, dropping the /// surrounding `"` quotes. Returns `None` if `node` is not a string /// literal. pub fn go_string_literal(node: Node<'_>, bytes: &[u8]) -> Option { if node.kind() != "interpreted_string_literal" && node.kind() != "raw_string_literal" { return None; } let raw = node.utf8_text(bytes).ok()?; let trimmed = raw.trim(); if trimmed.len() < 2 { return None; } let first = trimmed.as_bytes()[0]; let last = trimmed.as_bytes()[trimmed.len() - 1]; if (first == b'"' && last == b'"') || (first == b'`' && last == b'`') { Some(trimmed[1..trimmed.len() - 1].to_owned()) } else { None } } /// True when the callable argument resolves to `target`. Accepts: /// - bare identifier (`Handler`) /// - selector chain (`controllers.Show`, `c.Show`) /// - func literal — wildcard (the surrounding adapter already /// narrowed to a Go function whose name matches the summary) /// - method-value calls — wildcard fn callable_matches(node: Node<'_>, bytes: &[u8], target: &str) -> bool { match node.kind() { "identifier" => node.utf8_text(bytes).map(|s| s == target).unwrap_or(false), "selector_expression" => { let Some(field) = node.child_by_field_name("field") else { return false; }; field.utf8_text(bytes).map(|s| s == target).unwrap_or(false) } "func_literal" => true, "call_expression" => true, _ => false, } } #[cfg(test)] mod tests { use super::*; fn parse(src: &[u8]) -> tree_sitter::Tree { let mut parser = tree_sitter::Parser::new(); let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE); parser.set_language(&lang).unwrap(); parser.parse(src, None).unwrap() } #[test] fn extracts_colon_placeholders() { assert_eq!(extract_go_path_placeholders("/u/:id"), vec!["id"]); assert_eq!( extract_go_path_placeholders("/u/:id/posts/:slug"), vec!["id", "slug"] ); } #[test] fn extracts_brace_placeholders() { assert_eq!(extract_go_path_placeholders("/u/{id}"), vec!["id"]); assert_eq!(extract_go_path_placeholders("/u/{id:[0-9]+}"), vec!["id"]); } #[test] fn extracts_fiber_wildcards() { assert_eq!(extract_go_path_placeholders("/files/+rest"), vec!["rest"]); assert_eq!(extract_go_path_placeholders("/files/*rest"), vec!["rest"]); } #[test] fn binds_known_placeholder_as_path_segment() { let formals = vec!["c".to_string(), "id".to_string(), "extra".to_string()]; let bindings = bind_go_path_params(&formals, "/u/:id"); assert!(matches!(bindings[0].source, ParamSource::Implicit)); assert!(matches!(bindings[1].source, ParamSource::PathSegment(_))); assert!(matches!(bindings[2].source, ParamSource::QueryParam(_))); } #[test] fn verb_recognises_pascal_case() { assert_eq!(verb_from_method("GET"), Some(HttpMethod::GET)); assert_eq!(verb_from_method("Get"), Some(HttpMethod::GET)); assert_eq!(verb_from_method("post"), Some(HttpMethod::POST)); assert_eq!(verb_from_method("Handler"), None); } #[test] fn finds_function_declaration() { let src: &[u8] = b"package main\nfunc Show(c interface{}) {}\n"; let tree = parse(src); let n = find_go_function(tree.root_node(), src, "Show").unwrap(); assert_eq!(n.kind(), "function_declaration"); } #[test] fn finds_route_for_bare_identifier_callee() { let src: &[u8] = b"package main\nfunc init() { r := gin.New(); r.GET(\"/u/:id\", Show) }\nfunc Show(c interface{}) {}\n"; let tree = parse(src); let (method, path) = find_route_for_callee(tree.root_node(), src, "Show").expect("hit"); assert_eq!(method, HttpMethod::GET); assert_eq!(path, "/u/:id"); } #[test] fn finds_route_for_selector_callee() { let src: &[u8] = b"package main\nfunc init() { r := chi.NewRouter(); r.Get(\"/x\", controllers.Show) }\n"; let tree = parse(src); let (method, path) = find_route_for_callee(tree.root_node(), src, "Show").expect("hit"); assert_eq!(method, HttpMethod::GET); assert_eq!(path, "/x"); } #[test] fn formal_names_skip_types() { let src: &[u8] = b"package main\nfunc Show(c *gin.Context, id string) {}\n"; let tree = parse(src); let f = find_go_function(tree.root_node(), src, "Show").unwrap(); let names = go_formal_names(f, src); assert_eq!(names, vec!["c", "id"]); } #[test] fn collect_use_middleware_picks_bare_identifier() { let src: &[u8] = b"package main\nfunc init() { r := gin.Default(); r.Use(AuthMiddleware) }\n"; let tree = parse(src); let mw = collect_use_middleware(tree.root_node(), src); assert_eq!(mw.len(), 1); assert_eq!(mw[0].name, "AuthMiddleware"); } #[test] fn collect_use_middleware_picks_selector_marker() { let src: &[u8] = b"package main\nfunc init() { e := echo.New(); e.Use(middleware.JWT) }\n"; let tree = parse(src); let mw = collect_use_middleware(tree.root_node(), src); assert_eq!(mw.len(), 1); assert_eq!(mw[0].name, "middleware.JWT"); } #[test] fn collect_use_middleware_picks_call_factory() { let src: &[u8] = b"package main\nfunc init() { r := chi.NewRouter(); r.Use(csrf.New(secret)) }\n"; let tree = parse(src); let mw = collect_use_middleware(tree.root_node(), src); assert_eq!(mw.len(), 1); assert_eq!(mw[0].name, "csrf.New"); } #[test] fn collect_use_middleware_accepts_chi_with() { let src: &[u8] = b"package main\nfunc init() { r := chi.NewRouter(); r.With(jwtauth.Verifier).Get(\"/x\", Show) }\n"; let tree = parse(src); let mw = collect_use_middleware(tree.root_node(), src); assert_eq!(mw.len(), 1); assert_eq!(mw[0].name, "jwtauth.Verifier"); } #[test] fn collect_use_middleware_drops_unknown_names() { let src: &[u8] = b"package main\nfunc init() { r := gin.Default(); r.Use(loggingHandler) }\n"; let tree = parse(src); let mw = collect_use_middleware(tree.root_node(), src); assert!(mw.is_empty(), "loggingHandler is not a recognised marker"); } #[test] fn collect_use_middleware_dedupes_and_collects_multiple() { let src: &[u8] = b"package main\nfunc init() { r := gin.Default(); r.Use(AuthMiddleware, csrf.New(s)); r.Use(AuthMiddleware) }\n"; let tree = parse(src); let mw = collect_use_middleware(tree.root_node(), src); let names: Vec<&str> = mw.iter().map(|m| m.name.as_str()).collect(); assert_eq!(names, vec!["AuthMiddleware", "csrf.New"]); } #[test] fn collect_use_middleware_returns_empty_when_none_recognised() { let src: &[u8] = b"package main\nfunc init() { r := gin.Default(); r.GET(\"/x\", Show) }\n"; let tree = parse(src); let mw = collect_use_middleware(tree.root_node(), src); assert!(mw.is_empty()); } }