diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index a8b97c639..bc62393a5 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1763,6 +1763,48 @@ 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)); +} + /* The value of a plain PHP string literal ('...' or "..." with no * interpolation or escapes), "" for an empty one; NULL for anything else. */ static const char *php_plain_string_value(CBMArena *a, TSNode node, const char *source) { @@ -1817,7 +1859,7 @@ static const char *php_group_array_prefix(CBMArena *a, TSNode group_call, const } 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++) { @@ -1873,34 +1915,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; } /* A Laravel route registrar receiver (#1146): the Route facade — also at the @@ -4086,24 +4294,9 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML call.first_string_arg = laravel_uri; } } - /* #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)); - } - } + /* Laravel prefix()->group() (#952) and Go x.Group("/p") (#686). */ + call.first_string_arg = route_path_with_group_prefix(ctx, node, call.callee_name, + call.first_string_arg); /* A slashless registration's string action would only be * guessed at (#1146): mint its Route without a handler. */ if (call.first_string_arg && call.first_string_arg[0] == '/' && diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 30541db2c..29494fe30 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 @@ -2276,6 +2354,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);