diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index f14efb766..4da0ece7a 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -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'). */ diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 668919682..53f57025a 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -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) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a8a9f3693..dfaea7161 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -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) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 7f266eae9..451776807 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -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. */ diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index ff56a41ac..4159c19f4 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -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: ..... + * 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 ".."? 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 .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 = "..") 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 "." 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) { diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 5ed449b8d..999019c45 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -922,6 +922,169 @@ TEST(routes_laravel_withrouting_parallel_subdir_issue1146) { PASS(); } +/* Assert the exact HANDLES edge set as " -> " + * pairs. The tail is matched at a segment boundary against the handler's + * qualified name ("UserController.show" matches + * ".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", + " []]; }\n}\n"}, + {"app/Http/Controllers/UserController.php", + " $id]; }\n" + " public function index() { return []; }\n}\n"}, + {"app/Http/Controllers/Admin/UserController.php", + " $id]; }\n}\n"}, + {"app/Http/Controllers/PostController.php", + " $id]; }\n}\n"}, + {"app/Http/Controllers/GetCurrentUserController.php", + " true]; }\n}\n"}, + {"app/Http/Controllers/HealthController.php", + " true]; }\n}\n"}, + {"routes/api.php", + " 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);