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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-03 17:56:11 +02:00
parent 96c3f41cf3
commit 830f0ccfae
4 changed files with 361 additions and 13 deletions
+10
View File
@@ -304,10 +304,20 @@ typedef struct {
// guard's unique-name exemption. Default false. // guard's unique-name exemption. Default false.
} CBMCall; } 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 { typedef struct {
const char *local_name; // local alias or name const char *local_name; // local alias or name
const char *module_path; // resolved module path / QN const char *module_path; // resolved module path / QN
bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916) 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; } CBMImport;
typedef enum { typedef enum {
+29 -5
View File
@@ -1205,7 +1205,29 @@ static void parse_wolfram_imports(CBMExtractCtx *ctx) {
// require/include forms remain `expression_statement`s and are still handled by // require/include forms remain `expression_statement`s and are still handled by
// the text fallback. Take the first qualified_name/name descendant of each // the text fallback. Take the first qualified_name/name descendant of each
// clause as the module path. // 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; CBMArena *a = ctx->arena;
// The path node is the qualified_name / namespace_name / name child. // The path node is the qualified_name / namespace_name / name child.
TSNode path_node = clause; 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")); TSNode alias = ts_node_child_by_field_name(clause, TS_FIELD("alias"));
const char *local = const char *local =
!ts_node_is_null(alias) ? cbm_node_text(a, alias, ctx->source) : path_last(a, path); !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); cbm_imports_push(&ctx->result->imports, a, imp);
} }
static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) { static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) {
CBMArena *a = ctx->arena; CBMArena *a = ctx->arena;
const CBMImportKind decl_kind = php_use_kind(decl);
// Grouped form: namespace_use_group with a leading prefix qualified_name. // Grouped form: namespace_use_group with a leading prefix qualified_name.
TSNode group = decl; TSNode group = decl;
if (find_first_descendant_of(decl, "namespace_use_group", &group)) { 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); const char *ck = ts_node_type(clause);
if (strcmp(ck, "namespace_use_group_clause") == 0 || if (strcmp(ck, "namespace_use_group_clause") == 0 ||
strcmp(ck, "namespace_use_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; return;
@@ -1268,13 +1292,13 @@ static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) {
for (uint32_t i = 0; i < dc; i++) { for (uint32_t i = 0; i < dc; i++) {
TSNode clause = ts_node_named_child(decl, i); TSNode clause = ts_node_named_child(decl, i);
if (strcmp(ts_node_type(clause), "namespace_use_clause") == 0) { 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; any = true;
} }
} }
if (!any) { if (!any) {
// Some grammar versions inline the path directly under the declaration. // 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);
} }
} }
+129 -8
View File
@@ -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. */ /* Extract PSR-4 entries from one composer.json autoload section ("autoload"
static void extract_psr4(yyjson_val *root, const char *dir, cbm_pkg_entries_t *entries) { * or "autoload-dev": composer registers both, so test namespaces such as
yyjson_val *autoload = yyjson_obj_get(root, "autoload"); * 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)) { if (!yyjson_is_obj(autoload)) {
return; 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_prefix = yyjson_get_str(key);
const char *ns_dir = yyjson_get_str(val); 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]; char ns_entry[PKGMAP_PATH_BUF];
if (dir[0]) { if (dir[0]) {
snprintf(ns_entry, sizeof(ns_entry), "%s/%s", dir, ns_dir); 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); free(dir);
yyjson_doc_free(doc); 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 ────────────────────── */ /* ── 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, CBMHashTable *cbm_pkgmap_build(cbm_pkg_entries_t *worker_entries, int worker_count,
const char *project_name) { const char *project_name) {
/* Count total entries */ /* 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++) { for (int w = 0; w < worker_count; w++) {
cbm_pkg_entries_t *we = &worker_entries[w]; cbm_pkg_entries_t *we = &worker_entries[w];
for (int i = 0; i < we->count; i++) { for (int i = 0; i < we->count; i++) {
/* Convert entry_rel to QN: project.dir.parts */ /* Convert entry_rel to QN: project.dir.parts. A PSR-4 namespace
char *qn = cbm_pipeline_fqn_module(project_name, we->items[i].entry_rel); * prefix (key ends in '\\') keeps its repo-relative DIRECTORY
* instead: its readers map `Prefix\Sub\Class` onto a file path
* (<dir>/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) { if (!qn) {
continue; 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). /* 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, static char *resolve_backslash_prefix(CBMHashTable *map, const char *module_path,
const char *project_name) { const char *project_name) {
char *buf = strdup(module_path); 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; const char *subpath = module_path + (size_t)(bs - buf) + SKIP_ONE;
char path_result[PKGMAP_PATH_BUF]; 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++) { for (char *c = path_result; *c; c++) {
if (*c == '\\') { if (*c == '\\') {
*c = '/'; *c = '/';
@@ -2025,6 +2049,91 @@ static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx
return found; 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 <prefix-dir>/<rest>.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 cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx,
const char *source_rel, const char *source_rel,
const char *source_file_qn, 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; 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). /* Strategy 1: module-path resolution → existing node (Python/TS/Go).
* No label filter here: directory-module languages (Go/Java packages) * No label filter here: directory-module languages (Go/Java packages)
* legitimately resolve straight to a Folder node -- that's the intended, * legitimately resolve straight to a Folder node -- that's the intended,
+193
View File
@@ -1243,6 +1243,194 @@ TEST(ei_php_interface_use) {
PASS(); PASS();
} }
/* ═══════════════════════════════════════════════════════════════════════════
* PHP PSR-4 — #1186
*
* With composer.json `autoload.psr-4`, `use App\Models\Agency;` names exactly
* one file: <mapped-dir>/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) "<?php\nnamespace " ns ";\n\nclass " cls " {\n}\n"
/* Several classes in one namespace directory: each import binds its own file,
* not the first file of App\Models. */
TEST(ei_php_psr4_class_per_file_issue1186) {
static const EILangFile f[] = {
{"composer.json",
"{\"name\":\"acme/app\",\"autoload\":{\"psr-4\":{\"App\\\\\":\"app/\"}}}\n"},
{"app/Models/Agency.php", EI_PHP_CLASS("App\\Models", "Agency")},
{"app/Models/Client.php", EI_PHP_CLASS("App\\Models", "Client")},
{"app/Models/Property.php", EI_PHP_CLASS("App\\Models", "Property")},
{"app/Models/User.php", EI_PHP_CLASS("App\\Models", "User")},
{"app/Http/Controller.php", "<?php\nnamespace App\\Http;\n\n"
"use App\\Models\\Property;\nuse App\\Models\\User;\n"
"use App\\Models\\Client as C;\n\n"
"class Controller {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"Property", "app/Models/Property.php"},
{"User", "app/Models/User.php"},
{"C", "app/Models/Client.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 6, "app/Http/Controller.php", want, 3));
PASS();
}
/* Sub-namespaces map to subdirectories of the PSR-4 root, at any depth. */
TEST(ei_php_psr4_nested_subnamespace_issue1186) {
static const EILangFile f[] = {
{"composer.json", "{\"autoload\":{\"psr-4\":{\"App\\\\\":\"app/\"}}}\n"},
{"app/Models/Billing/Account.php", EI_PHP_CLASS("App\\Models\\Billing", "Account")},
{"app/Models/Billing/Invoice.php", EI_PHP_CLASS("App\\Models\\Billing", "Invoice")},
{"app/Models/Billing/Tax/Exempt.php", EI_PHP_CLASS("App\\Models\\Billing\\Tax", "Exempt")},
{"app/Models/Billing/Tax/Rate.php", EI_PHP_CLASS("App\\Models\\Billing\\Tax", "Rate")},
{"app/Jobs/Bill.php", "<?php\nnamespace App\\Jobs;\n\n"
"use App\\Models\\Billing\\Invoice;\n"
"use App\\Models\\Billing\\Tax\\Rate;\n\n"
"class Bill {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"Invoice", "app/Models/Billing/Invoice.php"},
{"Rate", "app/Models/Billing/Tax/Rate.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 6, "app/Jobs/Bill.php", want, 2));
PASS();
}
/* Several psr-4 roots, across two composer.json files and both autoload
* sections: the longest matching prefix wins (App\Domain\ -> 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", "<?php\nnamespace App\\Http;\n\n"
"use App\\Domain\\Order;\nuse Billing\\Ledger;\n"
"use Tests\\Support\\Assert;\n\n"
"class Checkout {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"Order", "src/Domain/Order.php"},
{"Ledger", "packages/billing/src/Ledger.php"},
{"Assert", "tests/Support/Assert.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 10, "app/Http/Checkout.php", want, 3));
PASS();
}
/* Control: `use function` / `use const` name a namespace member, not a class
* file, so they must not go through PSR-4 class-file mapping (there is no
* app/Helpers/format_money.php); they keep resolving to the declaring file. */
TEST(ei_php_psr4_use_function_not_class_mapped_issue1186) {
static const EILangFile f[] = {
{"composer.json", "{\"autoload\":{\"psr-4\":{\"App\\\\\":\"app/\"}}}\n"},
{"app/Helpers/money.php", "<?php\nnamespace App\\Helpers;\n\n"
"const CURRENCY = 'EUR';\n\n"
"function format_money($x) { return $x; }\n"},
{"app/Http/Shop.php", "<?php\nnamespace App\\Http;\n\n"
"use function App\\Helpers\\format_money;\n"
"use const App\\Helpers\\CURRENCY;\n\n"
"class Shop {\n"
" public function show() { return format_money(CURRENCY); }\n"
"}\n"}};
static const EIPhpImportExpect want[] = {
{"format_money", "app/Helpers/money.php"},
{"CURRENCY", "app/Helpers/money.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 3, "app/Http/Shop.php", want, 2));
PASS();
}
/* Control: a PSR-4 class whose file does not exist stays unresolved — it must
* never fall back to the first file of the namespace directory. The present
* sibling import still resolves. */
TEST(ei_php_psr4_missing_class_file_unresolved_issue1186) {
static const EILangFile f[] = {
{"composer.json", "{\"autoload\":{\"psr-4\":{\"App\\\\\":\"app/\"}}}\n"},
{"app/Models/User.php", EI_PHP_CLASS("App\\Models", "User")},
{"app/Http/Guard.php", "<?php\nnamespace App\\Http;\n\n"
"use App\\Models\\Ghost;\nuse App\\Models\\User;\n\n"
"class Guard {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"Ghost", NULL},
{"User", "app/Models/User.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 3, "app/Http/Guard.php", want, 2));
PASS();
}
/* ═══════════════════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════════════════
* SUITE registration * SUITE registration
* ═══════════════════════════════════════════════════════════════════════════ */ * ═══════════════════════════════════════════════════════════════════════════ */
@@ -1326,4 +1514,9 @@ SUITE(edge_imports) {
RUN_TEST(ei_php_const_use); RUN_TEST(ei_php_const_use);
RUN_TEST(ei_php_multiple_use_statements); RUN_TEST(ei_php_multiple_use_statements);
RUN_TEST(ei_php_interface_use); RUN_TEST(ei_php_interface_use);
RUN_TEST(ei_php_psr4_class_per_file_issue1186);
RUN_TEST(ei_php_psr4_nested_subnamespace_issue1186);
RUN_TEST(ei_php_psr4_multiple_roots_longest_prefix_issue1186);
RUN_TEST(ei_php_psr4_use_function_not_class_mapped_issue1186);
RUN_TEST(ei_php_psr4_missing_class_file_unresolved_issue1186);
} }