fix(routes): HANDLES for Laravel array and invokable controller actions

Why: Laravel names a route's controller as [UserController::class, 'show'],
or, for an invokable controller, as GetCurrentUserController::class. The
route handler scan only took identifiers and strings, so these routes never
got a HANDLES edge. The cross-repo HTTP matcher reaches a route's handler
through that edge, so apps written this way - the #1146 reporter's app
among them - produced 0 CROSS_HTTP_CALLS. Resolving the class by short name
is not enough either: the call pass's import map bound `use
App\Http\Controllers\UserController` to Admin\UserController by name, even
with a composer.json PSR-4 map present, and the generic resolver's
multi-candidate fallback bound a vendor controller's show() to another
class's show().

Fix:
- extract_calls.c: for PHP, [X::class, 'm'] becomes the handler reference
  "Ns\X::m" and X::class becomes "Ns\X::__invoke". X is qualified the way PHP
  resolves it: through the file's `use` imports (aliases included), else
  the file namespace; a leading backslash is already absolute.
- registry.c, cbm_registry_resolve_handler (both route passes): a
  class-qualified handler is placed on a method QN only by what places the
  class file - the composer.json PSR-4 root in the package map, else the one
  candidate whose folders mirror the namespace (App\Http\Controllers ->
  app/Http/Controllers). A class that is not in the repo gets no handler.
  A handler reference without "::" resolves exactly as before.

Proof (release CLI built from this tree vs origin/main 1f9b4db1):
- laravel.io: HANDLES 0 -> 4, one for each of the 5 route registrations
  main mints (two share a Route). The use statement tells
  Articles\ArticlesController apart from Admin\ArticlesController.
  Route count unchanged (39). With the slashless-path and withRouting
  fixes also applied: HANDLES 0 -> 58, all from PHP controllers, samples
  checked against source (aliased AdminArticlesController and
  AdminUsersController included); the 7 routes left without a handler use
  methods from a vendor trait (AuthenticatesUsers).
- Reporter-shaped cross-repo check (Laravel 11 withRouting api:, invokable
  GetCurrentUserController under prefix('/users'), TS fetch('/api/users/me')):
  CROSS_HTTP_CALLS 0 with this fix alone (route /users/me, no /api mount) and
  0 with the withRouting fix alone (no HANDLES); 2 with both applied.
- krayin/laravel-crm (reduced to one route file): / <- Webkul\Admin\Http\
  Controllers\Controller::redirectToLogin, placed through the PSR-4 root
  Webkul\Admin\ -> packages/Webkul/Admin/src.

Tests (edge_types_probe): handles_laravel_class_handlers_issue1146,
handles_laravel_class_handlers_parallel_issue1146 and
handles_laravel_class_handlers_no_composer_issue1146 assert the exact HANDLES
set. They include controls for a same-named Admin\UserController::show, an
alias import, an unimported fully-qualified class, a package under a PSR-4
root whose folder does not mirror its namespace, a decoy __invoke, and two
vendor controllers that must get no edge. RED on origin/main 3x, green, RED
on revert. edge_types_probe, php_lsp, pipeline, parallel, route_canon and
cross_repo pass (730); make lint-ci passes.

Refs #1146

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-04 20:46:42 +02:00
parent 100ea8f3b2
commit 4e490bef5a
6 changed files with 477 additions and 3 deletions
+124
View File
@@ -2553,6 +2553,123 @@ static const char *normalize_string_handler(CBMArena *a, const char *raw) {
return unq;
}
/* True for a PHP class/namespace name: identifier segments joined by '\'. */
static bool php_is_class_path(const char *s) {
if (!s || !s[0]) {
return false;
}
for (; *s; s++) {
if (!((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || (*s >= '0' && *s <= '9') ||
*s == '_' || *s == '\\')) {
return false;
}
}
return true;
}
/* Qualify a class name the way PHP resolves it: a leading '\' is already
* absolute; a first segment that a `use` clause imports (by alias or by last
* segment) is replaced by the imported name; anything else is relative to the
* file's namespace. Imports and the namespace are extracted before the call
* walk, so both are complete here. */
static const char *php_qualify_class(CBMExtractCtx *ctx, const char *written) {
if (written[0] == '\\') {
return written + SKIP_ONE;
}
const char *sep = strchr(written, '\\');
size_t first_len = sep ? (size_t)(sep - written) : strlen(written);
const CBMImportArray *imps = &ctx->result->imports;
for (int i = 0; i < imps->count; i++) {
const CBMImport *imp = &imps->items[i];
const char *path = imp->module_path;
if (!imp->local_name || !path || strlen(imp->local_name) != first_len ||
strncmp(imp->local_name, written, first_len) != 0) {
continue;
}
path += path[0] == '\\' ? SKIP_ONE : 0;
if (php_is_class_path(path)) {
return cbm_arena_sprintf(ctx->arena, "%s%s", path, sep ? sep : "");
}
}
const char *ns = ctx->result->namespace_name;
return ns && ns[0] ? cbm_arena_sprintf(ctx->arena, "%s\\%s", ns, written) : written;
}
/* The fully-qualified class a PHP `X::class` expression names (`use` imports
* and the file namespace applied). NULL for any other expression, including
* `static::class` and `self::class`. */
static const char *php_class_literal_fqn(CBMExtractCtx *ctx, TSNode node) {
if (strcmp(ts_node_type(node), "class_constant_access_expression") != 0 ||
ts_node_named_child_count(node) != PAIR_LEN) {
return NULL;
}
TSNode cls = ts_node_named_child(node, 0);
const char *ck = ts_node_type(cls);
const char *member =
cbm_node_text(ctx->arena, ts_node_named_child(node, SKIP_ONE), ctx->source);
if ((strcmp(ck, "name") != 0 && strcmp(ck, "qualified_name") != 0) || !member ||
strcmp(member, "class") != 0) {
return NULL;
}
const char *text = cbm_node_text(ctx->arena, cls, ctx->source);
return php_is_class_path(text) ? php_qualify_class(ctx, text) : NULL;
}
/* The value of a PHP array element written without a key; a null node for a
* keyed element (`'uses' => ...`) or anything else. */
static TSNode php_unkeyed_element_value(TSNode elem) {
TSNode none = {0};
if (strcmp(ts_node_type(elem), "array_element_initializer") != 0 ||
ts_node_named_child_count(elem) != SKIP_ONE) {
return none;
}
return ts_node_named_child(elem, 0);
}
static bool php_is_identifier(const char *s) {
if (!s || !((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || *s == '_')) {
return false;
}
for (; *s; s++) {
if (!((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || (*s >= '0' && *s <= '9') ||
*s == '_')) {
return false;
}
}
return true;
}
/* Laravel's class-based route handlers (#1146), as "Ns\Class::method":
* [UserController::class, 'show'] -> App\Http\Controllers\UserController::show
* GetCurrentUserController::class -> App\...\GetCurrentUserController::__invoke
* with the class qualified through the file's `use` imports; the route passes
* place it on a method QN (cbm_registry_resolve_handler). NULL for any other
* expression. */
static const char *php_class_route_handler(CBMExtractCtx *ctx, TSNode arg) {
const char *kind = ts_node_type(arg);
if (strcmp(kind, "class_constant_access_expression") == 0) {
const char *cls = php_class_literal_fqn(ctx, arg);
return cls ? cbm_arena_sprintf(ctx->arena, "%s::__invoke", cls) : NULL;
}
if (strcmp(kind, "array_creation_expression") != 0 ||
ts_node_named_child_count(arg) != PAIR_LEN) {
return NULL;
}
TSNode cls_node = php_unkeyed_element_value(ts_node_named_child(arg, 0));
TSNode method_node = php_unkeyed_element_value(ts_node_named_child(arg, SKIP_ONE));
if (ts_node_is_null(cls_node) || ts_node_is_null(method_node)) {
return NULL;
}
const char *cls = php_class_literal_fqn(ctx, cls_node);
const char *mk = ts_node_type(method_node);
if (!cls || (strcmp(mk, "string") != 0 && strcmp(mk, "encapsed_string") != 0)) {
return NULL;
}
const char *method =
strip_quotes(ctx->arena, cbm_node_text(ctx->arena, method_node, ctx->source));
return php_is_identifier(method) ? cbm_arena_sprintf(ctx->arena, "%s::%s", cls, method) : NULL;
}
static const char *extract_handler_arg(CBMExtractCtx *ctx, TSNode args) {
/* The LAST eligible argument wins, and every argument is examined.
* Express, Fastify, gin and Laravel all put middleware between the route
@@ -2570,6 +2687,13 @@ static const char *extract_handler_arg(CBMExtractCtx *ctx, TSNode args) {
if (strcmp(ts_node_type(arg2), "argument") == 0 && ts_node_named_child_count(arg2) > 0) {
arg2 = ts_node_named_child(arg2, 0);
}
if (ctx->language == CBM_LANG_PHP) {
const char *class_handler = php_class_route_handler(ctx, arg2);
if (class_handler) {
handler = class_handler;
continue;
}
}
const char *ak2 = ts_node_type(arg2);
/* `name` = PHP bare identifier handler; string = Laravel string handler
* ('showUsers' or 'Controller@method'). */
+3 -2
View File
@@ -404,8 +404,9 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca
esc_fa);
cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props);
if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') {
cbm_resolution_t hres = cbm_registry_resolve(ctx->registry, call->second_arg_name,
module_qn, imp_keys, imp_vals, imp_count);
cbm_resolution_t hres =
cbm_registry_resolve_handler(ctx->registry, call->second_arg_name, module_qn, imp_keys,
imp_vals, imp_count, cbm_pipeline_get_pkgmap());
if (hres.qualified_name != NULL && hres.qualified_name[0] != '\0') {
const cbm_gbuf_node_t *handler = cbm_gbuf_find_by_qn(ctx->gbuf, hres.qualified_name);
if (handler != NULL) {
+2 -1
View File
@@ -2295,7 +2295,8 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou
esc_rp);
cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props);
if (handler_ref && handler_ref[0] != '\0') {
cbm_resolution_t hres = cbm_registry_resolve(registry, handler_ref, module_qn, ik, iv, ic);
cbm_resolution_t hres = cbm_registry_resolve_handler(registry, handler_ref, module_qn, ik,
iv, ic, cbm_pipeline_get_pkgmap());
if (hres.qualified_name && hres.qualified_name[0] != '\0') {
const cbm_gbuf_node_t *h = cbm_gbuf_find_by_qn(main_gbuf, hres.qualified_name);
if (h) {
+14
View File
@@ -208,6 +208,20 @@ static inline int cbm_pipeline_relpath_is_excluded(const char *rel_path, char *c
CBMHashTable *cbm_pipeline_get_pkgmap(void);
void cbm_pipeline_set_pkgmap(CBMHashTable *map);
/* Resolve a route handler reference (the registry's answer for route passes).
* A class-qualified "Ns\\Class::method" reference — the extractor's form for
* Laravel `[Class::class, 'method']` and invokable `Class::class` handlers
* (#1146), with the class already qualified through the file's `use` imports
* and namespace — is placed on a method QN only by what places the class file:
* a composer.json PSR-4 root in psr4_roots (the package map), else the one
* candidate whose directories end with the class's namespace. Never by short
* name alone, so a vendor controller that is not in the repo gets no handler.
* A reference without "::" resolves exactly as cbm_registry_resolve does. */
cbm_resolution_t cbm_registry_resolve_handler(const cbm_registry_t *r, const char *handler_ref,
const char *module_qn, const char **import_map_keys,
const char **import_map_vals, int import_map_count,
const CBMHashTable *psr4_roots);
/* Unified module resolver: relative → pkgmap → fqn_module fallback.
* Handles bare specifiers via pkgmap lookup with prefix matching.
* Caller must free() the returned string. */
+168
View File
@@ -25,6 +25,7 @@ enum { REG_MAX_CANDIDATES = 256 };
#define DEFAULT_CONFIDENCE 0.5
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "cbm.h" /* cbm_label_is_relation — the resolve-time relation veto */
#include "foundation/compat.h" /* CBM_TLS */
#include "foundation/hash_table.h"
@@ -1462,6 +1463,173 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle
return res;
}
/* ── Class-qualified route handlers (PHP, #1146) ─────────────────── */
/* Method QNs are path-based: <project>.<dirs>.<file stem>.<Class>.<method>.
* A PHP class is named by its namespace instead, so a "Ns\\Class::method"
* handler is placed on a QN by what places the class file itself. */
#define CONF_PSR4_MEMBER 0.95
#define CONF_NAMESPACE_PATH_MEMBER 0.90
static bool seg_eq_nocase(const char *a, size_t alen, const char *b, size_t blen) {
if (alen != blen) {
return false;
}
for (size_t i = 0; i < alen; i++) {
char ca = (char)((a[i] >= 'A' && a[i] <= 'Z') ? a[i] - 'A' + 'a' : a[i]);
char cb = (char)((b[i] >= 'A' && b[i] <= 'Z') ? b[i] - 'A' + 'a' : b[i]);
if (ca != cb) {
return false;
}
}
return true;
}
/* Does qn end with ".<cls>.<member>"? Returns the offset of that tail or -1. */
static int qn_class_member_tail(const char *qn, const char *cls, size_t cls_len,
const char *member) {
size_t qlen = strlen(qn);
size_t mlen = strlen(member);
if (qlen < cls_len + mlen + PAIR_LEN) {
return CBM_NOT_FOUND;
}
const char *m = qn + qlen - mlen;
const char *c = m - SKIP_ONE - cls_len;
if (strcmp(m, member) != 0 || m[-SKIP_ONE] != '.' || strncmp(c, cls, cls_len) != 0 ||
c[-SKIP_ONE] != '.') {
return CBM_NOT_FOUND;
}
return (int)(c - SKIP_ONE - qn);
}
/* PSR-4: the longest composer.json autoload root "Prefix\\" that prefixes the
* FQN maps the remaining namespace onto directories below the root's folder
* and the class onto <Class>.php — the file Composer's autoloader loads.
* Returns the registered method QN, or NULL (no root prefixes the FQN, or the
* autoloaded file holds no such method). */
static const char *psr4_member_qn(const cbm_registry_t *r, const CBMHashTable *psr4_roots,
const char *fqn, size_t fqn_len, const char *cls, size_t cls_len,
const char *member) {
if (!psr4_roots || fqn_len >= CBM_SZ_512) {
return NULL;
}
char key[CBM_SZ_512];
for (size_t cut = fqn_len; cut > 0; cut--) {
if (fqn[cut - SKIP_ONE] != '\\') {
continue;
}
memcpy(key, fqn, cut); /* "App\\" / "App\\Http\\" — keeps the trailing '\\' */
key[cut] = '\0';
const char *root_qn = (const char *)cbm_ht_get(psr4_roots, key);
if (!root_qn) {
continue;
}
char qn[CBM_SZ_1K];
int n = snprintf(qn, sizeof(qn), "%s.%.*s.%.*s.%s", root_qn, (int)(fqn_len - cut),
fqn + cut, (int)cls_len, cls, member);
if (n <= 0 || (size_t)n >= sizeof(qn)) {
return NULL;
}
for (char *p = qn + strlen(root_qn); *p; p++) {
if (*p == '\\') {
*p = '.';
}
}
return cbm_ht_get_key(r->exact, qn);
}
return NULL;
}
/* Do the directory segments of qn (between the project root segment and the
* file stem, i.e. before offset tail = ".<Class>.<member>") end with every
* segment of the namespace fqn[0, ns_len)? Case-insensitive: App\\Http is
* conventionally app/Http. An empty namespace is trivially aligned. */
static bool namespace_matches_dirs(const char *qn, int tail, const char *fqn, size_t ns_len) {
const char *root_dot = strchr(qn, '.');
const char *stem_end = qn + tail;
if (!root_dot || stem_end <= root_dot) {
return false;
}
const char *dir_end = stem_end - SKIP_ONE; /* back over the file stem */
while (*dir_end != '.') {
dir_end--;
}
size_t ns_end = ns_len;
while (ns_end > 0) {
size_t ns_start = ns_end;
while (ns_start > 0 && fqn[ns_start - SKIP_ONE] != '\\') {
ns_start--;
}
if (dir_end <= root_dot) {
return false; /* more namespace segments than directories */
}
const char *dir_start = dir_end;
while (dir_start[-SKIP_ONE] != '.') {
dir_start--;
}
if (!seg_eq_nocase(fqn + ns_start, ns_end - ns_start, dir_start,
(size_t)(dir_end - dir_start))) {
return false;
}
dir_end = dir_start - SKIP_ONE;
ns_end = ns_start > 0 ? ns_start - SKIP_ONE : 0;
}
return true;
}
/* Namespace/path alignment: the one "<Class>.<member>" candidate that sits
* where its namespace says. NULL when none or several do. */
static const char *aligned_member_qn(const cbm_registry_t *r, const char *fqn, size_t ns_len,
const char *cls, size_t cls_len, const char *member) {
const qn_array_t *arr = cbm_ht_get(r->by_name, member);
const char *match = NULL;
for (int i = 0; arr && i < arr->count; i++) {
const char *qn = arr->items[i];
int tail = qn_class_member_tail(qn, cls, cls_len, member);
if (tail == CBM_NOT_FOUND || !namespace_matches_dirs(qn, tail, fqn, ns_len)) {
continue;
}
if (match) {
return NULL; /* ambiguous */
}
match = qn;
}
return match;
}
cbm_resolution_t cbm_registry_resolve_handler(const cbm_registry_t *r, const char *handler_ref,
const char *module_qn, const char **import_map_keys,
const char **import_map_vals, int import_map_count,
const CBMHashTable *psr4_roots) {
const char *sep = handler_ref ? strstr(handler_ref, "::") : NULL;
if (!r || !sep) {
return cbm_registry_resolve(r, handler_ref, module_qn, import_map_keys, import_map_vals,
import_map_count);
}
const char *member = sep + PAIR_LEN;
size_t fqn_len = (size_t)(sep - handler_ref);
size_t ns_len = fqn_len;
while (ns_len > 0 && handler_ref[ns_len - SKIP_ONE] != '\\') {
ns_len--;
}
const char *cls = handler_ref + ns_len;
size_t cls_len = fqn_len - ns_len;
ns_len = ns_len > 0 ? ns_len - SKIP_ONE : 0; /* drop the separator before the class */
if (cls_len == 0 || !member[0]) {
return empty_result();
}
const char *qn = psr4_member_qn(r, psr4_roots, handler_ref, fqn_len, cls, cls_len, member);
if (qn) {
return (cbm_resolution_t){qn, "php_psr4", CONF_PSR4_MEMBER, REG_RESOLVED};
}
qn = aligned_member_qn(r, handler_ref, ns_len, cls, cls_len, member);
if (qn) {
return (cbm_resolution_t){qn, "php_namespace_path", CONF_NAMESPACE_PATH_MEMBER,
REG_RESOLVED};
}
return empty_result();
}
cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const char *callee_name,
const char *module_qn, const char **import_map_keys,
const char **import_map_vals, int import_map_count) {
+166
View File
@@ -922,6 +922,169 @@ TEST(routes_laravel_withrouting_parallel_subdir_issue1146) {
PASS();
}
/* Assert the exact HANDLES edge set as "<handler-QN tail> -> <route name>"
* pairs. The tail is matched at a segment boundary against the handler's
* qualified name ("UserController.show" matches
* "<project>.app.Http.Controllers.UserController.UserController.show"), so a
* same-named method on another class never satisfies it; the total count must
* match too, so a fabricated extra edge fails the assertion. */
typedef struct {
const char *handler_tail;
const char *route;
} EtHandles;
static int et_qn_has_tail(const char *qn, const char *tail) {
size_t ql = qn ? strlen(qn) : 0;
size_t tl = strlen(tail);
return ql >= tl && strcmp(qn + ql - tl, tail) == 0 && (ql == tl || qn[ql - tl - 1] == '.');
}
static int et_handles_exact(const EtFile *files, int nfiles, const EtHandles *want, int parallel) {
EtProj lp;
cbm_store_t *store =
parallel ? et_index_parallel(&lp, files, nfiles) : et_index_files(&lp, files, nfiles);
int wanted = 0;
int found[ET_ROUTE_ASSERT_MAX] = {0};
while (want[wanted].handler_tail && wanted < ET_ROUTE_ASSERT_MAX) {
wanted++;
}
cbm_edge_t *edges = NULL;
int n = 0;
int ok = store != NULL &&
cbm_store_find_edges_by_type(store, lp.project, "HANDLES", &edges, &n) ==
CBM_STORE_OK &&
n == wanted;
for (int i = 0; store && i < n; i++) {
cbm_node_t src = {0};
cbm_node_t tgt = {0};
int have_src = cbm_store_find_node_by_id(store, edges[i].source_id, &src) == CBM_STORE_OK;
int have_tgt = cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) == CBM_STORE_OK;
int matched = 0;
for (int wi = 0; have_src && have_tgt && wi < wanted; wi++) {
if (!found[wi] && tgt.name && strcmp(tgt.name, want[wi].route) == 0 &&
et_qn_has_tail(src.qualified_name, want[wi].handler_tail)) {
found[wi] = matched = 1;
break;
}
}
if (!matched) {
ok = 0;
fprintf(stderr, " [ET-HANDLES] unexpected %s -> %s\n",
have_src && src.qualified_name ? src.qualified_name : "<?>",
have_tgt && tgt.name ? tgt.name : "<?>");
}
if (have_src) cbm_node_free_fields(&src);
if (have_tgt) cbm_node_free_fields(&tgt);
}
for (int wi = 0; wi < wanted; wi++) {
if (!found[wi]) {
ok = 0;
fprintf(stderr, " [ET-HANDLES] missing %s -> %s\n", want[wi].handler_tail,
want[wi].route);
}
}
if (!ok) {
fprintf(stderr, " [ET-HANDLES] FAIL (%s path) expected=%d actual=%d\n",
parallel ? "parallel" : "sequential", wanted, n);
}
if (edges) cbm_store_free_edges(edges, n);
et_cleanup(&lp, store);
return ok;
}
/* #1146: Laravel's two class-based handler forms —
* Route::get('/x', [UserController::class, 'show']) (controller method)
* Route::get('/x', GetCurrentUserController::class) (invokable: __invoke)
* got no HANDLES edge, because the handler scan only took identifiers and
* strings. The cross-repo matcher needs HANDLES on the Route, so apps written
* this way (the #1146 reporter's included) produced 0 CROSS_HTTP_CALLS.
* Controls: an Admin\UserController::show with the same short name (the `use`
* statement picks the class), an alias import, a fully-qualified class that is
* not imported, a package class under a PSR-4 root whose folder does not
* mirror its namespace (Acme\Blog\ -> packages/blog/src/), a decoy __invoke on
* another class, and two vendor classes that are not in the repo — those must
* get no HANDLES edge at all rather than bind to some other class's
* same-named method. composer.json comes first so the no-composer variant
* below can index the same fixture without it. */
static const EtFile et_laravel_class_handlers[] = {
{"composer.json", "{\"autoload\": {\"psr-4\": {\"App\\\\\": \"app/\", "
"\"Acme\\\\Blog\\\\\": \"packages/blog/src/\"}}}\n"},
{"packages/blog/src/Http/PostsController.php",
"<?php\nnamespace Acme\\Blog\\Http;\n\n"
"class PostsController {\n"
" public function index() { return ['posts' => []]; }\n}\n"},
{"app/Http/Controllers/UserController.php",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"class UserController {\n"
" public function show($id) { return ['id' => $id]; }\n"
" public function index() { return []; }\n}\n"},
{"app/Http/Controllers/Admin/UserController.php",
"<?php\nnamespace App\\Http\\Controllers\\Admin;\n\n"
"class UserController {\n"
" public function show($id) { return ['admin' => $id]; }\n}\n"},
{"app/Http/Controllers/PostController.php",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"class PostController {\n"
" public function show($id) { return ['post' => $id]; }\n}\n"},
{"app/Http/Controllers/GetCurrentUserController.php",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"class GetCurrentUserController {\n"
" public function __invoke() { return ['me' => true]; }\n}\n"},
{"app/Http/Controllers/HealthController.php",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"class HealthController {\n"
" public function __invoke() { return ['ok' => true]; }\n}\n"},
{"routes/api.php",
"<?php\n"
"use App\\Http\\Controllers\\UserController;\n"
"use App\\Http\\Controllers\\GetCurrentUserController as CurrentUser;\n"
"use Acme\\Blog\\Http\\PostsController;\n"
"use Illuminate\\Support\\Facades\\Route;\n\n"
"Route::get('/users/{id}', [UserController::class, 'show']);\n"
"Route::get('/me', CurrentUser::class);\n"
"Route::get('/posts/{id}', [\\App\\Http\\Controllers\\PostController::class, 'show']);\n"
"Route::get('/blog', [PostsController::class, 'index']);\n"
"Route::get('/vendor/{id}', [VendorController::class, 'show']);\n"
"Route::get('/vendor-ping', VendorInvokable::class);\n"}};
enum { ET_LARAVEL_CLASS_FILES = 8 };
static const EtHandles et_laravel_class_handles[] = {
{"Controllers.UserController.UserController.show", "/users/{id}"},
{"GetCurrentUserController.__invoke", "/me"},
{"PostController.show", "/posts/{id}"},
{"PostsController.index", "/blog"},
{NULL, NULL}};
TEST(handles_laravel_class_handlers_issue1146) {
ASSERT_TRUE(et_handles_exact(et_laravel_class_handlers, ET_LARAVEL_CLASS_FILES,
et_laravel_class_handles, 0));
PASS();
}
/* Same fixture through the parallel pipeline (> MIN_FILES_FOR_PARALLEL),
* whose route emitter (pass_parallel.c) is a separate code path. */
TEST(handles_laravel_class_handlers_parallel_issue1146) {
ASSERT_TRUE(et_handles_exact(et_laravel_class_handlers, ET_LARAVEL_CLASS_FILES,
et_laravel_class_handles, 1));
PASS();
}
/* Without composer.json there is no PSR-4 map: a class is placed only where
* its namespace mirrors the folders (App\Http\Controllers -> app/Http/
* Controllers). The package class, whose folder does not mirror its
* namespace, then gets no handler rather than a guessed one. */
TEST(handles_laravel_class_handlers_no_composer_issue1146) {
static const EtHandles want[] = {
{"Controllers.UserController.UserController.show", "/users/{id}"},
{"GetCurrentUserController.__invoke", "/me"},
{"PostController.show", "/posts/{id}"},
{NULL, NULL}};
ASSERT_TRUE(
et_handles_exact(et_laravel_class_handlers + 1, ET_LARAVEL_CLASS_FILES - 1, want, 0));
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
@@ -2138,6 +2301,9 @@ SUITE(edge_types_probe) {
RUN_TEST(routes_laravel_routeserviceprovider_control_issue1146);
RUN_TEST(routes_laravel_withrouting_nonliteral_prefix_issue1146);
RUN_TEST(routes_laravel_withrouting_parallel_subdir_issue1146);
RUN_TEST(handles_laravel_class_handlers_issue1146);
RUN_TEST(handles_laravel_class_handlers_parallel_issue1146);
RUN_TEST(handles_laravel_class_handlers_no_composer_issue1146);
RUN_TEST(handles_rails_ruby);
RUN_TEST(handles_actix_rust);