Skip to content

Commit 2469ecc

Browse files
authored
Merge pull request DeusData#1059 from DeusData/fix/952-laravel-facade-routes
fix(routes): mint facade-style Laravel routes and compose group prefixes
2 parents 8f3ee08 + 2776624 commit 2469ecc

3 files changed

Lines changed: 228 additions & 3 deletions

File tree

‎internal/cbm/extract_calls.c‎

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
#include "extract_unified.h"
66
#include "foundation/constants.h"
77
#include "extract_node_stack.h"
8-
#include "tree_sitter/api.h" // TSNode, ts_node_*
9-
#include <stdint.h> // uint32_t
8+
#include "service_patterns.h" // cbm_service_pattern_route_method (#952)
9+
#include "tree_sitter/api.h" // TSNode, ts_node_*
10+
#include <stdint.h> // uint32_t
1011
#include <stdio.h>
1112
#include <stdlib.h>
1213
#include <string.h>
@@ -463,7 +464,8 @@ static char *extract_scripting_callee(CBMArena *a, TSNode node, const char *sour
463464
if (ts_node_is_null(func_node)) {
464465
func_node = ts_node_child_by_field_name(node, TS_FIELD("name"));
465466
}
466-
return ts_node_is_null(func_node) ? NULL : cbm_node_text(a, func_node, source);
467+
char *pn = ts_node_is_null(func_node) ? NULL : cbm_node_text(a, func_node, source);
468+
return pn;
467469
}
468470
if (lang == CBM_LANG_KOTLIN && ts_node_child_count(node) > 0) {
469471
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 *
11651167
}
11661168

