Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 58 additions & 21 deletions internal/cbm/lsp/rust_lsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `<T as Tr>::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. */
Expand Down Expand Up @@ -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.
Expand All @@ -4524,7 +4553,7 @@ static void rust_resolve_call_expression_inner(RustLSPContext *ctx, TSNode node)
* MEMBER crate by matching the registered QN's `.<member>.`
* 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));
Expand Down Expand Up @@ -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". */
Expand Down Expand Up @@ -6323,37 +6352,45 @@ 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;
CBMTypeRegistry *reg = (CBMTypeRegistry *)cbm_arena_alloc(arena, sizeof(*reg));
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;
}
Expand Down
50 changes: 31 additions & 19 deletions src/pipeline/pass_lsp_cross.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down
178 changes: 178 additions & 0 deletions tests/test_pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <tmp>/<db_name> 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 /
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading