Skip to content

Commit b64cf42

Browse files
committed
Resolve PSR-4 classes across ordered Composer directory mappings
1 parent f0e1f8e commit b64cf42

2 files changed

Lines changed: 153 additions & 60 deletions

File tree

‎src/pipeline/pass_pkgmap.c‎

Lines changed: 104 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -457,28 +457,35 @@ static void extract_psr4(yyjson_val *root, const char *section, const char *dir,
457457
yyjson_obj_iter iter = yyjson_obj_iter_with(psr4);
458458
while ((key = yyjson_obj_iter_next(&iter)) != NULL) {
459459
yyjson_val *val = yyjson_obj_iter_get_val(key);
460-
if (!yyjson_is_str(key) || !yyjson_is_str(val)) {
460+
if (!yyjson_is_str(key)) {
461461
continue;
462462
}
463463
const char *ns_prefix = yyjson_get_str(key);
464-
const char *ns_dir = yyjson_get_str(val);
465-
while (ns_dir[0] == '.' && ns_dir[SKIP_ONE] == '/') {
466-
ns_dir += PAIR_LEN; /* "./src/" names src/ */
467-
}
468-
if (strcmp(ns_dir, ".") == 0) {
469-
ns_dir = ""; /* "." names the manifest's own directory */
470-
}
471-
char ns_entry[PKGMAP_PATH_BUF];
472-
if (dir[0]) {
473-
snprintf(ns_entry, sizeof(ns_entry), "%s/%s", dir, ns_dir);
474-
} else {
475-
snprintf(ns_entry, sizeof(ns_entry), "%s", ns_dir);
476-
}
477-
size_t nelen = strlen(ns_entry);
478-
if (nelen > 0 && ns_entry[nelen - SKIP_ONE] == '/') {
479-
ns_entry[nelen - SKIP_ONE] = '\0';
464+
size_t count = yyjson_is_arr(val) ? yyjson_arr_size(val) : 1;
465+
for (size_t i = 0; i < count; i++) {
466+
yyjson_val *path = yyjson_is_arr(val) ? yyjson_arr_get(val, i) : val;
467+
if (!yyjson_is_str(path)) {
468+
continue;
469+
}
470+
const char *ns_dir = yyjson_get_str(path);
471+
while (ns_dir[0] == '.' && ns_dir[SKIP_ONE] == '/') {
472+
ns_dir += PAIR_LEN; /* "./src/" names src/ */
473+
}
474+
if (strcmp(ns_dir, ".") == 0) {
475+
ns_dir = ""; /* "." names the manifest's own directory */
476+
}
477+
char ns_entry[PKGMAP_PATH_BUF];
478+
if (dir[0]) {
479+
snprintf(ns_entry, sizeof(ns_entry), "%s/%s", dir, ns_dir);
480+
} else {
481+
snprintf(ns_entry, sizeof(ns_entry), "%s", ns_dir);
482+
}
483+
size_t nelen = strlen(ns_entry);
484+
if (nelen > 0 && ns_entry[nelen - SKIP_ONE] == '/') {
485+
ns_entry[nelen - SKIP_ONE] = '\0';
486+
}
487+
pkg_entries_push(entries, strdup(ns_prefix), strdup(ns_entry));
480488
}
481-
pkg_entries_push(entries, strdup(ns_prefix), strdup(ns_entry));
482489
}
483490
}
484491

@@ -1003,6 +1010,17 @@ static bool pkgmap_is_psr4_prefix(const char *pkg_name) {
10031010
return n > 0 && pkg_name[n - SKIP_ONE] == '\\';
10041011
}
10051012

1013+
/* PSR-4 values own an ordered directory list; other package values remain QNs. */
1014+
typedef struct psr4_dir {
1015+
struct psr4_dir *next;
1016+
char path[];
1017+
} psr4_dir_t;
1018+
1019+
static const char *pkgmap_first_path(CBMHashTable *map, const char *key) {
1020+
void *value = cbm_ht_get(map, key);
1021+
return value && pkgmap_is_psr4_prefix(key) ? ((psr4_dir_t *)value)->path : value;
1022+
}
1023+
10061024
CBMHashTable *cbm_pkgmap_build(cbm_pkg_entries_t *worker_entries, int worker_count,
10071025
const char *project_name) {
10081026
/* Count total entries */
@@ -1020,28 +1038,43 @@ CBMHashTable *cbm_pkgmap_build(cbm_pkg_entries_t *worker_entries, int worker_cou
10201038
for (int w = 0; w < worker_count; w++) {
10211039
cbm_pkg_entries_t *we = &worker_entries[w];
10221040
for (int i = 0; i < we->count; i++) {
1023-
/* Convert entry_rel to QN: project.dir.parts. A PSR-4 namespace
1024-
* prefix (key ends in '\\') keeps its repo-relative DIRECTORY
1025-
* instead: its readers map `Prefix\Sub\Class` onto a file path
1026-
* (<dir>/Sub/Class.php), which a dotted QN cannot express (#1186). */
1027-
const char *entry_rel = we->items[i].entry_rel;
1028-
char *qn = pkgmap_is_psr4_prefix(we->items[i].pkg_name)
1029-
? cbm_strndup(entry_rel, strlen(entry_rel))
1030-
: cbm_pipeline_fqn_module(project_name, entry_rel);
1031-
if (!qn) {
1041+
const bool psr4 = pkgmap_is_psr4_prefix(we->items[i].pkg_name);
1042+
psr4_dir_t *dirs = psr4 ? cbm_ht_get(map, we->items[i].pkg_name) : NULL;
1043+
/* Other package mappings retain their existing first-wins rule. */
1044+
if (!psr4 && cbm_ht_has(map, we->items[i].pkg_name)) {
10321045
continue;
10331046
}
1034-
1035-
/* Check for duplicate — first wins */
1036-
if (cbm_ht_has(map, we->items[i].pkg_name)) {
1037-
free(qn);
1047+
char *key = dirs ? NULL : strdup(we->items[i].pkg_name);
1048+
if (!dirs && !key) {
10381049
continue;
10391050
}
1040-
1041-
/* Transfer ownership: key = strdup'd pkg_name, value = qn */
1042-
char *key = strdup(we->items[i].pkg_name);
1043-
cbm_ht_set(map, key, qn);
1044-
merged++;
1051+
const char *entry_rel = we->items[i].entry_rel;
1052+
void *value = NULL;
1053+
if (psr4) {
1054+
size_t len = strlen(entry_rel);
1055+
psr4_dir_t *node = cbm_alloc(CBM_MEM_CLASS_SEMANTIC, sizeof(*node) + len + 1);
1056+
if (node) {
1057+
node->next = NULL;
1058+
memcpy(node->path, entry_rel, len + 1);
1059+
}
1060+
value = node;
1061+
} else {
1062+
value = cbm_pipeline_fqn_module(project_name, entry_rel);
1063+
}
1064+
if (!value) {
1065+
free(key);
1066+
continue;
1067+
}
1068+
if (dirs) {
1069+
/* ponytail: linear append for small lists; cache a tail if large lists matter. */
1070+
while (dirs->next) {
1071+
dirs = dirs->next;
1072+
}
1073+
dirs->next = value;
1074+
} else {
1075+
cbm_ht_set(map, key, value);
1076+
merged++;
1077+
}
10451078
}
10461079
}
10471080

@@ -1296,8 +1329,17 @@ CBMHashTable *cbm_pkgmap_build_from_repo(const char *repo_path, const cbm_file_i
12961329

12971330
static void pkgmap_free_entry(const char *key, void *value, void *userdata) {
12981331
(void)userdata;
1332+
if (pkgmap_is_psr4_prefix(key)) {
1333+
psr4_dir_t *dir = value;
1334+
while (dir) {
1335+
psr4_dir_t *next = dir->next;
1336+
cbm_free(CBM_MEM_CLASS_SEMANTIC, dir);
1337+
dir = next;
1338+
}
1339+
} else {
1340+
free(value);
1341+
}
12991342
free((void *)key);
1300-
free(value);
13011343
}
13021344

13031345
void cbm_pkgmap_free(CBMHashTable *pkgmap) {
@@ -1322,7 +1364,7 @@ static char *resolve_slash_prefix(CBMHashTable *map, const char *module_path) {
13221364
continue;
13231365
}
13241366
*slash = '\0';
1325-
const char *base_qn = (const char *)cbm_ht_get(map, buf);
1367+
const char *base_qn = pkgmap_first_path(map, buf);
13261368
if (!base_qn) {
13271369
continue;
13281370
}
@@ -1355,7 +1397,7 @@ static char *resolve_dot_prefix(CBMHashTable *map, const char *module_path,
13551397
continue;
13561398
}
13571399
*dot = '\0';
1358-
const char *base_qn = (const char *)cbm_ht_get(map, buf);
1400+
const char *base_qn = pkgmap_first_path(map, buf);
13591401
if (!base_qn) {
13601402
continue;
13611403
}
@@ -1377,8 +1419,8 @@ static char *resolve_dot_prefix(CBMHashTable *map, const char *module_path,
13771419
}
13781420

13791421
/* Try backslash-based prefix matching (PHP PSR-4: App\\Controllers\\Foo).
1380-
* The map value of a PSR-4 prefix is its repo-relative directory (see
1381-
* cbm_pkgmap_build). Returns heap QN or NULL. */
1422+
* Generic module resolution uses the first mapped directory; class imports
1423+
* search every directory through resolve_php_psr4_class. Returns heap QN or NULL. */
13821424
static char *resolve_backslash_prefix(CBMHashTable *map, const char *module_path,
13831425
const char *project_name) {
13841426
char *buf = strdup(module_path);
@@ -1392,7 +1434,7 @@ static char *resolve_backslash_prefix(CBMHashTable *map, const char *module_path
13921434
*bs = '\0';
13931435
char prefix[PKGMAP_PATH_BUF];
13941436
snprintf(prefix, sizeof(prefix), "%s\\", buf);
1395-
const char *base_dir = (const char *)cbm_ht_get(map, prefix);
1437+
const char *base_dir = pkgmap_first_path(map, prefix);
13961438
if (!base_dir) {
13971439
continue;
13981440
}
@@ -1452,7 +1494,7 @@ char *cbm_pipeline_resolve_module(const cbm_pipeline_ctx_t *ctx, const char *sou
14521494
}
14531495

14541496
/* 3. Exact lookup */
1455-
const char *mapped_qn = (const char *)cbm_ht_get(pkgmap, module_path);
1497+
const char *mapped_qn = pkgmap_first_path(pkgmap, module_path);
14561498
if (mapped_qn) {
14571499
return strdup(mapped_qn);
14581500
}
@@ -2111,26 +2153,29 @@ static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx,
21112153
}
21122154
memcpy(key, name, cut);
21132155
key[cut] = '\0';
2114-
const char *dir = (const char *)cbm_ht_get(pkgmap, key);
2115-
if (!dir) {
2156+
const psr4_dir_t *dirs = cbm_ht_get(pkgmap, key);
2157+
if (!dirs) {
21162158
continue;
21172159
}
21182160
covered = true;
2119-
char rel[PKGMAP_PATH_BUF];
2120-
int w = snprintf(rel, sizeof(rel), "%s%s%s.php", dir, dir[0] ? "/" : "", name + cut);
2121-
if (w <= 0 || (size_t)w >= sizeof(rel)) {
2122-
continue;
2123-
}
2124-
for (char *c = rel; *c; c++) {
2125-
if (*c == '\\') {
2126-
*c = '/';
2161+
for (const psr4_dir_t *dir = dirs; dir; dir = dir->next) {
2162+
char rel[PKGMAP_PATH_BUF];
2163+
int w = snprintf(rel, sizeof(rel), "%s%s%s.php", dir->path, dir->path[0] ? "/" : "",
2164+
name + cut);
2165+
if (w <= 0 || (size_t)w >= sizeof(rel)) {
2166+
continue;
2167+
}
2168+
for (char *c = rel; *c; c++) {
2169+
if (*c == '\\') {
2170+
*c = '/';
2171+
}
2172+
}
2173+
const cbm_gbuf_node_t *file = psr4_file_node(ctx, rel);
2174+
if (file && (!source_file_qn || !file->qualified_name ||
2175+
strcmp(file->qualified_name, source_file_qn) != 0)) {
2176+
*out = file;
2177+
return PSR4_RESOLVED;
21272178
}
2128-
}
2129-
const cbm_gbuf_node_t *file = psr4_file_node(ctx, rel);
2130-
if (file && (!source_file_qn || !file->qualified_name ||
2131-
strcmp(file->qualified_name, source_file_qn) != 0)) {
2132-
*out = file;
2133-
return PSR4_RESOLVED;
21342179
}
21352180
}
21362181
return covered ? PSR4_UNRESOLVED : PSR4_NOT_APPLICABLE;

‎tests/test_edge_imports.c‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1280,7 +1280,10 @@ static void ei_import_target_path(cbm_store_t *store, const char *project, const
12801280
cbm_node_t target;
12811281
memset(&target, 0, sizeof(target));
12821282
if (cbm_store_find_node_by_id(store, edges[i].target_id, &target) == CBM_STORE_OK) {
1283-
snprintf(out, outsz, "%s", target.file_path ? target.file_path : "?");
1283+
snprintf(out, outsz, "%s",
1284+
target.label && strcmp(target.label, "File") == 0 && target.file_path
1285+
? target.file_path
1286+
: "?");
12841287
cbm_node_free_fields(&target);
12851288
}
12861289
break;
@@ -1390,6 +1393,49 @@ TEST(ei_php_psr4_multiple_roots_longest_prefix_issue1186) {
13901393
PASS();
13911394
}
13921395

1396+
/* Array mappings keep Composer order, including later-only files and root dirs. */
1397+
TEST(ei_php_psr4_array_directories) {
1398+
static const EILangFile f[] = {
1399+
{"composer.json", "{\"autoload\":{\"psr-4\":{\"App\\\\\":[\"./missing/\","
1400+
"\"z-first/\",42,\"a-second/\",\"third/\"]}},"
1401+
"\"autoload-dev\":{\"psr-4\":{\"Tests\\\\\":[\"./absent/\",\".\"]}}}\n"},
1402+
{"z-first/Shared.php", EI_PHP_CLASS("App", "Shared")},
1403+
{"a-second/Shared.php", EI_PHP_CLASS("App", "Shared")},
1404+
{"a-second/Later.php", EI_PHP_CLASS("App", "Later")},
1405+
{"third/Last.php", EI_PHP_CLASS("App", "Last")},
1406+
{"z-first/Decoy.php", EI_PHP_CLASS("App", "Missing")},
1407+
{"Support.php", EI_PHP_CLASS("Tests", "Support")},
1408+
{"Consumer.php", "<?php\nnamespace Consumer;\n\n"
1409+
"use App\\Shared;\nuse App\\Later;\nuse App\\Last;\n"
1410+
"use App\\Missing;\nuse Tests\\Support;\n\nclass Consumer {}\n"}};
1411+
static const EIPhpImportExpect want[] = {
1412+
{"Shared", "z-first/Shared.php"}, {"Later", "a-second/Later.php"},
1413+
{"Last", "third/Last.php"}, {"Missing", NULL},
1414+
{"Support", "Support.php"},
1415+
};
1416+
ASSERT_TRUE(ei_php_imports_match(f, 8, "Consumer.php", want, 5));
1417+
PASS();
1418+
}
1419+
1420+
/* Each directory is relative to its own composer.json, not the repo root. */
1421+
TEST(ei_php_psr4_array_manifest_relative) {
1422+
static const EILangFile f[] = {
1423+
{"packages/billing/composer.json",
1424+
"{\"autoload\":{\"psr-4\":{\"Billing\\\\\":[\"./absent/\",\"./src/\",\"backup/\"]}}}\n"},
1425+
{"src/Account.php", EI_PHP_CLASS("Billing", "Account")},
1426+
{"packages/billing/src/Account.php", EI_PHP_CLASS("Billing", "Account")},
1427+
{"packages/billing/backup/Account.php", EI_PHP_CLASS("Billing", "Account")},
1428+
{"packages/billing/backup/Ledger.php", EI_PHP_CLASS("Billing", "Ledger")},
1429+
{"Checkout.php", "<?php\nnamespace Consumer;\n\nuse Billing\\Account;\n"
1430+
"use Billing\\Ledger;\n\nclass Checkout {}\n"}};
1431+
static const EIPhpImportExpect want[] = {
1432+
{"Account", "packages/billing/src/Account.php"},
1433+
{"Ledger", "packages/billing/backup/Ledger.php"},
1434+
};
1435+
ASSERT_TRUE(ei_php_imports_match(f, 6, "Checkout.php", want, 2));
1436+
PASS();
1437+
}
1438+
13931439
/* Control: `use function` / `use const` name a namespace member, not a class
13941440
* file, so they must not go through PSR-4 class-file mapping (there is no
13951441
* app/Helpers/format_money.php); they keep resolving to the declaring file. */
@@ -1517,6 +1563,8 @@ SUITE(edge_imports) {
15171563
RUN_TEST(ei_php_psr4_class_per_file_issue1186);
15181564
RUN_TEST(ei_php_psr4_nested_subnamespace_issue1186);
15191565
RUN_TEST(ei_php_psr4_multiple_roots_longest_prefix_issue1186);
1566+
RUN_TEST(ei_php_psr4_array_directories);
1567+
RUN_TEST(ei_php_psr4_array_manifest_relative);
15201568
RUN_TEST(ei_php_psr4_use_function_not_class_mapped_issue1186);
15211569
RUN_TEST(ei_php_psr4_missing_class_file_unresolved_issue1186);
15221570
}

0 commit comments

Comments
 (0)