From 51a3e4e7b559e416c2eaff6caeacfca3a368dabe Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 17 Oct 2026 22:52:58 +0200 Subject: [PATCH 1/7] fix(python): resolve a base class spelled through a root-binding import `import pkg.mod.nodes` binds the ROOT name `pkg`, so `class X(pkg.mod.nodes.Context)` names module pkg.mod.nodes's Context. The cross-LSP base resolution asked the registry, whose import_map step joined the imported MODULE (the IMPORTS edge target, pkg.mod.nodes) with the whole remaining spelling (mod.nodes.Context) and missed; the base kept its raw spelling, so the Python LSP's inheritance view never saw it. Every member inherited through a dotted base fell to a name guess or to nothing: self.m() became unique_name, super().m() suffix_match, super().__init__() only resolved through a synthesized QN. pxc_resolve_base_qn now resolves such a spelling against the module the import names: the import map's module when the root local is unique there, else the dotted module path under the project's QN rule. Python only, import-backed and exact (no short-name step), and only a type-like project node is accepted. `import a.b as m` and `from a import b` bind the module itself and keep resolving through the import map. Tests: python_dotted_base_inheritance(_parallel): self.m(), super().m() and super().__init__() through a dotted base resolve by the LSP; the from-imported control keeps resolving; an untyped receiver stays without an LSP edge. Signed-off-by: Martin Vogel --- src/pipeline/pass_lsp_cross.c | 63 ++++++++++++++++++ tests/test_pipeline.c | 119 ++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index cf94926c0..bc48d75ae 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -238,6 +238,65 @@ typedef struct { CBMLanguage lang; } pxc_base_scope_t; +/* A type-like graph node, or NULL. */ +static const cbm_gbuf_node_t *pxc_type_node(const cbm_gbuf_node_t *node) { + return node && node->qualified_name && cbm_label_is_type_like(node->label) ? node : NULL; +} + +/* Python `import a.b.c` binds the ROOT name `a`, so a base spelled through it + * (`class X(a.b.c.Base)`) is module a.b.c's Base. The import map holds the + * imported MODULE under that root local (the IMPORTS edge target), and the + * registry's import_map step appends the whole remaining spelling to it + * (a.b.c + .b.c.Base), so a dotted base never resolved and every member + * inherited through it was invisible to the LSP. Resolve the spelling against + * the module the import names: the import map's module when that local is + * unique there, else the dotted module path under the project's QN rule. + * Import-backed and exact -- no short-name step -- and only a type-like + * project node is accepted. `import a.b as m` and `from a import b` bind the + * module itself and keep resolving through the import map. */ +static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const char *raw) { + if (bs->lang != CBM_LANG_PYTHON || !bs->imports || !bs->gbuf || !strchr(raw, '.')) { + return NULL; + } + size_t root_len = strcspn(raw, "."); + for (int i = 0; i < bs->imports->count; i++) { + const CBMImport *imp = &bs->imports->items[i]; + const char *local = imp->local_name; + const char *path = imp->module_path; + if (!local || !path || strlen(local) != root_len || strncmp(raw, local, root_len) != 0 || + strncmp(path, local, root_len) != 0 || (path[root_len] != '\0' && path[root_len] != '.')) { + continue; /* not a root binding of this base's first segment */ + } + size_t path_len = strlen(path); + if (strncmp(raw, path, path_len) != 0 || raw[path_len] != '.') { + continue; /* the base does not extend this import's module path */ + } + const char *module_qn = NULL; + int hits = 0; + for (int k = 0; k < bs->imp_count; k++) { + if (bs->imp_keys[k] && bs->imp_vals[k] && strcmp(bs->imp_keys[k], local) == 0) { + module_qn = bs->imp_vals[k]; + hits++; + } + } + const cbm_gbuf_node_t *node = NULL; + if (hits == 1) { + char candidate[CBM_SZ_512]; + int n = snprintf(candidate, sizeof(candidate), "%s%s", module_qn, raw + path_len); + if (n > 0 && (size_t)n < sizeof(candidate)) { + node = pxc_type_node(cbm_gbuf_find_by_qn(bs->gbuf, candidate)); + } + } + if (!node) { + node = pxc_type_node(cbm_pipeline_lsp_target_node(bs->gbuf, bs->project_name, raw, false)); + } + if (node) { + return node->qualified_name; + } + } + return NULL; +} + /* Resolve one base-class spelling to a project QN. Mirrors * pass_semantic.c::resolve_as_class — same registry, same type-like veto — * then additionally rejects weak short-name strategies (see above). @@ -248,6 +307,10 @@ static const char *pxc_resolve_base_qn(const pxc_base_scope_t *bs, const char *r if (!bs || !bs->reg || !raw || !raw[0]) { return NULL; } + const char *dotted = pxc_python_dotted_base_qn(bs, raw); + if (dotted) { + return dotted; + } cbm_resolution_t res = cbm_registry_resolve_lang(bs->reg, raw, module_qn, bs->imp_keys, bs->imp_vals, bs->imp_count, bs->lang); if (!res.qualified_name || !res.qualified_name[0]) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b52513ace..33b71d7d9 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -13517,6 +13517,123 @@ TEST(python_crossfile_typed_field_calls_issue1277_parallel) { return check_python_crossfile_typed_field_calls_issue1277(true); } +/* A base class spelled through a root-binding import (`import pkg.mod.nodes` + * binds `pkg`; `class X(pkg.mod.nodes.Context)`) is the same class as the + * from-imported spelling. The Python LSP's inheritance view never resolved the + * dotted spelling, so a member inherited through it (self.m(), super().m(), + * super().__init__()) fell to a weak name guess or to nothing. The plain + * from-import control must keep resolving; the untyped receiver must not. */ +static int check_python_dotted_base_inheritance(bool parallel) { + const char *nodes = "class Context:\n" + " def __init__(self, line=-1):\n" + " self.line = line\n\n" + " def ctx_only_helper(self):\n" + " return 1\n\n" + " def describe(self):\n" + " return 0\n"; + const char *types = "import pkg.mod.nodes\n" + "from pkg.mod.nodes import Context\n\n" + "class DottedType(pkg.mod.nodes.Context):\n" + " def __init__(self, line=-1):\n" + " super().__init__(line)\n\n" + " def run(self):\n" + " return self.ctx_only_helper()\n\n" + " def describe(self):\n" + " return super().describe()\n\n" + "class PlainType(Context):\n" + " def run(self):\n" + " return self.ctx_only_helper()\n\n" + "def untyped(thing):\n" + " return thing.ctx_only_helper()\n"; + if (setup_usages_repo("pkg/mod/nodes.py", nodes, "pkg/types.py", types) != 0) { + FAIL("failed to create temp dir"); + } + char path[512]; + const char *inits[] = {"pkg/__init__.py", "pkg/mod/__init__.py"}; + for (size_t i = 0; i < sizeof(inits) / sizeof(inits[0]); i++) { + snprintf(path, sizeof(path), "%s/%s", g_usages_tmpdir, inits[i]); + if (th_write_file(path, "") != 0) { + teardown_usages_repo(); + FAIL("failed to write package marker"); + } + } + if (parallel) { + for (int i = 0; i < 50; i++) { + snprintf(path, sizeof(path), "%s/dotted_pad_%02d.py", g_usages_tmpdir, i); + if (th_write_file(path, "dotted_padding = 0\n") != 0) { + teardown_usages_repo(); + FAIL("failed to write parallel-selection fixture"); + } + } + } + + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + if ((old_workers && !saved_workers) || (old_single && !saved_single)) { + free(saved_workers); + free(saved_single); + teardown_usages_repo(); + FAIL("failed to save pipeline environment"); + } + int pin_workers_rc = parallel ? cbm_setenv("CBM_WORKERS", "4", 1) : 0; + int pin_single_rc = parallel ? cbm_unsetenv("CBM_INDEX_SINGLE_THREAD") : 0; + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test_dotted_base.db", g_usages_tmpdir); + cbm_pipeline_t *p = pin_workers_rc == 0 && pin_single_rc == 0 + ? cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL) + : NULL; + int run_rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = run_rc == 0 ? cbm_store_open_path(db_path) : NULL; + bool store_opened = s != NULL; + int dotted_self = -1, dotted_super = -1, dotted_init = -1, plain_self = -1, untyped = -1; + if (s) { + const char *project = cbm_pipeline_project_name(p); + dotted_self = count_lsp_calls(s, project, "pkg.types.DottedType.run", + "pkg.mod.nodes.Context.ctx_only_helper"); + dotted_super = count_lsp_calls(s, project, "pkg.types.DottedType.describe", + "pkg.mod.nodes.Context.describe"); + dotted_init = count_lsp_calls(s, project, "pkg.types.DottedType.__init__", + "pkg.mod.nodes.Context.__init__"); + plain_self = count_lsp_calls(s, project, "pkg.types.PlainType.run", + "pkg.mod.nodes.Context.ctx_only_helper"); + untyped = count_lsp_calls(s, project, "pkg.types.untyped", "ctx_only_helper"); + cbm_store_close(s); + } + + cbm_pipeline_free(p); + int restore_workers_rc = + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + int restore_single_rc = saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1) + : cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + free(saved_workers); + free(saved_single); + teardown_usages_repo(); + + ASSERT_EQ(pin_workers_rc, 0); + ASSERT_EQ(pin_single_rc, 0); + ASSERT_EQ(restore_workers_rc, 0); + ASSERT_EQ(restore_single_rc, 0); + ASSERT_EQ(run_rc, 0); + ASSERT_TRUE(store_opened); + ASSERT_EQ(dotted_self, 1); /* self.m() inherited through a dotted base */ + ASSERT_EQ(dotted_super, 1); /* super().m() through a dotted base */ + ASSERT_EQ(dotted_init, 1); /* super().__init__() through a dotted base */ + ASSERT_EQ(plain_self, 1); /* control: from-imported base */ + ASSERT_EQ(untyped, 0); /* control: no receiver type, no LSP edge */ + PASS(); +} + +TEST(python_dotted_base_inheritance) { + return check_python_dotted_base_inheritance(false); +} + +TEST(python_dotted_base_inheritance_parallel) { + return check_python_dotted_base_inheritance(true); +} + /* Bind the external-import veto specifically to #1277's typed-field fold. * No base classes or CALLS edges participate: a name-only call fallback * cannot turn this metadata assertion into a false positive. The unguarded @@ -23665,6 +23782,8 @@ SUITE(pipeline) { RUN_TEST(override_python_through_intermediate_class); RUN_TEST(python_crossfile_typed_field_calls_issue1277); RUN_TEST(python_crossfile_typed_field_calls_issue1277_parallel); + RUN_TEST(python_dotted_base_inheritance); + RUN_TEST(python_dotted_base_inheritance_parallel); RUN_TEST(python_typed_field_external_import_stays_unresolved); RUN_TEST(python_typed_field_project_class_resolves); RUN_TEST(usages_creates_edges); From 2ac9c33e187f600ab47f29b718a2a544f2be700a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 17 Oct 2026 23:22:56 +0200 Subject: [PATCH 2/7] fix(rust-lsp): a Rust call never binds another language's definition Both Rust registries were built from the pipeline's mixed-language def set. The shared Tier-2 build (cbm_rust_build_cross_registry) and the per-file conversion (pxc_lspdefs_to_rust) copied every def, so a Perl `sub new` or any C function became a Rust free function. The lsp_short_name_unique fallback then bound `Store::new()` to the Perl constructor at confidence 0.90 on both venues: its "crate scope" check compares only the first module-QN segment, which is the project name. (Found by the generic-engine lane on a 12-language fixture.) Register Rust defs only, at both conversion points, exactly as the Go builder filters to Go. Rust impl-relation records already carry CBM_LANG_RUST. As a side effect, the shared Rust build no longer registers the C defs of a mixed C+Rust tree. Tests: pipeline_rust_call_never_binds_another_language (sequential and parallel): no CALLS from the Rust caller into the Perl file, and the Rust associated function still binds. Signed-off-by: Martin Vogel --- internal/cbm/lsp/rust_lsp.c | 42 ++++++++------ src/pipeline/pass_lsp_cross.c | 50 ++++++++++------- tests/test_pipeline.c | 101 ++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 36 deletions(-) diff --git a/internal/cbm/lsp/rust_lsp.c b/internal/cbm/lsp/rust_lsp.c index 44680cb69..959f4d1cf 100644 --- a/internal/cbm/lsp/rust_lsp.c +++ b/internal/cbm/lsp/rust_lsp.c @@ -6323,7 +6323,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 +6334,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; + 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, rdefs, def_count, /*module_qn=*/NULL); + 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 cf94926c0..940c82cb2 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 b52513ace..10d85d3ee 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8757,6 +8757,105 @@ 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. */ +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"); + 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); + ASSERT_NOT_NULL(s); + + /* 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); + if (pad_files > 0) { + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + } + 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); +} + 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 / @@ -23615,6 +23714,8 @@ 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_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); From bfe53f344b8a41c73aba8506c7e38ff9f89f3e92 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 17 Oct 2026 23:34:24 +0200 Subject: [PATCH 3/7] style: clang-format the new lines Formatting only, with the repo's clang-format (Homebrew LLVM). Signed-off-by: Martin Vogel --- src/pipeline/pass_lsp_cross.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 940c82cb2..376b25b6b 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -1475,7 +1475,7 @@ const struct CBMCargoManifest *cbm_pxc_get_rust_manifest(void) { * 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) { + int *out_count) { *out_count = 0; if (!defs || def_count <= 0) return NULL; From cc79e1d63fe0cd3906a0d00361280df0dda7c2a7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 02:51:25 +0200 Subject: [PATCH 4/7] style(pipeline): clang-format the dotted-base resolution Whitespace only; the token stream is unchanged. Signed-off-by: Martin Vogel --- src/pipeline/pass_lsp_cross.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index bc48d75ae..9ad97bee8 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -264,7 +264,8 @@ static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const c const char *local = imp->local_name; const char *path = imp->module_path; if (!local || !path || strlen(local) != root_len || strncmp(raw, local, root_len) != 0 || - strncmp(path, local, root_len) != 0 || (path[root_len] != '\0' && path[root_len] != '.')) { + strncmp(path, local, root_len) != 0 || + (path[root_len] != '\0' && path[root_len] != '.')) { continue; /* not a root binding of this base's first segment */ } size_t path_len = strlen(path); @@ -288,7 +289,8 @@ static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const c } } if (!node) { - node = pxc_type_node(cbm_pipeline_lsp_target_node(bs->gbuf, bs->project_name, raw, false)); + node = + pxc_type_node(cbm_pipeline_lsp_target_node(bs->gbuf, bs->project_name, raw, false)); } if (node) { return node->qualified_name; From ec4f0eaae638540134ef1f08aabcf8aa5b352b92 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 02:59:00 +0200 Subject: [PATCH 5/7] 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 `::f` path. Signed-off-by: Martin Vogel --- internal/cbm/lsp/rust_lsp.c | 33 ++++++++- tests/test_pipeline.c | 135 ++++++++++++++++++++++++++++-------- 2 files changed, 137 insertions(+), 31 deletions(-) diff --git a/internal/cbm/lsp/rust_lsp.c b/internal/cbm/lsp/rust_lsp.c index 959f4d1cf..27c1feab6 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". */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d3b1239e4..ccf556bf5 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -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 + * / 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); From 8469fed9602db7ff1d115abe5fbc0d887405b4d8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 06:55:57 +0200 Subject: [PATCH 6/7] test(rust-lsp): tag the shared-registry fixtures as Rust defs The shared Tier-2 build now registers Rust definitions only. Two fixtures that feed cbm_rust_build_cross_registry left CBMLSPDef.lang zero, which the pipeline never does (pass_lsp_cross.c tags every def with its file's language). The other shared-registry fixtures in this file already set CBM_LANG_RUST. Set it on these two as well. Signed-off-by: Martin Vogel --- tests/test_rust_lsp.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_rust_lsp.c b/tests/test_rust_lsp.c index 3c5483915..e5ab4cef3 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"}; From 67ff40d5cbe88d3fe933b846ab228340e8bb2f7d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 16:42:30 +0200 Subject: [PATCH 7/7] fix(kotlin): resolve calls through typed class properties (#1555) The cross-file Kotlin walk resolved a call on a typed method parameter by type, but a call on a class property - a primary-constructor val, a body property or `this.prop` - fell to the name guess. The cross registry held each own-file class from its extraction defs, whose properties carry no type (constructor vals none at all), and the walk's typed AST registration of the same class was added behind that entry under the same QN, so every lookup returned the field-less one. Constructor injection is the common Kotlin dependency shape, so `repository.inserir(...)` on an injected port bound to whichever same-named method the guess found, including an implementation of another port (#1555). The walk now registers the file's own declarations into an overlay chained to the cross registry and checked first, indexed once the top-level declarations are collected. The sole-definer fallback walks the whole chain so its candidate set is unchanged. Typing more receivers exposed resolution gaps that the untyped receivers had hidden; each would otherwise turn a typed call into a wrong name guess: - a member of the receiver class wins over a same-named extension of the same file (Kotlin resolution order; the overlay holds both); - `x.hashCode()` / `toString()` / `equals()` on a receiver typed by another file: the per-file pass emits the kotlin.Any placeholder, the cross pass the declared override, and two targets at one occurrence are ambiguous to the call join. The per-file placeholder is dropped when the cross pass resolves the same occurrence; - the same members on a stdlib or JDK type (`count.toString()` on an Int) resolve to the kotlin.Any builtin instead of a seeded stdlib member that has no graph node; - a property READ on a typed receiver whose QN the graph spells differently (a nested class's property) never becomes a CALL_REFERENCE to a same-named function: the reference join falls back by name to non-callable targets only, which binds the property's own node; - a typed value without an `invoke` operator (`private val handshake: Handshake?`) no longer shadows the same-named member function; - inside a lambda whose typed receiver becomes `this` (`repo.let { .. }`), and inside a member extension, the enclosing class's members stay callable bare (the implicit-receiver chain); a call naming a class stays a constructor call. Fixes #1555 Signed-off-by: Martin Vogel --- Makefile.cbm | 2 +- internal/cbm/lsp/kotlin_lsp.c | 170 +++++++++++++-- internal/cbm/lsp/kotlin_lsp.h | 7 + src/pipeline/lsp_resolve.h | 18 ++ src/pipeline/pass_lsp_cross.c | 63 ++++++ src/pipeline/pass_parallel.c | 11 + src/pipeline/pass_usages.c | 11 + tests/test_kotlin_typed_calls.c | 359 ++++++++++++++++++++++++++++++++ tests/test_main.c | 2 + 9 files changed, 628 insertions(+), 15 deletions(-) create mode 100644 tests/test_kotlin_typed_calls.c diff --git a/Makefile.cbm b/Makefile.cbm index b4b8c8827..6d19f430a 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -846,7 +846,7 @@ TEST_PY_LSP_SCALE_SRCS = tests/test_py_lsp_scale.c TEST_TS_LSP_SRCS = tests/test_ts_lsp.c TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c -TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c +TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c tests/test_kotlin_typed_calls.c TEST_RUST_LSP_SRCS = tests/test_rust_lsp.c TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c tests/test_conditional_variants.c tests/test_spawns.c diff --git a/internal/cbm/lsp/kotlin_lsp.c b/internal/cbm/lsp/kotlin_lsp.c index e860e8857..be86587ca 100644 --- a/internal/cbm/lsp/kotlin_lsp.c +++ b/internal/cbm/lsp/kotlin_lsp.c @@ -395,9 +395,47 @@ static const CBMType *kt_unwrap_nullable(const CBMType *t) { return t; } +/* A kotlin.Any member (toString / equals / hashCode) resolved on a stdlib or + * JDK type -- `count.toString()` on `val count: Int` binds kotlin.Int.toString + * from the stdlib seed. That target has no graph node, so the call join fell + * back to a name guess and bound the call to whichever project class declares + * a same-named override (#1555: detekt `defaultValue.toString()` -> + * Entity.toString). The member is the universal kotlin.Any one, an external + * builtin node: emit that, no project edge. `kotlin.` and `java.` are reserved + * packages (the compilers reject project declarations in them), so the prefix + * proves the owner is not a project type; project QNs in this resolver are + * package-spelled and keep their declared override. */ +static bool kt_is_any_member(const char *name) { + return strcmp(name, "toString") == 0 || strcmp(name, "equals") == 0 || + strcmp(name, "hashCode") == 0; +} + +static const char *kt_external_any_member(KotlinLSPContext *ctx, const char *callee_qn) { + const char *dot = strrchr(callee_qn, '.'); + if (!dot || !kt_is_any_member(dot + 1)) { + return NULL; + } + if (strncmp(callee_qn, "kotlin.Any.", strlen("kotlin.Any.")) == 0) { + return NULL; /* already the builtin */ + } + if (strncmp(callee_qn, "kotlin.", strlen("kotlin.")) != 0 && + strncmp(callee_qn, "java.", strlen("java.")) != 0) { + return NULL; /* a project override: a real node */ + } + return kt_join_dot(ctx->arena, "kotlin.Any", dot + 1); +} + /* Append a typed resolution result to the output array. */ static void kt_emit_resolved_kind(KotlinLSPContext *ctx, const char *callee_qn, const char *strategy, float confidence, CBMResolvedKind kind) { + if (kind == CBM_RESOLVED_INVOCATION && callee_qn) { + const char *any_qn = kt_external_any_member(ctx, callee_qn); + if (any_qn) { + callee_qn = any_qn; + strategy = "lsp_kt_any"; + confidence = confidence < KT_CONF_PARTIAL ? confidence : KT_CONF_PARTIAL; + } + } if (ctx->debug) { fprintf(stderr, "[kotlin_lsp] EMIT %s -> %s [%s %.2f] (resolved_calls=%p enc=%s)\n", ctx->enclosing_func_qn ? ctx->enclosing_func_qn : "(null)", @@ -1127,20 +1165,26 @@ const char *kotlin_resolve_function_name(KotlinLSPContext *ctx, const char *name * package/import/bare/default-import lookups miss, so it never overrides a * more specific match; in the per-file pass the registry holds just this * file's defs, so the candidate is the file's own sole top-level fun. */ - if (ctx->registry && ctx->registry->funcs) { + /* The cross-file walk runs on an own-file overlay chained to the cross + * registry (#1555), so the candidates span the whole chain; a function + * registered in both layers under one QN is one candidate. */ + { const char *only_qn = NULL; int matches = 0; - for (int i = 0; i < ctx->registry->func_count && matches < 2; i++) { - const CBMRegisteredFunc *f = &ctx->registry->funcs[i]; - if (!f->qualified_name || !f->short_name) { - continue; - } - if (f->receiver_type) { /* method / extension — not a bare top-level fun */ - continue; - } - if (strcmp(f->short_name, name) == 0) { - only_qn = f->qualified_name; - matches++; + for (const CBMTypeRegistry *r = ctx->registry; r && matches < 2; r = r->fallback) { + for (int i = 0; r->funcs && i < r->func_count && matches < 2; i++) { + const CBMRegisteredFunc *f = &r->funcs[i]; + if (!f->qualified_name || !f->short_name) { + continue; + } + if (f->receiver_type) { /* method / extension — not a bare top-level fun */ + continue; + } + if (strcmp(f->short_name, name) == 0 && + !(only_qn && strcmp(only_qn, f->qualified_name) == 0)) { + only_qn = f->qualified_name; + matches++; + } } } if (matches == 1 && only_qn) { @@ -1335,6 +1379,22 @@ const CBMRegisteredFunc *kotlin_lookup_method(KotlinLSPContext *ctx, const char const CBMRegisteredFunc *rf = cbm_registry_lookup_method_aliased(ctx->registry, cur_qn, method_name); if (rf) { + /* A member wins over an extension of the same name (Kotlin + * resolution). The file's own extension functions carry the + * extended class as receiver_type, so the receiver lookup can + * return `fun Builder.add(..)` for a call meant for the member + * `Builder.add`; the member's QN nests under the class. */ + size_t cur_len = strlen(cur_qn); + bool is_member = rf->qualified_name && + strncmp(rf->qualified_name, cur_qn, cur_len) == 0 && + rf->qualified_name[cur_len] == '.'; + if (!is_member) { + const CBMRegisteredFunc *member = cbm_registry_lookup_func( + ctx->registry, kt_join_dot(ctx->arena, cur_qn, method_name)); + if (member) { + return member; + } + } return rf; } @@ -2907,6 +2967,37 @@ out: return result ? result : cbm_type_unknown(); } +/* `handshake()` inside a class whose constructor declares `private val + * handshake: Handshake?` next to `override fun handshake()` (okhttp + * RealConnection): the property is in the method scope (class fields are bound + * there), but a value of a known type without an `invoke` operator is no call + * candidate, so Kotlin resolves the call to the member function. Once #1555 + * typed the class's constructor vals, the lexical-shadow rule below failed + * such calls closed and they fell to a name guess. An unknown or callable + * binding still shadows, as before. */ +static bool kt_value_binding_yields_to_member(KotlinLSPContext *ctx, const CBMType *bound, + const char *name) { + if (!bound || cbm_type_is_unknown(bound) || bound->kind == CBM_TYPE_CALLABLE || + bound->kind == CBM_TYPE_FUNC || !ctx->this_type || cbm_type_is_unknown(ctx->this_type)) { + return false; + } + const CBMType *value = kt_unwrap_nullable(bound); + const char *value_qn = kt_type_qn_of(value); + if (value_qn && kotlin_lookup_method(ctx, value_qn, "invoke")) { + return false; /* an invokable value: the call may be `value.invoke()` */ + } + const char *this_qn = kt_type_qn_of(ctx->this_type); + if (!this_qn) { + return false; + } + const CBMRegisteredFunc *rf = kotlin_lookup_method(ctx, this_qn, name); + if (!rf || !rf->qualified_name) { + return false; + } + size_t len = strlen(this_qn); + return strncmp(rf->qualified_name, this_qn, len) == 0 && rf->qualified_name[len] == '.'; +} + static const CBMType *kt_eval_constructor_or_func_call(KotlinLSPContext *ctx, TSNode call_node, const char *callee_text) { /* callee_text is a bare identifier — it may be: @@ -2944,7 +3035,10 @@ static const CBMType *kt_eval_constructor_or_func_call(KotlinLSPContext *ctx, TS bound->data.func.return_types[0]) { return bound->data.func.return_types[0]; } - return cbm_type_unknown(); + if (!kt_value_binding_yields_to_member(ctx, bound, callee_text)) { + return cbm_type_unknown(); + } + /* A known non-invokable value yields to the member function: step 1. */ } /* 1. `this`-method dispatch: if we're inside a class method or DSL @@ -2967,6 +3061,37 @@ static const CBMType *kt_eval_constructor_or_func_call(KotlinLSPContext *ctx, TS } } + /* 1b. Implicit receivers form a chain, innermost first: inside a lambda + * whose receiver (or `it` type, which kt_process_lambda also installs as + * `this`) is another type, and inside a member extension whose extension + * receiver is another type, the enclosing class's members stay callable + * bare. Once #1555 typed more receivers, `repo.let { ping() }` resolved + * against Repo only and fell to a name guess. A call naming a class is a + * constructor call, left to step 2 (a secondary constructor is registered + * as a same-named member and would otherwise be taken for a method). */ + const char *ctor_cls = kotlin_resolve_class_name(ctx, callee_text); + bool is_ctor_call = + ctor_cls && ctx->registry && cbm_registry_lookup_type(ctx->registry, ctor_cls) != NULL; + if (ctx->enclosing_class_qn && !is_ctor_call) { + const char *this_qn = (ctx->this_type && !cbm_type_is_unknown(ctx->this_type)) + ? kt_type_qn_of(ctx->this_type) + : NULL; + if (!this_qn || strcmp(this_qn, ctx->enclosing_class_qn) != 0) { + const CBMRegisteredFunc *rf = + kotlin_lookup_method(ctx, ctx->enclosing_class_qn, callee_text); + if (rf && rf->qualified_name) { + kt_emit_resolved_at(ctx, rf->qualified_name, "lsp_kt_this", KT_CONF_THIS, + call_node); + if (rf->signature && rf->signature->kind == CBM_TYPE_FUNC && + rf->signature->data.func.return_types && + rf->signature->data.func.return_types[0]) { + return rf->signature->data.func.return_types[0]; + } + return cbm_type_unknown(); + } + } + } + /* 2. Class? */ const char *cls_qn = kotlin_resolve_class_name(ctx, callee_text); if (cls_qn && ctx->registry) { @@ -4533,6 +4658,11 @@ void kotlin_lsp_process_file(KotlinLSPContext *ctx, TSNode root) { /* 2. Collect top-level definitions for intra-file resolution. */ kt_collect_top_level_decls(ctx, root); + /* Cross-file: index the own-file overlay now that its declarations are + * in; later additions stay visible through the post-finalize tail. */ + if (ctx->overlay_index_arena && ctx->registry) { + cbm_registry_finalize_into((CBMTypeRegistry *)ctx->registry, ctx->overlay_index_arena); + } /* 3. Walk for call resolution. */ kt_walk_top_level_for_resolution(ctx, root, NULL); @@ -5436,12 +5566,24 @@ void cbm_run_kotlin_lsp_cross(CBMArena *arena, const char *source, int source_le project_name = module_qn; } + /* Own-file overlay (#1555). The cross registry above already holds this + * file's classes, built from extraction defs whose properties carry no + * type (primary-constructor vals none at all). The walk re-registers each + * class from the AST with typed fields; registered into `reg` under the + * same QN that entry was shadowed by the earlier one, so every + * `repository.inserir()` on an injected property fell to the name guess. + * In an overlay checked first, the AST entry wins. */ + CBMTypeRegistry overlay; + cbm_registry_init(&overlay, arena); + overlay.fallback = ® + KotlinLSPContext ctx; /* Match the per-file pass: until a source package declaration overrides * it, the path-derived module is the callable-QN prefix. This preserves * exact caller identity for default-package functions across files. */ - kotlin_lsp_init(&ctx, arena, source, source_len, ®, module_qn ? module_qn : "", + kotlin_lsp_init(&ctx, arena, source, source_len, &overlay, module_qn ? module_qn : "", module_qn ? module_qn : "", project_name, /*rel_path=*/NULL, out); + ctx.overlay_index_arena = &idx_arena; /* Unique short type name -> registered QN, for receiver annotations that * name a type defined in another file without an import. Two types diff --git a/internal/cbm/lsp/kotlin_lsp.h b/internal/cbm/lsp/kotlin_lsp.h index 98e048e6f..6359b5aa6 100644 --- a/internal/cbm/lsp/kotlin_lsp.h +++ b/internal/cbm/lsp/kotlin_lsp.h @@ -147,6 +147,13 @@ typedef struct KotlinLSPContext { * each field's REAL def QN here (kotlin class properties are minted with * module-level QNs, so composing . would miss the node). */ const void *cross_field_map; + /* Cross-file only (NULL per-file): when set, `registry` is a per-file + * overlay chained to the cross registry, and kotlin_lsp_process_file + * indexes the overlay into this scratch arena once the file's own + * declarations are collected. The overlay holds the AST registrations + * (typed primary-constructor and body properties), so they are found + * before the extraction-built entries of the same QN (#1555). */ + CBMArena *overlay_index_arena; } KotlinLSPContext; /* Initialize a KotlinLSPContext for processing one file. */ diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index d67918735..647b3f801 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -223,6 +223,24 @@ static inline bool cbm_pipeline_node_is_callable_target(const cbm_gbuf_node_t *n strcmp(node->label, "Constructor") == 0 || strcmp(node->label, "Class") == 0; } +/* The Kotlin resolver claims a property READ on a typed receiver with an exact + * row (lsp_kt_property_ref) so that the raw-name fallback cannot borrow a + * same-named function. That row names a property, never a function value. Its + * QN is not always materialized under that spelling (a property of a nested + * class); the graph lookup then finds nothing, or its unique-callable step + * picks a same-named function elsewhere (#1555: okhttp `server.webSocket` -> a + * test function `webSocket`, a fabricated CALL_REFERENCE). Such a row is not + * admissible: the caller falls back by name, restricted to non-callable + * targets, which binds the property's own node as before the receiver was + * typed and can never borrow a function. */ +static inline bool cbm_pipeline_reference_target_admissible(const CBMResolvedCall *ref, + const cbm_gbuf_node_t *tgt) { + if (!ref || !ref->strategy || strcmp(ref->strategy, "lsp_kt_property_ref") != 0) { + return true; + } + return tgt && !cbm_pipeline_node_is_callable_target(tgt); +} + static inline int cbm_pipeline_qn_class_method_tail_eq(const char *qn, const char *tail) { const char *qt = cbm_pipeline_qn_class_method_tail(qn); return qt && tail && strcmp(qt, tail) == 0; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index cf94926c0..4f47e1b6b 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -1285,6 +1285,68 @@ static void pxc_supersede_occurrence(CBMArena *dst_arena, CBMResolvedCallArray * dst_calls->count = kept; } +/* The Kotlin universal-member placeholder: `x.hashCode()` / `toString()` / + * `equals()` on a receiver the resolver could not type is emitted on kotlin.Any + * (strategy lsp_kt_any, reduced confidence) -- the "no narrower receiver known" + * answer. */ +static bool pxc_is_kt_any_placeholder(const CBMResolvedCall *rc) { + return rc->strategy && strcmp(rc->strategy, "lsp_kt_any") == 0; +} + +static char *pxc_occurrence_key(CBMArena *keys, const CBMResolvedCall *rc) { + return cbm_arena_sprintf(keys, "%u\x1f%s\x1f%u:%u\x1f%u", (unsigned)rc->kind, rc->caller_qn, + rc->site_start_byte, rc->site_end_byte, (unsigned)rc->source_origin); +} + +/* #1555: the per-file pass sees only the file's own declarations, so a receiver + * typed by another file (`val type: ProtoType`) is unknown there and + * `type.hashCode()` gets the kotlin.Any placeholder. The cross pass types the + * receiver and answers with the declared override (`ProtoType.hashCode`), or + * with kotlin.Any again when no class on the receiver's chain declares one. Two + * distinct targets at one occurrence are a tombstone in the call join + * (pass_parallel.c LSP_IDX_AMBIGUOUS), so the occurrence fell to the name-based + * registry guess (wire: `type.hashCode` -> AllTypes.hashCode). A placeholder is + * therefore dropped when a cross row resolves the same occurrence; the cross row + * is then the only evidence. One hash pass per side keeps this linear. */ +static void pxc_drop_superseded_kt_any(CBMArena *keys, CBMResolvedCallArray *dst_calls, + const CBMResolvedCallArray *src_out) { + bool has_placeholder = false; + for (int i = 0; i < dst_calls->count && !has_placeholder; i++) { + has_placeholder = pxc_is_kt_any_placeholder(&dst_calls->items[i]); + } + if (!has_placeholder) { + return; + } + CBMHashTable *resolved = cbm_ht_create((uint32_t)src_out->count + 1); + for (int j = 0; j < src_out->count; j++) { + const CBMResolvedCall *src = &src_out->items[j]; + if (!src->caller_qn || !src->callee_qn || pxc_is_kt_any_placeholder(src) || + src->site_end_byte <= src->site_start_byte) { + continue; + } + char *k = pxc_occurrence_key(keys, src); + if (k) { + cbm_ht_set(resolved, k, (void *)(uintptr_t)1); + } + } + int kept = 0; + for (int i = 0; i < dst_calls->count; i++) { + const CBMResolvedCall *cur = &dst_calls->items[i]; + if (pxc_is_kt_any_placeholder(cur) && cur->caller_qn) { + char *k = pxc_occurrence_key(keys, cur); + if (k && cbm_ht_get(resolved, k)) { + continue; + } + } + if (kept != i) { + dst_calls->items[kept] = *cur; + } + kept++; + } + dst_calls->count = kept; + cbm_ht_free(resolved); +} + static void pxc_append_results(CBMArena *dst_arena, CBMResolvedCallArray *dst_calls, const CBMResolvedCallArray *src_out) { if (!dst_calls || !src_out) @@ -1312,6 +1374,7 @@ static void pxc_append_results(CBMArena *dst_arena, CBMResolvedCallArray *dst_ca } pxc_supersede_occurrence(dst_arena, dst_calls, src); } + pxc_drop_superseded_kt_any(&keys, dst_calls, src_out); for (int i = 0; i < dst_calls->count; i++) { const CBMResolvedCall *rc = &dst_calls->items[i]; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 2c0a9023a..4e398c948 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3616,6 +3616,7 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, const cbm_gbuf_node_t *tgt = NULL; bool precise_call_reference = false; const CBMResolvedCall *semantic_reference = NULL; + bool property_read = false; if (cbm_pipeline_usage_semantic_reference_candidate(usage)) { bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); semantic_reference = cbm_pipeline_find_lsp_reference_indexed_in_graph( @@ -3628,6 +3629,13 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (semantic_reference) { tgt = cbm_pipeline_lsp_target_node(rc->main_gbuf, rc->project_name, semantic_reference->callee_qn, allow_tail); + if (!cbm_pipeline_reference_target_admissible(semantic_reference, tgt)) { + /* a property read: the name fallback below may still bind the + * property's own node, never a callable */ + tgt = NULL; + semantic_reference = NULL; + property_read = true; + } precise_call_reference = cbm_pipeline_node_is_callable_target(tgt); } } @@ -3683,6 +3691,9 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, cbm_pipeline_node_is_callable_target(tgt))) { continue; } + if (property_read && cbm_pipeline_node_is_callable_target(tgt)) { + continue; + } } if (!tgt || src->id == tgt->id) { continue; diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index d47f53de6..972822522 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -213,6 +213,7 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res const cbm_gbuf_node_t *tgt = NULL; bool precise_call_reference = false; const CBMResolvedCall *semantic_reference = NULL; + bool property_read = false; if (cbm_pipeline_usage_semantic_reference_candidate(usage)) { bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); semantic_reference = cbm_pipeline_find_lsp_reference_indexed_in_graph( @@ -225,6 +226,13 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (semantic_reference) { tgt = cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, semantic_reference->callee_qn, allow_tail); + if (!cbm_pipeline_reference_target_admissible(semantic_reference, tgt)) { + /* a property read: the name fallback below may still bind the + * property's own node, never a callable */ + tgt = NULL; + semantic_reference = NULL; + property_read = true; + } precise_call_reference = cbm_pipeline_node_is_callable_target(tgt); } } @@ -285,6 +293,9 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res cbm_pipeline_node_is_callable_target(tgt))) { continue; } + if (property_read && cbm_pipeline_node_is_callable_target(tgt)) { + continue; + } } if (!tgt || src->id == tgt->id) { continue; diff --git a/tests/test_kotlin_typed_calls.c b/tests/test_kotlin_typed_calls.c new file mode 100644 index 000000000..5b0aca528 --- /dev/null +++ b/tests/test_kotlin_typed_calls.c @@ -0,0 +1,359 @@ +/* + * test_kotlin_typed_calls.c — Kotlin member calls through a class PROPERTY + * (primary-constructor `val`, body property, `this.prop`) bind to the + * property's declared type, on both resolver venues (#1555). + * + * The cross-file Kotlin walk resolved a typed method PARAMETER by type, but + * a property-typed receiver fell to the name guess: the cross registry held + * the class from extraction defs (properties untyped, constructor vals + * absent), and the walk's typed AST registration of the same class was + * shadowed by that earlier entry. Each fixture call has a same-named decoy + * method on another type, so a name guess is visible as a wrong target: + * - Service.log: `audit.inserir()` on `audit: Audit`, next to + * `repository: Repo` whose interface also declares inserir (the + * sequential venue's guess picks Repo.inserir); + * - two bounded contexts whose use cases both inject `repository` + * (the parallel venue's receiver-name guess bound the feriado use case + * to CompromissoRepository.inserir, the #1555 report). + * One fixture, padded past MIN_FILES_FOR_PARALLEL, is indexed through the + * sequential resolver (CBM_INDEX_SINGLE_THREAD=1) and the parallel one. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "pipeline/pipeline.h" +#include "sqlite3.h" + +#include +#include +#include + +static const char *const KTC_FILES[][2] = { + {"src/main/kotlin/com/acme/a/Repo.kt", + "package com.acme.a\n\ninterface Repo {\n fun inserir(x: String): Long\n}\n"}, + {"src/main/kotlin/com/acme/b/Audit.kt", + "package com.acme.b\n\nclass Audit {\n fun inserir(x: String): Long = 0L\n}\n"}, + {"src/main/kotlin/com/acme/app/Service.kt", + "package com.acme.app\n\nimport com.acme.a.Repo\nimport com.acme.b.Audit\n\n" + "class Service(private val repository: Repo, private val audit: Audit) {\n" + " fun save(x: String): Long = repository.inserir(x)\n" + " fun log(x: String): Long = audit.inserir(x)\n}\n\n" + "class Holder {\n private val audit: Audit = Audit()\n" + " fun hold(x: String): Long = this.audit.inserir(x)\n}\n"}, + {"src/main/kotlin/com/acme/agenda/domain/CompromissoRepository.kt", + "package com.acme.agenda.domain\n\ninterface CompromissoRepository {\n" + " fun inserir(titulo: String): Long\n}\n"}, + {"src/main/kotlin/com/acme/feriado/domain/FeriadoRepository.kt", + "package com.acme.feriado.domain\n\ninterface FeriadoRepository {\n" + " fun inserir(nome: String): Long\n}\n"}, + {"src/main/kotlin/com/acme/agenda/app/CriarCompromissoUseCase.kt", + "package com.acme.agenda.app\n\nimport com.acme.agenda.domain.CompromissoRepository\n\n" + "class CriarCompromissoUseCase(private val repository: CompromissoRepository) {\n" + " fun executar(titulo: String): Long = repository.inserir(titulo)\n}\n"}, + {"src/main/kotlin/com/acme/feriado/app/CriarFeriadoUseCase.kt", + "package com.acme.feriado.app\n\nimport com.acme.feriado.domain.FeriadoRepository\n\n" + "class CriarFeriadoUseCase(private val repository: FeriadoRepository) {\n" + " fun executar(nome: String): Long = repository.inserir(nome)\n}\n"}, + /* Guard: inside an extension function, a bare call names the receiver's + * MEMBER before a same-named extension of the same file (Kotlin + * resolution); main gets this right and the own-file overlay must too. */ + {"src/main/kotlin/com/acme/b/Builders.kt", + "package com.acme.b\n\nclass Builder {\n fun add(x: Int): Boolean = true\n" + " fun put(k: String, v: Int): Int = v\n}\n\n" + "fun Builder.add(s: String): Boolean = add(s.length)\n\n" + "fun Builder.putAll(k: String) { put(k, 1) }\n\n" + "fun Builder.put(k: String): Int = put(k, 0)\n"}, + /* kotlin.Any members on a receiver typed by ANOTHER file. The per-file + * pass cannot type `type`, so it emits the kotlin.Any placeholder; the + * cross pass names the declared override. Both rows at one occurrence + * were a tombstone in the call join and the call fell to a name guess + * (AllTypes is the decoy). A class with no override keeps kotlin.Any, and + * so does a stdlib receiver type (`count: Int`), whose seeded + * kotlin.Int.toString names no node and left the call to the guess. */ + {"src/main/kotlin/com/acme/c/Proto.kt", + "package com.acme.c\n\nclass ProtoType {\n private val s: String = \"\"\n" + " override fun hashCode() = s.length\n\n override fun toString() = s\n}\n\n" + "class Plain(val n: Int)\n"}, + {"src/main/kotlin/com/acme/d/Member.kt", + "package com.acme.d\n\nimport com.acme.c.Plain\nimport com.acme.c.ProtoType\n\n" + "class Member(val type: ProtoType, val plain: Plain, val count: Int) {\n" + " override fun hashCode() = type.hashCode() * 37\n\n" + " fun label(): String = type.toString()\n\n" + " fun plainHash(): Int = plain.hashCode()\n\n" + " fun countText(): String = count.toString()\n}\n"}, + {"src/main/kotlin/com/acme/e/AllTypes.kt", + "package com.acme.e\n\nclass AllTypes {\n override fun hashCode(): Int = 1\n\n" + " override fun toString(): String = \"all\"\n}\n"}, + /* A property READ on a now-typed receiver (a nested class's property, whose + * QN the graph spells differently): it must keep the property's own node + * (USAGE) and never become a CALL_REFERENCE to a same-named function in + * another file (okhttp `server.webSocket` -> a test fun `webSocket`). */ + {"src/main/kotlin/com/acme/w/SocketTest.kt", + "package com.acme.w\n\nclass SocketTest {\n" + " private val server: Streams = Streams()\n\n" + " fun loop(): Boolean {\n return server.webSocket != null\n }\n\n" + " class Streams {\n var webSocket: String? = null\n }\n}\n"}, + {"src/main/kotlin/com/acme/w2/ModernTest.kt", + "package com.acme.w2\n\nclass ModernTest {\n fun webSocket(): Int = 1\n}\n"}, + /* A constructor val next to a same-named member function: a bare + * `handshake()` calls the function (the String? value has no invoke), as + * main resolves it; typing the val must not shadow the member (okhttp + * RealConnection). Other.handshake is the decoy a guess would hit. */ + {"src/main/kotlin/com/acme/h/Conn.kt", + "package com.acme.h\n\ninterface Connection {\n fun handshake(): String?\n}\n\n" + "class RealConnection(private val handshake: String?) : Connection {\n" + " override fun handshake(): String? = handshake\n\n" + " fun isEligible(): Boolean = check(handshake()!!.length)\n\n" + " private fun check(n: Int): Boolean = n > 0\n}\n"}, + {"src/main/kotlin/com/acme/h2/Other.kt", + "package com.acme.h2\n\nclass Other {\n fun handshake(): String? = null\n}\n"}, + /* Inside `repo.let { .. }` / `repo.run { .. }` the now-typed receiver Repo + * becomes the lambda's implicit receiver; the enclosing class's member + * stays callable bare (Kotlin's implicit-receiver chain). */ + {"src/main/kotlin/com/acme/l/Svc.kt", + "package com.acme.l\n\nimport com.acme.a.Repo\n\n" + "class Svc(private val repo: Repo) {\n fun ping(): Int = 2\n\n" + " fun viaLet(): Int = repo.let { ping() }\n\n" + " fun viaRun(): Int = repo.run { ping() }\n}\n"}, + {"src/main/kotlin/com/acme/l2/Pinger.kt", + "package com.acme.l2\n\nclass Pinger {\n fun ping(): Int = 3\n}\n"}, + /* Guard for the receiver chain: inside a companion object, `Ledger(x)` is + * a constructor call (here the secondary constructor), never a member + * method of the enclosing class. */ + {"src/main/kotlin/com/acme/k/Ledger.kt", + "package com.acme.k\n\nclass Ledger private constructor(val a: String, val b: String) {\n" + " constructor(a: String) : this(a, \"gen\")\n\n" + " companion object {\n fun of(x: String) = Ledger(x)\n }\n}\n"}, +}; + +static int ktc_write_fixture(const char *dir) { + for (size_t i = 0; i < sizeof(KTC_FILES) / sizeof(KTC_FILES[0]); i++) { + if (th_write_file(TH_PATH(dir, KTC_FILES[i][0]), KTC_FILES[i][1]) != 0) { + return -1; + } + } + /* Past MIN_FILES_FOR_PARALLEL (50): the parallel venue is selected. */ + for (int i = 0; i < 55; i++) { + char rel[96]; + char body[128]; + snprintf(rel, sizeof(rel), "src/main/kotlin/com/acme/pad/Pad%d.kt", i); + snprintf(body, sizeof(body), "package com.acme.pad\n\nclass Pad%d {\n fun v%d(): Int = %d\n}\n", + i, i, i); + if (th_write_file(TH_PATH(dir, rel), body) != 0) { + return -1; + } + } + return 0; +} + +/* Target QNs of the CALLS edges whose source QN ends in `.` and whose + * callee text ends in `.`, one per line into out; returns the count. */ +static int ktc_targets(sqlite3 *db, const char *src_tail, const char *leaf, char *out, + size_t cap) { + out[0] = '\0'; + static const char sql[] = + "SELECT t.qualified_name FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'CALLS' " + "AND s.qualified_name LIKE '%.' || ?1 " + "AND (json_extract(e.properties, '$.callee') = ?2 " + "OR json_extract(e.properties, '$.callee') LIKE '%.' || ?2) ORDER BY 1"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + return -1; + } + sqlite3_bind_text(st, 1, src_tail, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, leaf, -1, SQLITE_STATIC); + int n = 0; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *qn = (const char *)sqlite3_column_text(st, 0); + size_t used = strlen(out); + snprintf(out + used, cap - used, "%s\n", qn ? qn : ""); + n++; + } + sqlite3_finalize(st); + return n; +} + +/* Exactly one such edge, and its target QN ends in `.`. */ +static int ktc_expect(sqlite3 *db, const char *venue, const char *src_tail, const char *leaf, + const char *want_tail) { + char got[2048]; + int n = ktc_targets(db, src_tail, leaf, got, sizeof(got)); + size_t gl = strlen(got); + size_t wl = strlen(want_tail); + if (n == 1 && gl > wl + 1 && got[gl - wl - 2] == '.' && + strncmp(got + gl - wl - 1, want_tail, wl) == 0) { + return 1; + } + printf("\n [%s] %s -> .%s: want one edge to ...%s, have %d:\n%s", venue, src_tail, leaf, + want_tail, n, got); + return 0; +} + +/* ktc_expect, and the edge comes from the resolver (an lsp_* strategy), not a + * name guess that happens to land on the same target. */ +static int ktc_expect_lsp(sqlite3 *db, const char *venue, const char *src_tail, const char *leaf, + const char *want_tail) { + if (!ktc_expect(db, venue, src_tail, leaf, want_tail)) { + return 0; + } + static const char sql[] = + "SELECT coalesce(json_extract(e.properties, '$.strategy'), '') FROM edges e " + "JOIN nodes s ON s.id = e.source_id WHERE e.type = 'CALLS' " + "AND s.qualified_name LIKE '%.' || ?1 " + "AND (json_extract(e.properties, '$.callee') = ?2 " + "OR json_extract(e.properties, '$.callee') LIKE '%.' || ?2)"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + return 0; + } + sqlite3_bind_text(st, 1, src_tail, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, leaf, -1, SQLITE_STATIC); + char strategy[64] = ""; + if (sqlite3_step(st) == SQLITE_ROW) { + const char *s = (const char *)sqlite3_column_text(st, 0); + snprintf(strategy, sizeof(strategy), "%s", s ? s : ""); + } + sqlite3_finalize(st); + if (strncmp(strategy, "lsp_", 4) == 0) { + return 1; + } + printf("\n [%s] %s -> .%s: right target, but strategy '%s' (want lsp_*)\n", venue, + src_tail, leaf, strategy); + return 0; +} + +/* The reference edges (USAGE / CALL_REFERENCE) from the method whose QN ends in + * `.` to nodes named `name`: exactly one, a USAGE onto a non-callable. */ +static int ktc_expect_property_usage(sqlite3 *db, const char *venue, const char *src_tail, + const char *name) { + static const char sql[] = + "SELECT e.type || ' ' || t.label || ' ' || t.qualified_name FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type IN ('USAGE', 'CALL_REFERENCE', 'CALLS') " + "AND s.qualified_name LIKE '%.' || ?1 AND t.name = ?2 ORDER BY 1"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + return 0; + } + sqlite3_bind_text(st, 1, src_tail, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC); + char got[1024] = ""; + int n = 0; + bool ok = false; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *row = (const char *)sqlite3_column_text(st, 0); + size_t used = strlen(got); + snprintf(got + used, sizeof(got) - used, "%s\n", row ? row : ""); + ok = row && strncmp(row, "USAGE ", 6) == 0 && strstr(row, " Function ") == NULL && + strstr(row, " Method ") == NULL; + n++; + } + sqlite3_finalize(st); + if (n == 1 && ok) { + return 1; + } + printf("\n [%s] %s -> %s: want one USAGE onto the property, have %d:\n%s", venue, src_tail, + name, n, got); + return 0; +} + +static int ktc_index(const char *dir, const char *db_name, bool single_thread, char *db_out, + size_t cap) { + snprintf(db_out, cap, "%s/%s", dir, db_name); + char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + if (single_thread) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } + int rc = -1; + cbm_pipeline_t *p = cbm_pipeline_new(dir, db_out, CBM_MODE_FULL); + if (p) { + rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + return rc; +} + +static int ktc_check_venue(const char *dir, const char *venue, bool single_thread) { + char db_path[512]; + char name[64]; + snprintf(name, sizeof(name), "ktc-%s.db", venue); + if (ktc_index(dir, name, single_thread, db_path, sizeof(db_path)) != 0) { + printf("\n [%s] index failed\n", venue); + return 0; + } + sqlite3 *db = NULL; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + if (db) { + sqlite3_close(db); + } + return 0; + } + int ok = 1; + /* Constructor vals, each next to a same-named decoy on the other type. */ + ok &= ktc_expect(db, venue, "Service.save", "inserir", "Repo.inserir"); + ok &= ktc_expect(db, venue, "Service.log", "inserir", "Audit.inserir"); + /* Body property read through `this`. */ + ok &= ktc_expect(db, venue, "Holder.hold", "inserir", "Audit.inserir"); + /* #1555: two ports sharing `inserir`, both injected as `repository`. */ + ok &= ktc_expect(db, venue, "CriarFeriadoUseCase.executar", "inserir", + "FeriadoRepository.inserir"); + ok &= ktc_expect(db, venue, "CriarCompromissoUseCase.executar", "inserir", + "CompromissoRepository.inserir"); + /* Guard: a member wins over a same-named extension of the same file. */ + ok &= ktc_expect(db, venue, "Builders.putAll", "put", "Builder.put"); + ok &= ktc_expect(db, venue, "Builders.add", "add", "Builder.add"); + /* kotlin.Any members: the declared override, else kotlin.Any (external). */ + ok &= ktc_expect_lsp(db, venue, "Member.hashCode", "hashCode", "ProtoType.hashCode"); + ok &= ktc_expect_lsp(db, venue, "Member.label", "toString", "ProtoType.toString"); + ok &= ktc_expect_lsp(db, venue, "Member.plainHash", "hashCode", "Any.hashCode"); + ok &= ktc_expect_lsp(db, venue, "Member.countText", "toString", "Any.toString"); + /* A typed property read keeps the property node, never a same-named function. */ + ok &= ktc_expect_property_usage(db, venue, "SocketTest.loop", "webSocket"); + /* A typed constructor val never shadows the same-named member function. */ + ok &= ktc_expect_lsp(db, venue, "RealConnection.isEligible", "handshake", + "RealConnection.handshake"); + /* The enclosing class's member inside a lambda with a typed receiver. */ + ok &= ktc_expect_lsp(db, venue, "Svc.viaLet", "ping", "Svc.ping"); + ok &= ktc_expect_lsp(db, venue, "Svc.viaRun", "ping", "Svc.ping"); + ok &= ktc_expect_lsp(db, venue, "Ledger.kt.__file__", "Ledger", "Ledger.Ledger.Ledger"); + sqlite3_close(db); + return ok; +} + +TEST(kotlin_property_receiver_binds_declared_type_both_venues) { + char *dir = th_mktempdir("cbm_kt_typed_calls"); + ASSERT_NOT_NULL(dir); + if (ktc_write_fixture(dir) != 0) { + th_cleanup(dir); + FAIL("fixture"); + } + int seq = ktc_check_venue(dir, "seq", true); + int par = ktc_check_venue(dir, "par", false); + th_cleanup(dir); + ASSERT_TRUE(seq); + ASSERT_TRUE(par); + PASS(); +} + +SUITE(kotlin_typed_calls) { + RUN_TEST(kotlin_property_receiver_binds_declared_type_both_venues); +} diff --git a/tests/test_main.c b/tests/test_main.c index d6e3c2cb2..e0526cf85 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1812,6 +1812,7 @@ extern void suite_ts_lsp(void); extern void suite_java_lsp(void); extern void suite_java_lsp_coverage(void); extern void suite_kotlin_lsp(void); +extern void suite_kotlin_typed_calls(void); extern void suite_rust_lsp(void); extern void suite_store_arch(void); extern void suite_architecture_projection(void); @@ -2394,6 +2395,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(perl_lsp); RUN_SELECTED_SUITE(py_lsp); RUN_SELECTED_SUITE(kotlin_lsp); + RUN_SELECTED_SUITE(kotlin_typed_calls); RUN_SELECTED_SUITE(rust_lsp); RUN_SELECTED_SUITE_PERF(py_lsp_bench); RUN_SELECTED_SUITE(py_lsp_stress);