nyx/src/dynamic/framework/adapters/go_routes.rs

606 lines
20 KiB
Rust

//! 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<Node<'a>> {
let mut hit: Option<Node<'a>> = None;
walk_go(root, bytes, target, &mut hit);
hit
}
fn walk_go<'a>(node: Node<'a>, bytes: &'a [u8], target: &str, out: &mut Option<Node<'a>>) {
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<String> {
let mut out: Vec<String> = 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<String> {
let mut out: Vec<String> = 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<ParamBinding> {
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<HttpMethod> {
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<MiddlewareShape> {
let mut raw: Vec<String> = Vec::new();
walk_use_calls(root, bytes, &mut raw);
let mut out: Vec<MiddlewareShape> = 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<String>) {
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<String>) {
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<String> {
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
/// `<receiver>.<Verb>(<string>, <callable>)`. 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<Node<'_>> = {
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<String> {
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());
}
}