diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index ef3ba0f10..daf917379 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2037,6 +2037,49 @@ static const cbm_gbuf_node_t *resolve_header_include(const cbm_pipeline_ctx_t *c return NULL; } +static bool explicit_relative_path(const char *p) { + return strncmp(p, "./", PAIR_LEN) == 0 || strncmp(p, "../", PAIR_LEN + SKIP_ONE) == 0; +} + +enum { SIBLING_MAX_CANDS = 4 }; + +/* The paths resolve_sibling_file tries for module_path, relative to the + * importer's directory `dir`, in priority order. Returns how many it wrote. */ +static int sibling_file_candidates(const char *dir, const char *module_path, + char cands[SIBLING_MAX_CANDS][PKGMAP_PATH_BUF]) { + int ncand = 0; + const char *sep = dir[0] ? "/" : ""; + const char *base = module_path; + /* Skip a leading "./". */ + if (strncmp(base, "./", PAIR_LEN) == 0) { + base += PAIR_LEN; + } + const char *slash = strrchr(base, '/'); + /* 1. Direct sibling: dir/. */ + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s%s%s", dir, sep, base); + /* 2. SCSS partial: dir/[subdir/]_.scss (underscore-prefixed). */ + { + const char *bn = slash ? slash + SKIP_ONE : base; + char *dpart = slash ? cbm_strndup(base, (size_t)(slash - base)) : strdup(""); + if (dpart && bn[0] != '_') { + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s%s%s%s_%s.scss", dir, sep, dpart, + dpart[0] ? "/" : "", bn); + } + free(dpart); + } + /* 3. Meson subdir: dir//meson.build. */ + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s%s%s/meson.build", dir, sep, base); + /* 4. Basename sibling: dir/. Covers include paths + * that carry a non-relative prefix (Hyprlang `source = ~/.config/.../x.conf`, + * absolute include paths) but reference a file sitting beside the importer. + * An explicit `./` or `../` path names one location, which Strategy 1c + * resolves exactly; its basename beside the importer is another file (#2797). */ + if (slash && slash[SKIP_ONE] && !explicit_relative_path(module_path)) { + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s%s%s", dir, sep, slash + SKIP_ONE); + } + return ncand; +} + /* Resolve a sibling-file import: a bare path/name (no leading "./") that names * a file relative to the importer's directory. This covers build/markup * grammars whose import string is a sibling filename or directory rather than a @@ -2063,38 +2106,8 @@ static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx return NULL; } - /* Candidate relative paths, in priority order. */ - char cands[5][PKGMAP_PATH_BUF]; - int ncand = 0; - const char *base = module_path; - /* Skip a leading "./". */ - if (base[0] == '.' && base[1] == '/') { - base += 2; - } - /* 1. Direct sibling: dir/. */ - snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s%s%s", dir, dir[0] ? "/" : "", base); - /* 2. SCSS partial: dir/[subdir/]_.scss (underscore-prefixed). */ - { - const char *slash = strrchr(base, '/'); - const char *bn = slash ? slash + 1 : base; - char *dpart = slash ? cbm_strndup(base, (size_t)(slash - base)) : strdup(""); - if (dpart && bn[0] != '_') { - snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s%s%s%s_%s.scss", dir, dir[0] ? "/" : "", - dpart[0] ? dpart : "", dpart[0] ? "/" : "", bn); - } - free(dpart); - } - /* 3. Meson subdir: dir//meson.build. */ - snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s%s%s/meson.build", dir, dir[0] ? "/" : "", base); - /* 4. Basename sibling: dir/. Covers include paths - * that carry a non-relative prefix (Hyprlang `source = ~/.config/.../x.conf`, - * absolute include paths) but reference a file sitting beside the importer. */ - { - const char *slash = strrchr(base, '/'); - if (slash && slash[1]) { - snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s%s%s", dir, dir[0] ? "/" : "", slash + 1); - } - } + char cands[SIBLING_MAX_CANDS][PKGMAP_PATH_BUF]; + int ncand = sibling_file_candidates(dir, module_path, cands); const cbm_gbuf_node_t *found = NULL; for (int i = 0; i < ncand; i++) { @@ -2123,8 +2136,7 @@ static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx static const cbm_gbuf_node_t *resolve_relative_file(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *module_path, bool php_only) { - if (!source_rel || !module_path || module_path[0] != '.' || - !(module_path[1] == '/' || (module_path[1] == '.' && module_path[2] == '/'))) { + if (!source_rel || !module_path || !explicit_relative_path(module_path)) { return NULL; } char dir[PKGMAP_PATH_BUF]; diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 266cc255a..024bc582b 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -363,6 +363,51 @@ TEST(ei_relative_astro_css_imports_issue2558) { PASS(); } +/* #2797: an explicit `../` path names one file. A same-named file beside the + * importer (checks/values.nix) must not stand in for it, whether the named + * file exists (lib/values.nix) or not (gone/values.nix). */ +TEST(ei_nix_parent_relative_import_issue2797) { + static const EILangFile f[] = { + {"lib/values.nix", "{ port = 1; }\n"}, + {"lib/helper.nix", "{ x }: { id = x; }\n"}, + {"checks/values.nix", "{ unrelated = true; }\n"}, + {"checks/user.nix", "let\n" + " inherit (import ../lib/helper.nix { x = 1; }) id;\n" + " port = (import ../lib/values.nix).port;\n" + " gone = import ../gone/values.nix;\n" + "in\n" + "{ inherit id port; }\n"}, + {"top.nix", "{ v = (import ./lib/values.nix).port; }\n"}, + }; + static const EIFileEdgeExpect want[] = { + {"checks/user.nix", "lib/helper.nix"}, + {"checks/user.nix", "lib/values.nix"}, + {"top.nix", "lib/values.nix"}, + }; + + ASSERT_TRUE(ei_file_edges_match(f, (int)(sizeof(f) / sizeof(f[0])), want, + (int)(sizeof(want) / sizeof(want[0])), 3)); + PASS(); +} + +/* #2797: the same basename guess applies to any grammar whose import is a + * file path, not only Nix. */ +TEST(ei_thrift_parent_relative_import_issue2797) { + static const EILangFile f[] = { + {"shared/base.thrift", "struct Base { 1: i32 id }\n"}, + {"service/base.thrift", "struct Unrelated { 1: bool flag }\n"}, + {"service/api.thrift", "include \"../shared/base.thrift\"\n" + "service Api { base.Base get() }\n"}, + }; + static const EIFileEdgeExpect want[] = { + {"service/api.thrift", "shared/base.thrift"}, + }; + + ASSERT_TRUE(ei_file_edges_match(f, (int)(sizeof(f) / sizeof(f[0])), want, + (int)(sizeof(want) / sizeof(want[0])), 1)); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════════════ * GREEN GUARD — Python * @@ -2396,6 +2441,8 @@ TEST(ei_php_psr4_missing_class_leaves_type_evidence_unknown) { SUITE(edge_imports) { RUN_TEST(ei_relative_astro_css_imports_issue2558); + RUN_TEST(ei_nix_parent_relative_import_issue2797); + RUN_TEST(ei_thrift_parent_relative_import_issue2797); /* ── GREEN GUARDS — Python (must stay passing) ── */ RUN_TEST(ei_python_relative_from_import);