diff --git a/internal/cbm/lsp/rust_lsp.c b/internal/cbm/lsp/rust_lsp.c index 44680cb69f..27c1feab64 100644 --- a/internal/cbm/lsp/rust_lsp.c +++ b/internal/cbm/lsp/rust_lsp.c @@ -4128,6 +4128,32 @@ static bool rust_qn_has_suffix(const char *qualified_name, const char *suffix) { (qn_len == suffix_len || qualified_name[qn_len - suffix_len - 1U] == '.'); } +/* True when the segment that qualifies a call path's tail names a TYPE: + * `Store::new`, `Self::helper`, `T::default`, `::f` (turbofish is + * already stripped, so a `>` before the last `::` closes a qualified path). + * Such a call is an associated function, never a free function, so the + * free-function short-name fallbacks must not bind it: with a project-wide + * unique free `new`/`write`, `Store::new()` landed on that function and the + * LSP row pre-empted the registry's correct `Store.new`. Module segments are + * snake_case, so an ASCII-uppercase initial is a type. `tail` must point just + * past the path's last `::`, or equal `path` when there is none. */ +static bool rust_path_qualifier_is_type(const char *path, const char *tail) { + if (!path || !tail || tail - path < 3) { + return false; + } + const char *seg_end = tail - 2; /* the last "::" */ + if (seg_end[-1] == '>') { + return true; + } + const char *seg = path; + for (const char *p = path; p + 1 < seg_end; p++) { + if (p[0] == ':' && p[1] == ':') { + seg = p + 2; + } + } + return seg < seg_end && seg[0] >= 'A' && seg[0] <= 'Z'; +} + /* Resolve only an exact identifier/scoped-path value. No branch, closure, or * other composite expression enters this helper. A suffix fallback is admitted * only for a qualified import path and only when it identifies one free func. */ @@ -4514,6 +4540,9 @@ static void rust_resolve_call_expression_inner(RustLSPContext *ctx, TSNode node) } else { tail = path; } + /* `Type::f` is an associated function: the two free-function + * fallbacks below never bind it (see rust_path_qualifier_is_type). */ + const bool assoc_call = rust_path_qualifier_is_type(path, tail); /* Cross-crate workspace-member resolution (#56): when the call * path's head is a declared Cargo workspace member (e.g. @@ -4524,7 +4553,7 @@ static void rust_resolve_call_expression_inner(RustLSPContext *ctx, TSNode node) * MEMBER crate by matching the registered QN's `..` * path segment plus the call tail. Requires a parsed manifest * (threaded through pass_lsp_cross.c); NULL manifest skips this. */ - if (ctx->cargo_manifest && tail && *tail) { + if (!assoc_call && ctx->cargo_manifest && tail && *tail) { const char *head_sep = strstr(path, "::"); if (head_sep && head_sep > path) { char *head = cbm_arena_strndup(ctx->arena, path, (size_t)(head_sep - path)); @@ -4568,7 +4597,7 @@ static void rust_resolve_call_expression_inner(RustLSPContext *ctx, TSNode node) * foo; use foo::bar; bar()` a chance to resolve when the * intermediate module wasn't tracked through an explicit * use-map entry. */ - if (tail && *tail && ctx->module_qn) { + if (!assoc_call && tail && *tail && ctx->module_qn) { /* Crate prefix is the first dotted segment of module_qn after * the project name, but for simplicity we just match on * "starts with first dot-segment". */ @@ -6323,7 +6352,10 @@ static void rust_resolve_against_registry(CBMArena *arena, const char *source, i * read-only, and shared across every Rust file's resolve (mirrors C/py/cs/ts). * Converts the pipeline's CBMLSPDef into CBMRustLSPDef inline (same field copy as * pass_lsp_cross.c's pxc_lspdefs_to_rust, including proven trait provenance). - * module_qn=NULL is byte-identical because all_defs always carry def_module_qn. */ + * module_qn=NULL is byte-identical because all_defs always carry def_module_qn. + * all_defs is mixed-language: only Rust defs are registered, exactly like the + * per-file conversion, so a Perl `new` or a C function can never become the + * target of a Rust call (the Go builder filters the same way). */ CBMTypeRegistry *cbm_rust_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, int def_count) { if (!arena) return NULL; @@ -6331,29 +6363,34 @@ CBMTypeRegistry *cbm_rust_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, if (!reg) return NULL; CBMRustLSPDef *rdefs = NULL; + int n = 0; if (def_count > 0) { rdefs = (CBMRustLSPDef *)cbm_arena_alloc(arena, (size_t)def_count * sizeof(CBMRustLSPDef)); if (!rdefs) return NULL; for (int i = 0; i < def_count; i++) { - rdefs[i].qualified_name = defs[i].qualified_name; - rdefs[i].short_name = defs[i].short_name; - rdefs[i].label = defs[i].label; - rdefs[i].receiver_type = defs[i].receiver_type; - rdefs[i].def_module_qn = defs[i].def_module_qn; - rdefs[i].return_types = defs[i].return_types; - rdefs[i].embedded_types = defs[i].embedded_types; - rdefs[i].field_defs = defs[i].field_defs; - rdefs[i].method_names_str = defs[i].method_names_str; - rdefs[i].signature_param_types = defs[i].signature_param_types; - rdefs[i].signature_param_count = defs[i].signature_param_count; - rdefs[i].trait_qn = defs[i].trait_qn; - rdefs[i].is_interface = defs[i].is_interface; - rdefs[i].is_rust_impl_relation = defs[i].is_rust_impl_relation; - rdefs[i].is_abstract = defs[i].is_abstract; - } - } - rust_populate_cross_registry(reg, arena, rdefs, def_count, /*module_qn=*/NULL); + if (defs[i].lang != CBM_LANG_RUST) { + continue; + } + rdefs[n].qualified_name = defs[i].qualified_name; + rdefs[n].short_name = defs[i].short_name; + rdefs[n].label = defs[i].label; + rdefs[n].receiver_type = defs[i].receiver_type; + rdefs[n].def_module_qn = defs[i].def_module_qn; + rdefs[n].return_types = defs[i].return_types; + rdefs[n].embedded_types = defs[i].embedded_types; + rdefs[n].field_defs = defs[i].field_defs; + rdefs[n].method_names_str = defs[i].method_names_str; + rdefs[n].signature_param_types = defs[i].signature_param_types; + rdefs[n].signature_param_count = defs[i].signature_param_count; + rdefs[n].trait_qn = defs[i].trait_qn; + rdefs[n].is_interface = defs[i].is_interface; + rdefs[n].is_rust_impl_relation = defs[i].is_rust_impl_relation; + rdefs[n].is_abstract = defs[i].is_abstract; + n++; + } + } + rust_populate_cross_registry(reg, arena, n > 0 ? rdefs : NULL, n, /*module_qn=*/NULL); reg->read_only = true; /* seal: shared Tier-2 registry is read-only during resolve */ return reg; } diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index cf94926c0d..376b25b6b5 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -1470,31 +1470,42 @@ const struct CBMCargoManifest *cbm_pxc_get_rust_manifest(void) { /* Convert a CBMLSPDef array (the pipeline's lingua franca, go_lsp.h:73) * into a CBMRustLSPDef array (rust_lsp.h) inside `arena`. The two structs * have similar fields but different layouts; CBMLSPDef adds - * language/namespace metadata, so a memcpy is unsafe — copy field-by-field. */ -static CBMRustLSPDef *pxc_lspdefs_to_rust(CBMArena *arena, const CBMLSPDef *defs, int def_count) { + * language/namespace metadata, so a memcpy is unsafe — copy field-by-field. + * Only Rust defs are kept: the def set is mixed-language, and a Perl or C + * function must never become a Rust call target (the Go registrar filters + * the same way). *out_count gets the number kept. */ +static CBMRustLSPDef *pxc_lspdefs_to_rust(CBMArena *arena, const CBMLSPDef *defs, int def_count, + int *out_count) { + *out_count = 0; if (!defs || def_count <= 0) return NULL; CBMRustLSPDef *out = (CBMRustLSPDef *)cbm_arena_alloc(arena, (size_t)def_count * sizeof(CBMRustLSPDef)); if (!out) return NULL; + int n = 0; for (int i = 0; i < def_count; i++) { - out[i].qualified_name = defs[i].qualified_name; - out[i].short_name = defs[i].short_name; - out[i].label = defs[i].label; - out[i].receiver_type = defs[i].receiver_type; - out[i].def_module_qn = defs[i].def_module_qn; - out[i].return_types = defs[i].return_types; - out[i].embedded_types = defs[i].embedded_types; - out[i].field_defs = defs[i].field_defs; - out[i].method_names_str = defs[i].method_names_str; - out[i].signature_param_types = defs[i].signature_param_types; - out[i].signature_param_count = defs[i].signature_param_count; - out[i].trait_qn = defs[i].trait_qn; - out[i].is_interface = defs[i].is_interface; - out[i].is_rust_impl_relation = defs[i].is_rust_impl_relation; - out[i].is_abstract = defs[i].is_abstract; + if (defs[i].lang != CBM_LANG_RUST) { + continue; + } + out[n].qualified_name = defs[i].qualified_name; + out[n].short_name = defs[i].short_name; + out[n].label = defs[i].label; + out[n].receiver_type = defs[i].receiver_type; + out[n].def_module_qn = defs[i].def_module_qn; + out[n].return_types = defs[i].return_types; + out[n].embedded_types = defs[i].embedded_types; + out[n].field_defs = defs[i].field_defs; + out[n].method_names_str = defs[i].method_names_str; + out[n].signature_param_types = defs[i].signature_param_types; + out[n].signature_param_count = defs[i].signature_param_count; + out[n].trait_qn = defs[i].trait_qn; + out[n].is_interface = defs[i].is_interface; + out[n].is_rust_impl_relation = defs[i].is_rust_impl_relation; + out[n].is_abstract = defs[i].is_abstract; + n++; } + *out_count = n; return out; } @@ -1581,9 +1592,10 @@ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const * but diverge after, so convert into the scratch arena. The * workspace manifest (set once by the sequential driver) lets * `crate_a::foo` route across the crate boundary (#56). */ - CBMRustLSPDef *rdefs = pxc_lspdefs_to_rust(&scratch, defs, def_count); + int rust_def_count = 0; + CBMRustLSPDef *rdefs = pxc_lspdefs_to_rust(&scratch, defs, def_count, &rust_def_count); cbm_run_rust_lsp_cross_with_manifest(&scratch, source, source_len, module_qn, rdefs, - def_count, imp_names, imp_qns, imp_count, tree, + rust_def_count, imp_names, imp_qns, imp_count, tree, g_pxc_rust_manifest, &out, &synthetic_calls); break; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 78bd9c97e5..ccf556bf57 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8868,6 +8868,180 @@ TEST(pipeline_go_rw_usage_never_cross_into_c) { PASS(); } +/* The Rust registries were built from the mixed-language def set: a Perl + * `sub new` became a Rust free function, and the project-wide + * lsp_short_name_unique fallback bound `Store::new()` to it at 0.90 (found by + * the generic-engine lane on its 12-language fixture). A Rust call never + * targets another language's definition. pad_files > 0 forces the parallel + * resolver, whose Rust registry is the shared all-defs build. */ +/* Pads a Rust fixture with pad_files filler modules (pad_files > 0 forces the + * parallel resolver: CBM_WORKERS=4 for the run), runs the full pipeline into + * / and opens the store. NULL store = the run failed. */ +static cbm_store_t *rust_fixture_run(const char *tmp, int pad_files, const char *db_name, + cbm_pipeline_t **out_p) { + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.rs", i); + snprintf(body, sizeof(body), "pub fn filler%d() -> u32 {\n %d\n}\n", i, i); + write_temp_file(tmp, name, body); + } + char *saved = NULL; + if (pad_files > 0) { + char *old_workers = getenv("CBM_WORKERS"); + saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s", tmp, db_name); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + if (pad_files > 0) { + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + } + *out_p = p; + return rc == 0 ? cbm_store_open_path(db_path) : NULL; +} + +static int rust_registry_rust_only_case(int pad_files) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rust_only_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"); + write_temp_file(tmp, "src/store.rs", + "pub struct Store {\n" + " n: u32,\n" + "}\n" + "\n" + "impl Store {\n" + " pub fn new() -> Store {\n" + " Store { n: 0 }\n" + " }\n" + "\n" + " pub fn get(&self, k: u32) -> u32 {\n" + " self.n + k\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/main.rs", + "mod store;\n" + "\n" + "use crate::store::Store;\n" + "\n" + "fn run() -> u32 {\n" + " let s = Store::new();\n" + " s.get(1)\n" + "}\n" + "\n" + "fn main() {\n" + " run();\n" + "}\n"); + write_temp_file(tmp, "perlsrc/Lib.pm", + "package Lib;\n" + "\n" + "sub new {\n" + " my ($class) = @_;\n" + " return bless {}, $class;\n" + "}\n" + "\n" + "1;\n"); + cbm_pipeline_t *p = NULL; + cbm_store_t *s = rust_fixture_run(tmp, pad_files, "rust_only.db", &p); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + /* Never onto the Perl constructor ... */ + ASSERT_EQ(named_edge_to_file_count(s, project, "CALLS", "run", "new", "perlsrc/Lib.pm"), 0); + /* ... and the Rust call still binds the Rust associated function. */ + ASSERT_GTE(named_edge_to_file_count(s, project, "CALLS", "run", "new", "src/store.rs"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_rust_call_never_binds_another_language) { + return rust_registry_rust_only_case(0); +} + +TEST(pipeline_rust_call_never_binds_another_language_parallel) { + return rust_registry_rust_only_case(52); +} + +/* `Opts::write(true)` is an associated function. The LSP leaves it to the + * registry (import_map -> fsx.Opts.write), but its free-function short-name + * fallback bound it to the project's only free `write` (cache.write) first, and + * that LSP row pre-empted the right answer. With the cross-language namesakes + * gone (above), this hijack replaced 600+ correct qualified_suffix/import_map + * edges on the Rust corpus. A type-qualified call never takes that fallback. */ +static int rust_assoc_call_free_namesake_case(int pad_files) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rust_assoc_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"); + write_temp_file(tmp, "src/fsx.rs", + "pub struct Opts {\n" + " pub w: bool,\n" + "}\n" + "\n" + "impl Opts {\n" + " pub fn write(w: bool) -> Opts {\n" + " Opts { w }\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/cache.rs", + "pub fn write(p: &str) -> usize {\n" + " p.len()\n" + "}\n"); + write_temp_file(tmp, "src/main.rs", + "mod cache;\n" + "mod fsx;\n" + "\n" + "use crate::fsx::Opts;\n" + "\n" + "fn run() -> bool {\n" + " let o = Opts::write(true);\n" + " o.w\n" + "}\n" + "\n" + "fn main() {\n" + " run();\n" + " cache::write(\"x\");\n" + "}\n"); + cbm_pipeline_t *p = NULL; + cbm_store_t *s = rust_fixture_run(tmp, pad_files, "rust_assoc.db", &p); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + /* The associated function, never the free namesake ... */ + ASSERT_GTE(named_edge_to_file_count(s, project, "CALLS", "run", "write", "src/fsx.rs"), 1); + ASSERT_EQ(named_edge_to_file_count(s, project, "CALLS", "run", "write", "src/cache.rs"), 0); + /* ... while the module-qualified free call keeps its target. */ + ASSERT_GTE(named_edge_to_file_count(s, project, "CALLS", "main", "write", "src/cache.rs"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_rust_assoc_call_never_binds_free_namesake) { + return rust_assoc_call_free_namesake_case(0); +} + +TEST(pipeline_rust_assoc_call_never_binds_free_namesake_parallel) { + return rust_assoc_call_free_namesake_case(52); +} + TEST(pipeline_go_rw_usage_never_cross_into_c_parallel) { /* Parallel twin of the test above: USAGE and WRITES/READS each have a * second, independent resolver in pass_parallel.c (resolve_file_usages / @@ -23728,6 +23902,10 @@ SUITE(pipeline) { RUN_TEST(pipeline_html_embedded_member_call_stays_unbound); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); + RUN_TEST(pipeline_rust_call_never_binds_another_language); + RUN_TEST(pipeline_rust_call_never_binds_another_language_parallel); + RUN_TEST(pipeline_rust_assoc_call_never_binds_free_namesake); + RUN_TEST(pipeline_rust_assoc_call_never_binds_free_namesake_parallel); RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method); RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method_parallel); RUN_TEST(pipeline_go_bare_ref_never_binds_field); diff --git a/tests/test_rust_lsp.c b/tests/test_rust_lsp.c index 3c54839153..e5ab4cef39 100644 --- a/tests/test_rust_lsp.c +++ b/tests/test_rust_lsp.c @@ -591,6 +591,9 @@ TEST(rustlsp_shared_registry_resolves_like_per_file) { defs[1].receiver_type = "test.demo.Database"; defs[1].def_module_qn = "test.demo"; defs[1].return_types = "alloc.string.String"; + /* The shared build registers Rust defs only, as the pipeline tags them. */ + defs[0].lang = CBM_LANG_RUST; + defs[1].lang = CBM_LANG_RUST; const char *imp_names[] = {"demo"}; const char *imp_qns[] = {"test::demo"}; @@ -695,6 +698,7 @@ TEST(rustlsp_shared_registry_macro_hidden_call_has_carrier) { defs[0].label = "Function"; defs[0].def_module_qn = "test.lib"; defs[0].return_types = "str"; + defs[0].lang = CBM_LANG_RUST; const char *imp_names[] = {"lib"}; const char *imp_qns[] = {"test::lib"};