From d141ee937def3d9ce90e852668a96674f864b332 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 3 Oct 2026 01:31:13 +0200 Subject: [PATCH] fix(extract): preserve Go router group prefixes Follow literal Group receivers through local bindings and compose their prefixes for both sequential and parallel route extraction. Keep current URL argument handling and reuse the existing Laravel prefix composition. Refs #686 Signed-off-by: Martin Vogel --- internal/cbm/extract_calls.c | 272 +++++++++++++++++++++++++++++----- tests/test_edge_types_probe.c | 81 ++++++++++ 2 files changed, 313 insertions(+), 40 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index f14efb766..89caca047 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1763,8 +1763,50 @@ static const char *php_chain_prefix_arg(CBMArena *a, TSNode call_node, const cha return NULL; } +/* Compose route-group prefix segments into one path. parts[] is inner-first; + * the result is outer-first with exactly one '/' between segments and a + * leading '/'. Returns NULL when no segment contributes or the result would + * be oversized (the route then stays un-prefixed). Shared by the Laravel + * (#952) and Go router-group (#686) prefix walks. */ +static const char *route_prefix_compose(CBMArena *a, const char *const *parts, int part_count) { + char buf[CBM_SZ_256]; + size_t pos = 0; + for (int i = part_count - 1; i >= 0; i--) { + const char *seg = parts[i]; + while (*seg == '/') { + seg++; + } + size_t sl = strlen(seg); + if (sl == 0) { + continue; + } + if (pos + sl + 2 >= sizeof(buf)) { + return NULL; /* oversized — leave path un-prefixed */ + } + buf[pos++] = '/'; + memcpy(buf + pos, seg, sl); + pos += sl; + while (pos > 1 && buf[pos - 1] == '/') { + pos--; /* strip trailing slash per segment */ + } + } + buf[pos] = '\0'; + return pos ? cbm_arena_strndup(a, buf, pos) : NULL; +} + +/* Prepend a composed group prefix to a route path ("/x" + "/users" -> + * "/x/users"; a bare "/" route becomes the prefix itself). */ +static const char *route_path_with_prefix(CBMArena *a, const char *prefix, const char *path) { + const char *rel = path; + while (*rel == '/') { + rel++; + } + return rel[0] ? cbm_arena_sprintf(a, "%s/%s", prefix, rel) + : cbm_arena_strndup(a, prefix, strlen(prefix)); +} + static const char *php_group_prefix_for_call(CBMArena *a, TSNode node, const char *source) { - const char *parts[PHP_PREFIX_PARTS_MAX]; + const char *parts[PHP_PREFIX_PARTS_MAX] = {NULL}; int part_count = 0; TSNode cur = ts_node_parent(node); for (int depth = 0; depth < PHP_GROUP_WALK_MAX && !ts_node_is_null(cur); depth++) { @@ -1813,34 +1855,200 @@ static const char *php_group_prefix_for_call(CBMArena *a, TSNode node, const cha } cur = ts_node_parent(cur); } - if (part_count == 0) { + return route_prefix_compose(a, parts, part_count); +} + +/* #686: compose Go router-group prefixes (Fiber, Gin, Echo: `g := app.Group("/admin")` + * then `g.Post("/x")`). Starting at the route call's receiver, follow: + * - an identifier to the latest binding before the use (`:=`, `=`, `var`) + * among the preceding statements of the enclosing blocks of the same + * function (a re-bound variable uses the binding in effect at the call), + * - a `recv.Group("", ...)` call to its literal prefix and receiver, + * until the root router (`fiber.New()`, `gin.Default()`, a parameter ...). + * A non-literal group prefix returns NULL: the route stays un-prefixed rather + * than carrying a guessed path. Groups passed across functions are not + * followed (parameters are roots). */ +enum { GO_GROUP_HOPS_MAX = 32 }; + +static const char *go_string_literal_value(CBMArena *a, TSNode n, const char *source) { + const char *k = ts_node_type(n); + if (strcmp(k, "interpreted_string_literal") != 0 && strcmp(k, "raw_string_literal") != 0) { return NULL; } - /* parts[] is inner-first; compose outer-first. Ensure exactly one '/' - * between segments and a leading '/'. */ - char buf[CBM_SZ_256]; - size_t pos = 0; - for (int i = part_count - 1; i >= 0; i--) { - const char *seg = parts[i]; - while (*seg == '/') { - seg++; + char *t = cbm_node_text(a, n, source); + size_t len = t ? strlen(t) : 0; + if (len < 2) { + return NULL; + } + t[len - 1] = '\0'; + return t + 1; +} + +/* 1 = `recv.Group("")` (receiver + literal written out), 0 = not a Group + * call, -1 = a Group call whose prefix is not a string literal. */ +static int go_group_call(CBMArena *a, TSNode expr, const char *source, TSNode *recv_out, + const char **prefix_out) { + if (strcmp(ts_node_type(expr), "call_expression") != 0) { + return 0; + } + TSNode fn = ts_node_child_by_field_name(expr, TS_FIELD("function")); + if (ts_node_is_null(fn) || strcmp(ts_node_type(fn), "selector_expression") != 0) { + return 0; + } + TSNode field = ts_node_child_by_field_name(fn, TS_FIELD("field")); + const char *ft = ts_node_is_null(field) ? NULL : cbm_node_text(a, field, source); + if (!ft || strcmp(ft, "Group") != 0) { + return 0; + } + *recv_out = ts_node_child_by_field_name(fn, TS_FIELD("operand")); + TSNode args = ts_node_child_by_field_name(expr, TS_FIELD("arguments")); + *prefix_out = NULL; + if (!ts_node_is_null(args) && ts_node_named_child_count(args) > 0) { + *prefix_out = go_string_literal_value(a, ts_node_named_child(args, 0), source); + } + return *prefix_out ? 1 : -1; +} + +/* Value bound to `name` by one var_spec (`var a, b = x, y`), or a null node. */ +static TSNode go_var_spec_binding(CBMArena *a, TSNode spec, const char *name, const char *source) { + TSNode none = {0}; + TSNode value = ts_node_child_by_field_name(spec, TS_FIELD("value")); + if (ts_node_is_null(value)) { + return none; + } + uint32_t idx = 0; + uint32_t nc = ts_node_named_child_count(spec); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(spec, i); + if (strcmp(ts_node_type(c), "identifier") != 0) { + continue; } - size_t sl = strlen(seg); - if (sl == 0) { + const char *t = cbm_node_text(a, c, source); + if (t && strcmp(t, name) == 0) { + return idx < ts_node_named_child_count(value) ? ts_node_named_child(value, idx) : none; + } + idx++; + } + return none; +} + +/* Value bound to `name` by statement `stmt`, or a null node. */ +static TSNode go_stmt_binding(CBMArena *a, TSNode stmt, const char *name, const char *source) { + TSNode none = {0}; + const char *k = ts_node_type(stmt); + if (strcmp(k, "var_declaration") == 0 || strcmp(k, "var_spec_list") == 0) { + for (uint32_t i = ts_node_named_child_count(stmt); i > 0; i--) { + TSNode c = ts_node_named_child(stmt, i - 1); + TSNode v = strcmp(ts_node_type(c), "var_spec") == 0 + ? go_var_spec_binding(a, c, name, source) + : go_stmt_binding(a, c, name, source); + if (!ts_node_is_null(v)) { + return v; + } + } + return none; + } + if (strcmp(k, "short_var_declaration") != 0 && strcmp(k, "assignment_statement") != 0) { + return none; + } + TSNode left = ts_node_child_by_field_name(stmt, TS_FIELD("left")); + TSNode right = ts_node_child_by_field_name(stmt, TS_FIELD("right")); + if (ts_node_is_null(left) || ts_node_is_null(right)) { + return none; + } + uint32_t nl = ts_node_named_child_count(left); + for (uint32_t i = 0; i < nl; i++) { + const char *t = cbm_node_text(a, ts_node_named_child(left, i), source); + if (t && strcmp(t, name) == 0) { + return i < ts_node_named_child_count(right) ? ts_node_named_child(right, i) : none; + } + } + return none; +} + +static bool go_is_function_boundary(const char *k) { + return strcmp(k, "function_declaration") == 0 || strcmp(k, "method_declaration") == 0 || + strcmp(k, "func_literal") == 0 || strcmp(k, "source_file") == 0; +} + +/* Latest binding of `name` among the statements preceding `use` in its + * enclosing blocks, innermost block first; a null node when none. */ +static TSNode go_find_binding(CBMArena *a, TSNode use, const char *name, const char *source) { + TSNode child = use; + TSNode cur = ts_node_parent(use); + while (!ts_node_is_null(cur) && !go_is_function_boundary(ts_node_type(cur))) { + const char *k = ts_node_type(cur); + if (strcmp(k, "block") == 0 || strcmp(k, "statement_list") == 0) { + for (TSNode s = ts_node_prev_named_sibling(child); !ts_node_is_null(s); + s = ts_node_prev_named_sibling(s)) { + TSNode v = go_stmt_binding(a, s, name, source); + if (!ts_node_is_null(v)) { + return v; + } + } + } + child = cur; + cur = ts_node_parent(cur); + } + TSNode none = {0}; + return none; +} + +static const char *go_group_prefix_for_call(CBMArena *a, TSNode call, const char *source) { + TSNode fn = ts_node_child_by_field_name(call, TS_FIELD("function")); + if (ts_node_is_null(fn) || strcmp(ts_node_type(fn), "selector_expression") != 0) { + return NULL; + } + const char *parts[GO_GROUP_HOPS_MAX] = {NULL}; + int part_count = 0; + TSNode recv = ts_node_child_by_field_name(fn, TS_FIELD("operand")); + for (int hop = 0; !ts_node_is_null(recv); hop++) { + if (hop >= GO_GROUP_HOPS_MAX) { + return NULL; /* chain too deep to trust: never a partial prefix */ + } + const char *k = ts_node_type(recv); + if (strcmp(k, "parenthesized_expression") == 0 && ts_node_named_child_count(recv) > 0) { + recv = ts_node_named_child(recv, 0); continue; } - if (pos + sl + 2 >= sizeof(buf)) { - return NULL; /* oversized — leave path un-prefixed */ + if (strcmp(k, "identifier") == 0) { + const char *name = cbm_node_text(a, recv, source); + recv = name ? go_find_binding(a, recv, name, source) : (TSNode){0}; + continue; /* unbound (parameter, package var): root reached */ } - buf[pos++] = '/'; - memcpy(buf + pos, seg, sl); - pos += sl; - while (pos > 1 && buf[pos - 1] == '/') { - pos--; /* strip trailing slash per segment */ + TSNode inner = {0}; + const char *prefix = NULL; + int g = go_group_call(a, recv, source, &inner, &prefix); + if (g < 0) { + return NULL; /* dynamic prefix: do not guess */ + } + if (g == 0) { + break; /* root router constructor or other expression */ } + parts[part_count++] = prefix; /* inner-first */ + recv = inner; } - buf[pos] = '\0'; - return pos ? cbm_arena_strndup(a, buf, pos) : NULL; + return route_prefix_compose(a, parts, part_count); +} + +/* Routes registered inside router groups must carry the composed path — the + * resolve passes only see the flat CBMCall, so the enclosing group chain can + * only be read here where the AST still exists. Laravel `prefix()->group()` + * closures (#952) and Go `x.Group("/p")` receivers (#686). Returns the route + * path to store (unchanged when no group prefix applies). */ +static const char *route_path_with_group_prefix(CBMExtractCtx *ctx, TSNode node, + const char *callee_name, const char *path) { + if (!path || path[0] != '/' || !callee_name || + cbm_service_pattern_route_method(callee_name) == NULL) { + return path; + } + const char *gp = NULL; + if (ctx->language == CBM_LANG_PHP) { + gp = php_group_prefix_for_call(ctx->arena, node, ctx->source); + } else if (ctx->language == CBM_LANG_GO) { + gp = go_group_prefix_for_call(ctx->arena, node, ctx->source); + } + return (gp && gp[0]) ? route_path_with_prefix(ctx->arena, gp, path) : path; } static bool is_nested_verilog_call_wrapper(CBMLanguage lang, TSNode node) { @@ -3940,25 +4148,9 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML args = swift_call_args(node); } if (!ts_node_is_null(args)) { - call.first_string_arg = extract_url_or_topic_arg(ctx, args, call.callee_name); - /* #952: routes registered inside Laravel `prefix()->group()` - * closures must carry the composed path — the resolve passes - * only see the flat CBMCall, so the enclosing chain can only - * be read here where the AST still exists. */ - if (ctx->language == CBM_LANG_PHP && call.first_string_arg && - call.first_string_arg[0] == '/' && call.callee_name && - cbm_service_pattern_route_method(call.callee_name) != NULL) { - const char *gp = php_group_prefix_for_call(ctx->arena, node, ctx->source); - if (gp && gp[0]) { - const char *rel = call.first_string_arg; - while (*rel == '/') { - rel++; - } - call.first_string_arg = - rel[0] ? cbm_arena_sprintf(ctx->arena, "%s/%s", gp, rel) - : cbm_arena_strndup(ctx->arena, gp, strlen(gp)); - } - } + call.first_string_arg = route_path_with_group_prefix( + ctx, node, call.callee_name, + extract_url_or_topic_arg(ctx, args, call.callee_name)); if (call.first_string_arg && call.first_string_arg[0] == '/') { call.second_arg_name = extract_handler_arg(ctx, args); } diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 5ed449b8d..26a9986ae 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -546,6 +546,84 @@ TEST(handles_gin_go) { PASS(); } +/* #686: Go router groups. Fiber (and Gin/Echo, same syntax) build the external + * path from `grp := app.Group("/admin")` plus `grp.Post("/x")`, so the full + * path is never one literal at the registration site. The Route must carry + * the composed path, including nested groups (`v1 := api.Group("/v1")`) and + * groups built inline (`app.Group("/x").Get(...)`). Controls: a route on the + * root app keeps its own path, a variable later re-bound to a different group + * uses the binding in effect at the call, and a non-literal group prefix + * leaves the route unprefixed (never guessed). Exact Route set on BOTH the + * sequential and the parallel pipeline. */ +static const EtFile et_fiber_groups_issue686[] = { + {"main.go", + "package main\n\n" + "import \"github.com/gofiber/fiber/v2\"\n\n" + "func health(c *fiber.Ctx) error { return nil }\n" + "func getUser(c *fiber.Ctx) error { return nil }\n" + "func updateCustomer(c *fiber.Ctx) error { return nil }\n" + "func listOrders(c *fiber.Ctx) error { return nil }\n" + "func ping(c *fiber.Ctx) error { return nil }\n" + "func report(c *fiber.Ctx) error { return nil }\n" + "func dyn(c *fiber.Ctx) error { return nil }\n\n" + "func main() {\n" + " app := fiber.New()\n" + " app.Get(\"/health\", health)\n" + " api := app.Group(\"/api\")\n" + " v1 := api.Group(\"/v1\", authMiddleware)\n" + " v1.Get(\"/users/:id\", getUser)\n" + " admin := app.Group(\"/admin\")\n" + " admin.Post(\"/customers/:id\", updateCustomer)\n" + " grp := api.Group(\"/shop\")\n" + " grp.Get(\"/orders\", listOrders)\n" + " grp = app.Group(\"/internal\")\n" + " grp.Get(\"/ping\", ping)\n" + " app.Group(\"/reports\").Get(\"/daily\", report)\n" + " prefix := \"/p\"\n" + " dg := app.Group(prefix)\n" + " dg.Get(\"/dyn\", dyn)\n" + " app.Listen(\":3000\")\n" + "}\n\n" + "func authMiddleware(c *fiber.Ctx) error { return c.Next() }\n"}}; + +static const char *et_fiber_groups_issue686_routes[] = { + "/health", "/api/v1/users/:id", "/admin/customers/:id", "/api/shop/orders", + "/internal/ping", "/reports/daily", "/dyn", NULL}; + +TEST(handles_fiber_group_prefix_sequential_issue686) { + ASSERT_TRUE(et_routes_exact_mode(et_fiber_groups_issue686, 1, et_fiber_groups_issue686_routes, + false, NULL)); + PASS(); +} + +TEST(handles_fiber_group_prefix_parallel_issue686) { + ASSERT_TRUE(et_routes_exact_mode(et_fiber_groups_issue686, 1, et_fiber_groups_issue686_routes, + true, NULL)); + PASS(); +} + +/* #686 control for the same mechanism on Gin: `v1 := r.Group("/v1")` with the + * idiomatic brace block. */ +TEST(handles_gin_group_prefix_issue686) { + static const EtFile f[] = { + {"main.go", + "package main\n\n" + "import \"github.com/gin-gonic/gin\"\n\n" + "func listOrders(c *gin.Context) {}\n" + "func createOrder(c *gin.Context) {}\n\n" + "func main() {\n" + " r := gin.Default()\n" + " v1 := r.Group(\"/v1\")\n" + " {\n" + " v1.GET(\"/orders\", listOrders)\n" + " v1.POST(\"/orders\", createOrder)\n" + " }\n" + "}\n"}}; + static const char *routes[] = {"/v1/orders", "/v1/orders", NULL}; /* GET + POST */ + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + /* Spring (Java) — class-level @RequestMapping must prefix method mappings. * Reproduce-first: a HANDLES count alone can pass with partial routes * ("/orders"), but callers/search_graph need the actual endpoint names @@ -2121,6 +2199,9 @@ SUITE(edge_types_probe) { RUN_TEST(handles_express_ts); RUN_TEST(handles_fastify_js); RUN_TEST(handles_gin_go); + RUN_TEST(handles_fiber_group_prefix_sequential_issue686); + RUN_TEST(handles_fiber_group_prefix_parallel_issue686); + RUN_TEST(handles_gin_group_prefix_issue686); RUN_TEST(handles_spring_java); RUN_TEST(handles_spring_java_path_attribute_fourth); RUN_TEST(handles_spring_kotlin);