11671169
// Extract callee name from a call node
1170+
/* #952: compose Laravel group prefixes into a route path. Walks UP from a
1171+
* route-registration call: every enclosing anonymous function that is the
1172+
* argument of a `->group(...)` member call contributes the `prefix('...')`
1173+
* (or `Route::prefix('...')`) found on that group call's receiver chain.
1174+
* Chain methods that don't shape the path (middleware, name, as, domain)
1175+
* are skipped. Outer groups accumulate before inner ones; nested groups
1176+
* compose left-to-right. Returns the composed path (arena) or NULL when no
1177+
* enclosing group carries a prefix. */
1178+
enum { PHP_GROUP_WALK_MAX = 64, PHP_PREFIX_PARTS_MAX = 8 };
1179+
1180+
static const char *php_chain_prefix_arg(CBMArena *a, TSNode call_node, const char *source) {
1181+
/* call_node is a member_call_expression / scoped_call_expression whose
1182+
* name is `prefix`; return its first string argument (unquoted). */
1183+
TSNode args = ts_node_child_by_field_name(call_node, TS_FIELD("arguments"));
1184+
if (ts_node_is_null(args)) {
1185+
return NULL;
1186+
}
1187+
uint32_t nc = ts_node_named_child_count(args);
1188+
for (uint32_t i = 0; i < nc; i++) {
1189+
TSNode arg = ts_node_named_child(args, i);
1190+
TSNode inner = arg;
1191+
if (strcmp(ts_node_type(arg), "argument") == 0 && ts_node_named_child_count(arg) > 0) {
1192+
inner = ts_node_named_child(arg, 0);
1193+
}
1194+
if (strcmp(ts_node_type(inner), "string") == 0 ||
1195+
strcmp(ts_node_type(inner), "encapsed_string") == 0) {
1196+
char *txt = cbm_node_text(a, inner, source);
1197+
if (txt && txt[0]) {
1198+
size_t len = strlen(txt);
1199+
if (len >= 2 && (txt[0] == '\'' || txt[0] == '"')) {
1200+
txt[len - 1] = '\0';
1201+
txt++;
1202+
}
1203+
return txt[0] ? txt : NULL;
1204+
}
1205+
}
1206+
}
1207+
return NULL;
1208+
}
1209+
1210+
static const char *php_group_prefix_for_call(CBMArena *a, TSNode node, const char *source) {
1211+
const char *parts[PHP_PREFIX_PARTS_MAX];
1212+
int part_count = 0;
1213+
TSNode cur = ts_node_parent(node);
1214+
for (int depth = 0; depth < PHP_GROUP_WALK_MAX && !ts_node_is_null(cur); depth++) {
1215+
if (strcmp(ts_node_type(cur), "anonymous_function") == 0 ||
1216+
strcmp(ts_node_type(cur), "arrow_function") == 0) {
1217+
/* Is this closure the argument of a ->group(...) call? Walk to
1218+
* the enclosing call and check its method name. */
1219+
TSNode p = ts_node_parent(cur);
1220+
while (!ts_node_is_null(p) && strcmp(ts_node_type(p), "member_call_expression") != 0 &&
1221+
strcmp(ts_node_type(p), "scoped_call_expression") != 0) {
1222+
if (strcmp(ts_node_type(p), "statement") == 0 ||
1223+
strcmp(ts_node_type(p), "expression_statement") == 0) {
1224+
break; /* left the argument position */
1225+
}
1226+
p = ts_node_parent(p);
1227+
}
1228+
if (!ts_node_is_null(p) && (strcmp(ts_node_type(p), "member_call_expression") == 0 ||
1229+
strcmp(ts_node_type(p), "scoped_call_expression") == 0)) {
1230+
TSNode gname = ts_node_child_by_field_name(p, TS_FIELD("name"));
1231+
char *gtxt = ts_node_is_null(gname) ? NULL : cbm_node_text(a, gname, source);
1232+
if (gtxt && strcmp(gtxt, "group") == 0) {
1233+
/* Scan the receiver chain for prefix('...'). */
1234+
TSNode recv = ts_node_child_by_field_name(p, TS_FIELD("object"));
1235+
for (int hops = 0; hops < PHP_GROUP_WALK_MAX && !ts_node_is_null(recv);
1236+
hops++) {
1237+
const char *rk = ts_node_type(recv);
1238+
if (strcmp(rk, "member_call_expression") == 0 ||
1239+
strcmp(rk, "scoped_call_expression") == 0) {
1240+
TSNode rname = ts_node_child_by_field_name(recv, TS_FIELD("name"));
1241+
char *rtxt =
1242+
ts_node_is_null(rname) ? NULL : cbm_node_text(a, rname, source);
1243+
if (rtxt && strcmp(rtxt, "prefix") == 0) {
1244+
const char *pf = php_chain_prefix_arg(a, recv, source);
1245+
if (pf && part_count < PHP_PREFIX_PARTS_MAX) {
1246+
parts[part_count++] = pf; /* inner-first */
1247+
}
1248+
break;
1249+
}
1250+
recv = ts_node_child_by_field_name(recv, TS_FIELD("object"));
1251+
} else {
1252+
break;
1253+
}
1254+
}
1255+
}
1256+
}
1257+
}
1258+
cur = ts_node_parent(cur);
1259+
}
1260+
if (part_count == 0) {
1261+
return NULL;
1262+
}
1263+
/* parts[] is inner-first; compose outer-first. Ensure exactly one '/'
1264+
* between segments and a leading '/'. */
1265+
char buf[CBM_SZ_256];
1266+
size_t pos = 0;
1267+
for (int i = part_count - 1; i >= 0; i--) {
1268+
const char *seg = parts[i];
1269+
while (*seg == '/') {
1270+
seg++;
1271+
}
1272+
size_t sl = strlen(seg);
1273+
if (sl == 0) {
1274+
continue;
1275+
}
1276+
if (pos + sl + 2 >= sizeof(buf)) {
1277+
return NULL; /* oversized — leave path un-prefixed */
1278+
}
1279+
buf[pos++] = '/';
1280+
memcpy(buf + pos, seg, sl);
1281+
pos += sl;
1282+
while (pos > 1 && buf[pos - 1] == '/') {
1283+
pos--; /* strip trailing slash per segment */
1284+
}
1285+
}
1286+
buf[pos] = '\0';
1287+
return pos ? cbm_arena_strndup(a, buf, pos) : NULL;
1288+
}
1289+
11681290
static char *extract_callee_name(CBMArena *a, TSNode node, const char *source, CBMLanguage lang) {
11691291
// Lean 4: skip type-position applies
11701292
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
12081330
}
12091331
}
12101332

1333+
/* #952: PHP facade route registrations (`Route::get(...)`, a
1334+
* scoped_call_expression) must carry the scope in the callee text — the
1335+
* empty-resolution route fallback keys on the "::get" suffix table, and
1336+
* the bare "get" that generic field resolution would return deliberately
1337+
* never matches (every $obj->get() would become a route). Runs BEFORE
1338+
* field-based resolution, which short-circuits on the `name` field.
1339+
* Gated to the literal `Route` scope AND a route-method match: any other
1340+
* scope (Cache::get) would suffix-match "::get" too and mint junk routes
1341+
* from slash-prefixed keys. Known limitation: aliased facade imports are
1342+
* not recognized. */
1343+
if (lang == CBM_LANG_PHP && strcmp(ts_node_type(node), "scoped_call_expression") == 0) {
1344+
TSNode scope = ts_node_child_by_field_name(node, TS_FIELD("scope"));
1345+
TSNode mname = ts_node_child_by_field_name(node, TS_FIELD("name"));
1346+
if (!ts_node_is_null(scope) && !ts_node_is_null(mname)) {
1347+
char *sc = cbm_node_text(a, scope, source);
1348+
char *mn = cbm_node_text(a, mname, source);
1349+
if (sc && mn && strcmp(sc, "Route") == 0) {
1350+
char *qual = cbm_arena_sprintf(a, "%s::%s", sc, mn);
1351+
if (qual && cbm_service_pattern_route_method(qual) != NULL) {
1352+
return qual;
1353+
}
1354+
}
1355+
}
1356+
}
1357+
12111358
// Try common field-based resolution first
12121359
char *name = extract_callee_from_fields(a, node, source);
12131360
if (name) {
@@ -1919,6 +2066,24 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk
19192066
TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments"));
19202067
if (!ts_node_is_null(args)) {
19212068
call.first_string_arg = extract_url_or_topic_arg(ctx, args);
2069+
/* #952: routes registered inside Laravel `prefix()->group()`
2070+
* closures must carry the composed path — the resolve passes
2071+
* only see the flat CBMCall, so the enclosing chain can only
2072+
* be read here where the AST still exists. */
2073+
if (ctx->language == CBM_LANG_PHP && call.first_string_arg &&
2074+
call.first_string_arg[0] == '/' && call.callee_name &&
2075+
cbm_service_pattern_route_method(call.callee_name) != NULL) {
2076+
const char *gp = php_group_prefix_for_call(ctx->arena, node, ctx->source);
2077+
if (gp && gp[0]) {
2078+
const char *rel = call.first_string_arg;
2079+
while (*rel == '/') {
2080+
rel++;
2081+
}
2082+
call.first_string_arg =
2083+
rel[0] ? cbm_arena_sprintf(ctx->arena, "%s/%s", gp, rel)
2084+
: cbm_arena_strndup(ctx->arena, gp, strlen(gp));
2085+
}
2086+
}
19222087
if (call.first_string_arg && call.first_string_arg[0] == '/') {
19232088
call.second_arg_name = extract_handler_arg(ctx, args);
19242089
}

‎src/pipeline/pass_calls.c‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,18 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
525525
* Native `fetch()` (#856) belongs here too, not in the substring
526526
* tables above: it only counts as the global API once resolution has
527527
* already failed to find a local/imported `fetch` definition. */
528+
/* Route registration on an unresolvable callee (#952): facade-style
529+
* Laravel (`Route::get('/x', ...)`) — the facade class lives in
530+
* vendor/ and is never indexed, so resolution is ALWAYS empty in real
531+
* apps. Classify by callee suffix + path-shaped first arg, exactly
532+
* like the parallel path's callee_suffix fallback; without this the
533+
* sequential path minted zero Route nodes for such files. */
534+
if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg &&
535+
call->first_string_arg[0] == '/') {
536+
handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals,
537+
imp_count);
538+
return SKIP_ONE;
539+
}
528540
cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name);
529541
if (esvc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) {
530542
esvc = CBM_SVC_HTTP;

‎tests/test_edge_types_probe.c‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,52 @@ TEST(handles_laravel_php) {
435435
PASS();
436436
}
437437

438+
/* #952: facade-style Laravel — the ONLY style real apps use, since the
439+
* Illuminate facade lives in vendor/ and is never indexed. The callee of
440+
* `Route::get(...)` (scoped_call_expression) was extracted as bare "get", so
441+
* the empty-resolution route fallback ("::get" suffix table) never engaged
442+
* and NO Route node minted — not even for flat registrations. Grouped routes
443+
* additionally need the enclosing `prefix('/x')->group(...)` chain composed
444+
* (same class as Spring's #734). Exact-set assertion distinguishes
445+
* prefix-dropped from missing entirely. */
446+
TEST(handles_laravel_facade_routes_issue952) {
447+
static const char *routes[] = {"/api/welcome", "/users/me", "/users/login",
448+
"/companies/tenants/list", NULL};
449+
static const EtFile f[] = {
450+
{"routes/api.php",
451+
"<?php\nuse Illuminate\\Support\\Facades\\Route;\n\n"
452+
"Route::get('/api/welcome', WelcomeController::class);\n"
453+
"Route::prefix('/users')->middleware(DomainCheckMiddleware::class)"
454+
"->group(function (): void {\n"
455+
" Route::get('/me', GetCurrentUserController::class);\n"
456+
" Route::post('/login', LoginUserController::class);\n"
457+
"});\n"
458+
"Route::prefix('companies')->group(function (): void {\n"
459+
" Route::prefix('tenants')->group(function (): void {\n"
460+
" Route::get('/list', ListTenantsController::class);\n"
461+
" });\n"
462+
"});\n"}};
463+
ASSERT_TRUE(et_routes_exact(f, 1, routes));
464+
PASS();
465+
}
466+
467+
/* #952 inverse guard: a non-router static call whose method name collides
468+
* with a route verb (Cache::get) must NOT mint a Route node — the callee
469+
* qualification is route-table-gated and the path gate requires a leading
470+
* slash. */
471+
TEST(handles_laravel_facade_no_junk_routes_issue952) {
472+
static const char *routes[] = {"/real", NULL};
473+
static const EtFile f[] = {
474+
{"routes/api.php",
475+
"<?php\nuse Illuminate\\Support\\Facades\\Route;\n"
476+
"use Illuminate\\Support\\Facades\\Cache;\n\n"
477+
"Route::get('/real', RealController::class);\n"
478+
"$v = Cache::get('users.count');\n"
479+
"$w = Cache::get('/leading/slash/key');\n"}};
480+
ASSERT_TRUE(et_routes_exact(f, 1, routes));
481+
PASS();
482+
}
483+
438484
/* Rails (Ruby) — ActionDispatch router. The handler MUST be passed as a bare
439485
* identifier (not the idiomatic `to: 'list_items'` string, which extract_handler_arg
440486
* cannot capture). mapper.get resolves by name to the Mapper#get method whose QN
@@ -1453,6 +1499,8 @@ SUITE(edge_types_probe) {
14531499
RUN_TEST(handles_spring_kotlin);
14541500
RUN_TEST(handles_aspnet_csharp);
14551501
RUN_TEST(handles_laravel_php);
1502+
RUN_TEST(handles_laravel_facade_routes_issue952);
1503+
RUN_TEST(handles_laravel_facade_no_junk_routes_issue952);
14561504
RUN_TEST(handles_rails_ruby);
14571505
RUN_TEST(handles_actix_rust);
14581506

0 commit comments

Comments
 (0)