Merge pull request #1059 from DeusData/fix/952-laravel-facade-routes

fix(routes): mint facade-style Laravel routes and compose group prefixes
This commit is contained in:
Martin Vogel
2026-07-12 22:44:38 +02:00
committed by GitHub
3 changed files with 228 additions and 3 deletions
+168 -3
View File
@@ -5,8 +5,9 @@
#include "extract_unified.h"
#include "foundation/constants.h"
#include "extract_node_stack.h"
#include "tree_sitter/api.h" // TSNode, ts_node_*
#include <stdint.h> // uint32_t
#include "service_patterns.h" // cbm_service_pattern_route_method (#952)
#include "tree_sitter/api.h" // TSNode, ts_node_*
#include <stdint.h> // uint32_t
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -463,7 +464,8 @@ static char *extract_scripting_callee(CBMArena *a, TSNode node, const char *sour
if (ts_node_is_null(func_node)) {
func_node = ts_node_child_by_field_name(node, TS_FIELD("name"));
}
return ts_node_is_null(func_node) ? NULL : cbm_node_text(a, func_node, source);
char *pn = ts_node_is_null(func_node) ? NULL : cbm_node_text(a, func_node, source);
return pn;
}
if (lang == CBM_LANG_KOTLIN && ts_node_child_count(node) > 0) {
return cbm_node_text(a, ts_node_child(node, 0), source);
@@ -1165,6 +1167,126 @@ static char *extract_callee_lang_specific(CBMArena *a, TSNode node, const char *
}
// Extract callee name from a call node
/* #952: compose Laravel group prefixes into a route path. Walks UP from a
* route-registration call: every enclosing anonymous function that is the
* argument of a `->group(...)` member call contributes the `prefix('...')`
* (or `Route::prefix('...')`) found on that group call's receiver chain.
* Chain methods that don't shape the path (middleware, name, as, domain)
* are skipped. Outer groups accumulate before inner ones; nested groups
* compose left-to-right. Returns the composed path (arena) or NULL when no
* enclosing group carries a prefix. */
enum { PHP_GROUP_WALK_MAX = 64, PHP_PREFIX_PARTS_MAX = 8 };
static const char *php_chain_prefix_arg(CBMArena *a, TSNode call_node, const char *source) {
/* call_node is a member_call_expression / scoped_call_expression whose
* name is `prefix`; return its first string argument (unquoted). */
TSNode args = ts_node_child_by_field_name(call_node, TS_FIELD("arguments"));
if (ts_node_is_null(args)) {
return NULL;
}
uint32_t nc = ts_node_named_child_count(args);
for (uint32_t i = 0; i < nc; i++) {
TSNode arg = ts_node_named_child(args, i);
TSNode inner = arg;
if (strcmp(ts_node_type(arg), "argument") == 0 && ts_node_named_child_count(arg) > 0) {
inner = ts_node_named_child(arg, 0);
}
if (strcmp(ts_node_type(inner), "string") == 0 ||
strcmp(ts_node_type(inner), "encapsed_string") == 0) {
char *txt = cbm_node_text(a, inner, source);
if (txt && txt[0]) {
size_t len = strlen(txt);
if (len >= 2 && (txt[0] == '\'' || txt[0] == '"')) {
txt[len - 1] = '\0';
txt++;
}
return txt[0] ? txt : NULL;
}
}
}
return NULL;
}
static const char *php_group_prefix_for_call(CBMArena *a, TSNode node, const char *source) {
const char *parts[PHP_PREFIX_PARTS_MAX];
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++) {
if (strcmp(ts_node_type(cur), "anonymous_function") == 0 ||
strcmp(ts_node_type(cur), "arrow_function") == 0) {
/* Is this closure the argument of a ->group(...) call? Walk to
* the enclosing call and check its method name. */
TSNode p = ts_node_parent(cur);
while (!ts_node_is_null(p) && strcmp(ts_node_type(p), "member_call_expression") != 0 &&
strcmp(ts_node_type(p), "scoped_call_expression") != 0) {
if (strcmp(ts_node_type(p), "statement") == 0 ||
strcmp(ts_node_type(p), "expression_statement") == 0) {
break; /* left the argument position */
}
p = ts_node_parent(p);
}
if (!ts_node_is_null(p) && (strcmp(ts_node_type(p), "member_call_expression") == 0 ||
strcmp(ts_node_type(p), "scoped_call_expression") == 0)) {
TSNode gname = ts_node_child_by_field_name(p, TS_FIELD("name"));
char *gtxt = ts_node_is_null(gname) ? NULL : cbm_node_text(a, gname, source);
if (gtxt && strcmp(gtxt, "group") == 0) {
/* Scan the receiver chain for prefix('...'). */
TSNode recv = ts_node_child_by_field_name(p, TS_FIELD("object"));
for (int hops = 0; hops < PHP_GROUP_WALK_MAX && !ts_node_is_null(recv);
hops++) {
const char *rk = ts_node_type(recv);
if (strcmp(rk, "member_call_expression") == 0 ||
strcmp(rk, "scoped_call_expression") == 0) {
TSNode rname = ts_node_child_by_field_name(recv, TS_FIELD("name"));
char *rtxt =
ts_node_is_null(rname) ? NULL : cbm_node_text(a, rname, source);
if (rtxt && strcmp(rtxt, "prefix") == 0) {
const char *pf = php_chain_prefix_arg(a, recv, source);
if (pf && part_count < PHP_PREFIX_PARTS_MAX) {
parts[part_count++] = pf; /* inner-first */
}
break;
}
recv = ts_node_child_by_field_name(recv, TS_FIELD("object"));
} else {
break;
}
}
}
}
}
cur = ts_node_parent(cur);
}
if (part_count == 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++;
}
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;
}
static char *extract_callee_name(CBMArena *a, TSNode node, const char *source, CBMLanguage lang) {
// Lean 4: skip type-position applies
if (lang == CBM_LANG_LEAN && strcmp(ts_node_type(node), "apply") == 0) {
@@ -1208,6 +1330,31 @@ static char *extract_callee_name(CBMArena *a, TSNode node, const char *source, C
}
}
/* #952: PHP facade route registrations (`Route::get(...)`, a
* scoped_call_expression) must carry the scope in the callee text — the
* empty-resolution route fallback keys on the "::get" suffix table, and
* the bare "get" that generic field resolution would return deliberately
* never matches (every $obj->get() would become a route). Runs BEFORE
* field-based resolution, which short-circuits on the `name` field.
* Gated to the literal `Route` scope AND a route-method match: any other
* scope (Cache::get) would suffix-match "::get" too and mint junk routes
* from slash-prefixed keys. Known limitation: aliased facade imports are
* not recognized. */
if (lang == CBM_LANG_PHP && strcmp(ts_node_type(node), "scoped_call_expression") == 0) {
TSNode scope = ts_node_child_by_field_name(node, TS_FIELD("scope"));
TSNode mname = ts_node_child_by_field_name(node, TS_FIELD("name"));
if (!ts_node_is_null(scope) && !ts_node_is_null(mname)) {
char *sc = cbm_node_text(a, scope, source);
char *mn = cbm_node_text(a, mname, source);
if (sc && mn && strcmp(sc, "Route") == 0) {
char *qual = cbm_arena_sprintf(a, "%s::%s", sc, mn);
if (qual && cbm_service_pattern_route_method(qual) != NULL) {
return qual;
}
}
}
}
// Try common field-based resolution first
char *name = extract_callee_from_fields(a, node, source);
if (name) {
@@ -1919,6 +2066,24 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk
TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments"));
if (!ts_node_is_null(args)) {
call.first_string_arg = extract_url_or_topic_arg(ctx, args);
/* #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));
}
}
if (call.first_string_arg && call.first_string_arg[0] == '/') {
call.second_arg_name = extract_handler_arg(ctx, args);
}
+12
View File
@@ -525,6 +525,18 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
* Native `fetch()` (#856) belongs here too, not in the substring
* tables above: it only counts as the global API once resolution has
* already failed to find a local/imported `fetch` definition. */
/* Route registration on an unresolvable callee (#952): facade-style
* Laravel (`Route::get('/x', ...)`) — the facade class lives in
* vendor/ and is never indexed, so resolution is ALWAYS empty in real
* apps. Classify by callee suffix + path-shaped first arg, exactly
* like the parallel path's callee_suffix fallback; without this the
* sequential path minted zero Route nodes for such files. */
if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg &&
call->first_string_arg[0] == '/') {
handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals,
imp_count);
return SKIP_ONE;
}
cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name);
if (esvc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) {
esvc = CBM_SVC_HTTP;
+48
View File
@@ -435,6 +435,52 @@ TEST(handles_laravel_php) {
PASS();
}
/* #952: facade-style Laravel — the ONLY style real apps use, since the
* Illuminate facade lives in vendor/ and is never indexed. The callee of
* `Route::get(...)` (scoped_call_expression) was extracted as bare "get", so
* the empty-resolution route fallback ("::get" suffix table) never engaged
* and NO Route node minted — not even for flat registrations. Grouped routes
* additionally need the enclosing `prefix('/x')->group(...)` chain composed
* (same class as Spring's #734). Exact-set assertion distinguishes
* prefix-dropped from missing entirely. */
TEST(handles_laravel_facade_routes_issue952) {
static const char *routes[] = {"/api/welcome", "/users/me", "/users/login",
"/companies/tenants/list", NULL};
static const EtFile f[] = {
{"routes/api.php",
"<?php\nuse Illuminate\\Support\\Facades\\Route;\n\n"
"Route::get('/api/welcome', WelcomeController::class);\n"
"Route::prefix('/users')->middleware(DomainCheckMiddleware::class)"
"->group(function (): void {\n"
" Route::get('/me', GetCurrentUserController::class);\n"
" Route::post('/login', LoginUserController::class);\n"
"});\n"
"Route::prefix('companies')->group(function (): void {\n"
" Route::prefix('tenants')->group(function (): void {\n"
" Route::get('/list', ListTenantsController::class);\n"
" });\n"
"});\n"}};
ASSERT_TRUE(et_routes_exact(f, 1, routes));
PASS();
}
/* #952 inverse guard: a non-router static call whose method name collides
* with a route verb (Cache::get) must NOT mint a Route node — the callee
* qualification is route-table-gated and the path gate requires a leading
* slash. */
TEST(handles_laravel_facade_no_junk_routes_issue952) {
static const char *routes[] = {"/real", NULL};
static const EtFile f[] = {
{"routes/api.php",
"<?php\nuse Illuminate\\Support\\Facades\\Route;\n"
"use Illuminate\\Support\\Facades\\Cache;\n\n"
"Route::get('/real', RealController::class);\n"
"$v = Cache::get('users.count');\n"
"$w = Cache::get('/leading/slash/key');\n"}};
ASSERT_TRUE(et_routes_exact(f, 1, routes));
PASS();
}
/* Rails (Ruby) — ActionDispatch router. The handler MUST be passed as a bare
* identifier (not the idiomatic `to: 'list_items'` string, which extract_handler_arg
* cannot capture). mapper.get resolves by name to the Mapper#get method whose QN
@@ -1453,6 +1499,8 @@ SUITE(edge_types_probe) {
RUN_TEST(handles_spring_kotlin);
RUN_TEST(handles_aspnet_csharp);
RUN_TEST(handles_laravel_php);
RUN_TEST(handles_laravel_facade_routes_issue952);
RUN_TEST(handles_laravel_facade_no_junk_routes_issue952);
RUN_TEST(handles_rails_ruby);
RUN_TEST(handles_actix_rust);