From 830f0ccfae65349b8fed2e92d37163b5cc34347c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 3 Oct 2026 17:56:11 +0200 Subject: [PATCH] fix(pipeline): resolve PSR-4 imports to exact class files Preserve import kinds and resolve class imports through Composer prefixes to the existing target class file, including autoload-dev mappings. Refs #1186 Signed-off-by: Martin Vogel --- internal/cbm/cbm.h | 10 ++ internal/cbm/extract_imports.c | 34 +++++- src/pipeline/pass_pkgmap.c | 137 +++++++++++++++++++++-- tests/test_edge_imports.c | 193 +++++++++++++++++++++++++++++++++ 4 files changed, 361 insertions(+), 13 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index fb5e446a5..12eb5572b 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -304,10 +304,20 @@ typedef struct { // guard's unique-name exemption. Default false. } CBMCall; +// What an import statement names, when the syntax says so. Only PHP's +// `use function` / `use const` set a non-default kind today: they name a +// namespace MEMBER, so they must never be mapped to a class file (#1186). +typedef enum { + CBM_IMPORT_KIND_DEFAULT = 0, // module / class / namespace (language default) + CBM_IMPORT_KIND_FUNCTION, // PHP `use function A\b` + CBM_IMPORT_KIND_CONST, // PHP `use const A\B` +} CBMImportKind; + typedef struct { const char *local_name; // local alias or name const char *module_path; // resolved module path / QN bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916) + CBMImportKind kind; // CBM_IMPORT_KIND_DEFAULT unless the syntax names a member kind } CBMImport; typedef enum { diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 7d416923c..4aa0baa75 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -1205,7 +1205,29 @@ static void parse_wolfram_imports(CBMExtractCtx *ctx) { // require/include forms remain `expression_statement`s and are still handled by // the text fallback. Take the first qualified_name/name descendant of each // clause as the module path. -static void emit_php_use_clause(CBMExtractCtx *ctx, TSNode clause, const char *group_prefix) { +// `use function` / `use const` carry the keyword as an anonymous direct child +// of the declaration (flat form) or of the clause (grouped form +// `use A\{function f, const C, K}`). DEFAULT when neither is present. +static CBMImportKind php_use_kind(TSNode node) { + uint32_t n = ts_node_child_count(node); + for (uint32_t i = 0; i < n; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_named(c)) { + continue; + } + const char *k = ts_node_type(c); + if (strcmp(k, "function") == 0) { + return CBM_IMPORT_KIND_FUNCTION; + } + if (strcmp(k, "const") == 0) { + return CBM_IMPORT_KIND_CONST; + } + } + return CBM_IMPORT_KIND_DEFAULT; +} + +static void emit_php_use_clause(CBMExtractCtx *ctx, TSNode clause, const char *group_prefix, + CBMImportKind decl_kind) { CBMArena *a = ctx->arena; // The path node is the qualified_name / namespace_name / name child. TSNode path_node = clause; @@ -1230,12 +1252,14 @@ static void emit_php_use_clause(CBMExtractCtx *ctx, TSNode clause, const char *g TSNode alias = ts_node_child_by_field_name(clause, TS_FIELD("alias")); const char *local = !ts_node_is_null(alias) ? cbm_node_text(a, alias, ctx->source) : path_last(a, path); - CBMImport imp = {.local_name = local, .module_path = path}; + CBMImportKind kind = decl_kind != CBM_IMPORT_KIND_DEFAULT ? decl_kind : php_use_kind(clause); + CBMImport imp = {.local_name = local, .module_path = path, .kind = kind}; cbm_imports_push(&ctx->result->imports, a, imp); } static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) { CBMArena *a = ctx->arena; + const CBMImportKind decl_kind = php_use_kind(decl); // Grouped form: namespace_use_group with a leading prefix qualified_name. TSNode group = decl; if (find_first_descendant_of(decl, "namespace_use_group", &group)) { @@ -1257,7 +1281,7 @@ static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) { const char *ck = ts_node_type(clause); if (strcmp(ck, "namespace_use_group_clause") == 0 || strcmp(ck, "namespace_use_clause") == 0) { - emit_php_use_clause(ctx, clause, prefix); + emit_php_use_clause(ctx, clause, prefix, decl_kind); } } return; @@ -1268,13 +1292,13 @@ static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) { for (uint32_t i = 0; i < dc; i++) { TSNode clause = ts_node_named_child(decl, i); if (strcmp(ts_node_type(clause), "namespace_use_clause") == 0) { - emit_php_use_clause(ctx, clause, NULL); + emit_php_use_clause(ctx, clause, NULL, decl_kind); any = true; } } if (!any) { // Some grammar versions inline the path directly under the declaration. - emit_php_use_clause(ctx, decl, NULL); + emit_php_use_clause(ctx, decl, NULL, decl_kind); } } diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index dc5e46db0..9c4ace14b 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -440,9 +440,12 @@ static void parse_pyproject_toml(const char *source, int source_len, const char } } -/* Extract PSR-4 autoload entries from composer.json root. */ -static void extract_psr4(yyjson_val *root, const char *dir, cbm_pkg_entries_t *entries) { - yyjson_val *autoload = yyjson_obj_get(root, "autoload"); +/* Extract PSR-4 entries from one composer.json autoload section ("autoload" + * or "autoload-dev": composer registers both, so test namespaces such as + * Tests\ map to their directory as well). */ +static void extract_psr4(yyjson_val *root, const char *section, const char *dir, + cbm_pkg_entries_t *entries) { + yyjson_val *autoload = yyjson_obj_get(root, section); if (!yyjson_is_obj(autoload)) { return; } @@ -459,6 +462,12 @@ static void extract_psr4(yyjson_val *root, const char *dir, cbm_pkg_entries_t *e } const char *ns_prefix = yyjson_get_str(key); const char *ns_dir = yyjson_get_str(val); + while (ns_dir[0] == '.' && ns_dir[SKIP_ONE] == '/') { + ns_dir += PAIR_LEN; /* "./src/" names src/ */ + } + if (strcmp(ns_dir, ".") == 0) { + ns_dir = ""; /* "." names the manifest's own directory */ + } char ns_entry[PKGMAP_PATH_BUF]; if (dir[0]) { snprintf(ns_entry, sizeof(ns_entry), "%s/%s", dir, ns_dir); @@ -497,7 +506,8 @@ static void parse_composer_json(const char *source, int source_len, const char * } } - extract_psr4(root, dir, entries); + extract_psr4(root, "autoload", dir, entries); + extract_psr4(root, "autoload-dev", dir, entries); free(dir); yyjson_doc_free(doc); @@ -987,6 +997,12 @@ bool cbm_pkgmap_try_parse(const char *basename, const char *rel_path, const char /* ── Merge: per-worker entries → hash table ────────────────────── */ +/* A composer.json `autoload.psr-4` key: a namespace prefix ending in '\\'. */ +static bool pkgmap_is_psr4_prefix(const char *pkg_name) { + size_t n = pkg_name ? strlen(pkg_name) : 0; + return n > 0 && pkg_name[n - SKIP_ONE] == '\\'; +} + CBMHashTable *cbm_pkgmap_build(cbm_pkg_entries_t *worker_entries, int worker_count, const char *project_name) { /* Count total entries */ @@ -1004,8 +1020,14 @@ CBMHashTable *cbm_pkgmap_build(cbm_pkg_entries_t *worker_entries, int worker_cou for (int w = 0; w < worker_count; w++) { cbm_pkg_entries_t *we = &worker_entries[w]; for (int i = 0; i < we->count; i++) { - /* Convert entry_rel to QN: project.dir.parts */ - char *qn = cbm_pipeline_fqn_module(project_name, we->items[i].entry_rel); + /* Convert entry_rel to QN: project.dir.parts. A PSR-4 namespace + * prefix (key ends in '\\') keeps its repo-relative DIRECTORY + * instead: its readers map `Prefix\Sub\Class` onto a file path + * (/Sub/Class.php), which a dotted QN cannot express (#1186). */ + const char *entry_rel = we->items[i].entry_rel; + char *qn = pkgmap_is_psr4_prefix(we->items[i].pkg_name) + ? cbm_strndup(entry_rel, strlen(entry_rel)) + : cbm_pipeline_fqn_module(project_name, entry_rel); if (!qn) { continue; } @@ -1355,7 +1377,8 @@ static char *resolve_dot_prefix(CBMHashTable *map, const char *module_path, } /* Try backslash-based prefix matching (PHP PSR-4: App\\Controllers\\Foo). - * Returns heap QN or NULL. */ + * The map value of a PSR-4 prefix is its repo-relative directory (see + * cbm_pkgmap_build). Returns heap QN or NULL. */ static char *resolve_backslash_prefix(CBMHashTable *map, const char *module_path, const char *project_name) { char *buf = strdup(module_path); @@ -1375,7 +1398,8 @@ static char *resolve_backslash_prefix(CBMHashTable *map, const char *module_path } const char *subpath = module_path + (size_t)(bs - buf) + SKIP_ONE; char path_result[PKGMAP_PATH_BUF]; - snprintf(path_result, sizeof(path_result), "%s/%s", base_dir, subpath); + snprintf(path_result, sizeof(path_result), "%s%s%s", base_dir, base_dir[0] ? "/" : "", + subpath); for (char *c = path_result; *c; c++) { if (*c == '\\') { *c = '/'; @@ -2025,6 +2049,91 @@ static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx return found; } +/* ── PHP PSR-4 class imports (#1186) ──────────────────────────────── */ + +typedef enum { + PSR4_NOT_APPLICABLE = 0, /* no composer psr-4 prefix covers the import */ + PSR4_RESOLVED, /* the class file exists: *out is its File node */ + PSR4_UNRESOLVED, /* a prefix covers it but no class file exists */ +} psr4_outcome_t; + +/* The File node at exactly `rel_path`, or NULL. Looked up through the name + * index (File nodes are named by basename) and matched on the full path, so + * a same-named file in another directory never qualifies. */ +static const cbm_gbuf_node_t *psr4_file_node(const cbm_pipeline_ctx_t *ctx, const char *rel_path) { + const cbm_gbuf_node_t **hits = NULL; + int hit_count = 0; + if (cbm_gbuf_find_by_name(ctx->gbuf, path_leaf(rel_path), &hits, &hit_count) != 0 || !hits) { + return NULL; + } + for (int i = 0; i < hit_count; i++) { + const cbm_gbuf_node_t *n = hits[i]; + if (n && n->label && strcmp(n->label, "File") == 0 && n->file_path && + strcmp(n->file_path, rel_path) == 0) { + return n; + } + } + return NULL; +} + +/* Resolve a PHP class import `use A\B\C;` the way composer's PSR-4 autoloader + * does: for every autoload.psr-4 prefix covering the name, longest first, the + * class lives in exactly /.php with sub-namespaces as + * subdirectories; the first existing file wins. When a prefix covers the name + * but no such file exists the import is UNRESOLVED: it must never fall through + * to the namespace bucket, which binds it to whichever file of the namespace + * came first. `use function` / `use const` name namespace members, not class + * files, so they are not applicable. */ +static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx, + const char *source_file_qn, const CBMImport *imp, + const cbm_gbuf_node_t **out) { + *out = NULL; + CBMHashTable *pkgmap = cbm_pipeline_get_pkgmap(); + if (!pkgmap || imp->kind != CBM_IMPORT_KIND_DEFAULT) { + return PSR4_NOT_APPLICABLE; + } + const char *name = imp->module_path; + if (name[0] == '\\') { + name++; /* fully qualified `use \App\X;` */ + } + char key[PKGMAP_PATH_BUF]; + size_t name_len = strlen(name); + if (name_len == 0 || name_len >= sizeof(key) || !strchr(name, '\\')) { + return PSR4_NOT_APPLICABLE; + } + bool covered = false; + /* Right to left: each '\' ends a candidate prefix, longest first. */ + for (size_t cut = name_len - SKIP_ONE; cut > 0; cut--) { + if (name[cut - SKIP_ONE] != '\\') { + continue; + } + memcpy(key, name, cut); + key[cut] = '\0'; + const char *dir = (const char *)cbm_ht_get(pkgmap, key); + if (!dir) { + continue; + } + covered = true; + char rel[PKGMAP_PATH_BUF]; + int w = snprintf(rel, sizeof(rel), "%s%s%s.php", dir, dir[0] ? "/" : "", name + cut); + if (w <= 0 || (size_t)w >= sizeof(rel)) { + continue; + } + for (char *c = rel; *c; c++) { + if (*c == '\\') { + *c = '/'; + } + } + const cbm_gbuf_node_t *file = psr4_file_node(ctx, rel); + if (file && (!source_file_qn || !file->qualified_name || + strcmp(file->qualified_name, source_file_qn) != 0)) { + *out = file; + return PSR4_RESOLVED; + } + } + return covered ? PSR4_UNRESOLVED : PSR4_NOT_APPLICABLE; +} + const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, @@ -2042,6 +2151,18 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t return header_target; } + /* PHP class imports covered by a composer psr-4 prefix name exactly one + * file; when it is absent the import stays unresolved (#1186). */ + const cbm_gbuf_node_t *psr4_target = NULL; + switch (resolve_php_psr4_class(ctx, source_file_qn, imp, &psr4_target)) { + case PSR4_RESOLVED: + return psr4_target; + case PSR4_UNRESOLVED: + return NULL; + case PSR4_NOT_APPLICABLE: + break; + } + /* Strategy 1: module-path resolution → existing node (Python/TS/Go). * No label filter here: directory-module languages (Go/Java packages) * legitimately resolve straight to a Folder node -- that's the intended, diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 097cebafe..01d4419c1 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -1243,6 +1243,194 @@ TEST(ei_php_interface_use) { PASS(); } +/* ═══════════════════════════════════════════════════════════════════════════ + * PHP PSR-4 — #1186 + * + * With composer.json `autoload.psr-4`, `use App\Models\Agency;` names exactly + * one file: /Models/Agency.php. The resolver instead fell through + * to the namespace bucket and bound every class import of App\Models to the + * FIRST file declaring that namespace (User.php), fabricating a hub. These + * tests pin the exact target file per local name; a missing class file must + * leave the import unresolved rather than land on a sibling. + * ═══════════════════════════════════════════════════════════════════════════ */ + +typedef struct { + const char *local_name; /* IMPORTS edge local_name */ + const char *want_path; /* expected target file_path; NULL = no edge */ +} EIPhpImportExpect; + +/* Find the IMPORTS edge of `importer` whose local_name is `local` and write + * its target's file_path into `out` ("" when there is no such edge). */ +static void ei_import_target_path(cbm_store_t *store, const char *project, const char *importer, + const char *local, char *out, size_t outsz) { + out[0] = '\0'; + int64_t src_id = ei_node_id_for_file_label(store, project, importer, "File"); + cbm_edge_t *edges = NULL; + int n = 0; + if (src_id <= 0 || + cbm_store_find_edges_by_source_type(store, src_id, "IMPORTS", &edges, &n) != CBM_STORE_OK) { + return; + } + char needle[256]; + snprintf(needle, sizeof(needle), "\"local_name\":\"%s\"", local); + for (int i = 0; i < n; i++) { + if (!edges[i].properties_json || !strstr(edges[i].properties_json, needle)) { + continue; + } + cbm_node_t target; + memset(&target, 0, sizeof(target)); + if (cbm_store_find_node_by_id(store, edges[i].target_id, &target) == CBM_STORE_OK) { + snprintf(out, outsz, "%s", target.file_path ? target.file_path : "?"); + cbm_node_free_fields(&target); + } + break; + } + cbm_store_free_edges(edges, n); +} + +/* Index `files` and check every expectation for `importer`. Returns 1 when + * all hold; prints each mismatch so a RED names the fabricated target. */ +static int ei_php_imports_match(const EILangFile *files, int nfiles, const char *importer, + const EIPhpImportExpect *want, int nwant) { + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, files, nfiles); + if (!store) { + ei_cleanup(&lp, store); + return 0; + } + int ok = 1; + for (int i = 0; i < nwant; i++) { + char got[512]; + ei_import_target_path(store, lp.project, importer, want[i].local_name, got, sizeof(got)); + const char *expect = want[i].want_path ? want[i].want_path : ""; + if (strcmp(got, expect) != 0) { + fprintf(stderr, " [IMPORTS %s] %s -> got \"%s\", want \"%s\"\n", importer, + want[i].local_name, got, expect); + ok = 0; + } + } + ei_cleanup(&lp, store); + return ok; +} + +#define EI_PHP_CLASS(ns, cls) " src/Domain/ over + * App\ -> app/), a package's own root maps its namespace, and autoload-dev + * maps Tests\. The decoy app/Domain/Order.php declares the same namespace and + * sorts first. */ +TEST(ei_php_psr4_multiple_roots_longest_prefix_issue1186) { + static const EILangFile f[] = { + {"composer.json", "{\"autoload\":{\"psr-4\":{\"App\\\\\":\"app/\"," + "\"App\\\\Domain\\\\\":\"src/Domain/\"}}," + "\"autoload-dev\":{\"psr-4\":{\"Tests\\\\\":\"tests/\"}}}\n"}, + {"tests/Support/Assert.php", EI_PHP_CLASS("Tests\\Support", "Assert")}, + {"tests/Support/Factory.php", EI_PHP_CLASS("Tests\\Support", "Factory")}, + {"packages/billing/composer.json", + "{\"name\":\"acme/billing\",\"autoload\":{\"psr-4\":{\"Billing\\\\\":\"src/\"}}}\n"}, + {"app/Domain/Order.php", EI_PHP_CLASS("App\\Domain", "Order")}, + {"src/Domain/Customer.php", EI_PHP_CLASS("App\\Domain", "Customer")}, + {"src/Domain/Order.php", EI_PHP_CLASS("App\\Domain", "Order")}, + {"packages/billing/src/Account.php", EI_PHP_CLASS("Billing", "Account")}, + {"packages/billing/src/Ledger.php", EI_PHP_CLASS("Billing", "Ledger")}, + {"app/Http/Checkout.php", "