fix(rust-lsp): a type-qualified call never takes the free-function fallback

`Opts::write(true)` is an associated function. When the Rust LSP cannot
place it, the registry can (import_map or qualified_suffix onto
fsx.Opts.write). The two free-function short-name fallbacks (the
cross-crate member lookup and the project-wide lsp_short_name_unique)
ran first, though. They bound the call to the project's only free
`write`, and that LSP row pre-empted the right answer.

While the Rust registry also held other languages' definitions, a
same-named JS or Perl function often made the free name ambiguous and
hid this. With Rust-only registries the hijack replaced 516
qualified_suffix and 84 import_map edges on 7 Rust repos.

A call whose qualifying segment names a type now skips both fallbacks.
That covers an ASCII-uppercase initial, `Self`, and a qualified
`<T as Tr>::f` path.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-18 02:59:00 +02:00
parent 42769c2d6d
commit ec4f0eaae6
2 changed files with 137 additions and 31 deletions
+31 -2
View File
@@ -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. */
@@ -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 `.<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));
@@ -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". */
+106 -29
View File
@@ -8874,6 +8874,40 @@ TEST(pipeline_go_rw_usage_never_cross_into_c) {
* 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");
@@ -8917,28 +8951,10 @@ static int rust_registry_rust_only_case(int pad_files) {
"}\n"
"\n"
"1;\n");
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/rust_only.db", tmp);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
const char *project = cbm_pipeline_project_name(p);
cbm_store_t *s = cbm_store_open_path(db_path);
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);
@@ -8947,14 +8963,6 @@ static int rust_registry_rust_only_case(int pad_files) {
cbm_store_close(s);
cbm_pipeline_free(p);
if (pad_files > 0) {
if (saved) {
cbm_setenv("CBM_WORKERS", saved, 1);
free(saved);
} else {
cbm_unsetenv("CBM_WORKERS");
}
}
th_rmtree(tmp);
PASS();
}
@@ -8967,6 +8975,73 @@ 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 /
@@ -23829,6 +23904,8 @@ SUITE(pipeline) {
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);