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/internal/cbm/lsp/rust_lsp.c b/internal/cbm/lsp/rust_lsp.c index 44680cb69..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". */ @@ -6323,7 +6352,10 @@ static void rust_resolve_against_registry(CBMArena *arena, const char *source, i * read-only, and shared across every Rust file's resolve (mirrors C/py/cs/ts). * Converts the pipeline's CBMLSPDef into CBMRustLSPDef inline (same field copy as * pass_lsp_cross.c's pxc_lspdefs_to_rust, including proven trait provenance). - * module_qn=NULL is byte-identical because all_defs always carry def_module_qn. */ + * module_qn=NULL is byte-identical because all_defs always carry def_module_qn. + * all_defs is mixed-language: only Rust defs are registered, exactly like the + * per-file conversion, so a Perl `new` or a C function can never become the + * target of a Rust call (the Go builder filters the same way). */ CBMTypeRegistry *cbm_rust_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, int def_count) { if (!arena) return NULL; @@ -6331,29 +6363,34 @@ CBMTypeRegistry *cbm_rust_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, if (!reg) return NULL; CBMRustLSPDef *rdefs = NULL; + int n = 0; if (def_count > 0) { rdefs = (CBMRustLSPDef *)cbm_arena_alloc(arena, (size_t)def_count * sizeof(CBMRustLSPDef)); if (!rdefs) return NULL; for (int i = 0; i < def_count; i++) { - rdefs[i].qualified_name = defs[i].qualified_name; - rdefs[i].short_name = defs[i].short_name; - rdefs[i].label = defs[i].label; - rdefs[i].receiver_type = defs[i].receiver_type; - rdefs[i].def_module_qn = defs[i].def_module_qn; - rdefs[i].return_types = defs[i].return_types; - rdefs[i].embedded_types = defs[i].embedded_types; - rdefs[i].field_defs = defs[i].field_defs; - rdefs[i].method_names_str = defs[i].method_names_str; - rdefs[i].signature_param_types = defs[i].signature_param_types; - rdefs[i].signature_param_count = defs[i].signature_param_count; - rdefs[i].trait_qn = defs[i].trait_qn; - rdefs[i].is_interface = defs[i].is_interface; - rdefs[i].is_rust_impl_relation = defs[i].is_rust_impl_relation; - rdefs[i].is_abstract = defs[i].is_abstract; + 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/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..6d99ff794 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -238,6 +238,67 @@ 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 +309,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]) { @@ -1285,6 +1350,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 +1439,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]; @@ -1470,31 +1598,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 +1720,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/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index f908de590..346d4f225 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3635,6 +3635,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( @@ -3647,6 +3648,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); } } @@ -3702,6 +3710,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); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 396c5ed3e..44e066d21 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8868,6 +8868,180 @@ TEST(pipeline_go_rw_usage_never_cross_into_c) { PASS(); } +/* The Rust registries were built from the mixed-language def set: a Perl + * `sub new` became a Rust free function, and the project-wide + * lsp_short_name_unique fallback bound `Store::new()` to it at 0.90 (found by + * the generic-engine lane on its 12-language fixture). A Rust call never + * targets another language's definition. pad_files > 0 forces the parallel + * resolver, whose Rust registry is the shared all-defs build. */ +/* Pads a Rust fixture with pad_files filler modules (pad_files > 0 forces the + * parallel resolver: CBM_WORKERS=4 for the run), runs the full pipeline into + * / and opens the store. NULL store = the run failed. */ +static cbm_store_t *rust_fixture_run(const char *tmp, int pad_files, const char *db_name, + cbm_pipeline_t **out_p) { + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.rs", i); + snprintf(body, sizeof(body), "pub fn filler%d() -> u32 {\n %d\n}\n", i, i); + write_temp_file(tmp, name, body); + } + char *saved = NULL; + if (pad_files > 0) { + char *old_workers = getenv("CBM_WORKERS"); + saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s", tmp, db_name); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + if (pad_files > 0) { + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + } + *out_p = p; + return rc == 0 ? cbm_store_open_path(db_path) : NULL; +} + +static int rust_registry_rust_only_case(int pad_files) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rust_only_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"); + write_temp_file(tmp, "src/store.rs", + "pub struct Store {\n" + " n: u32,\n" + "}\n" + "\n" + "impl Store {\n" + " pub fn new() -> Store {\n" + " Store { n: 0 }\n" + " }\n" + "\n" + " pub fn get(&self, k: u32) -> u32 {\n" + " self.n + k\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/main.rs", + "mod store;\n" + "\n" + "use crate::store::Store;\n" + "\n" + "fn run() -> u32 {\n" + " let s = Store::new();\n" + " s.get(1)\n" + "}\n" + "\n" + "fn main() {\n" + " run();\n" + "}\n"); + write_temp_file(tmp, "perlsrc/Lib.pm", + "package Lib;\n" + "\n" + "sub new {\n" + " my ($class) = @_;\n" + " return bless {}, $class;\n" + "}\n" + "\n" + "1;\n"); + cbm_pipeline_t *p = NULL; + cbm_store_t *s = rust_fixture_run(tmp, pad_files, "rust_only.db", &p); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + /* Never onto the Perl constructor ... */ + ASSERT_EQ(named_edge_to_file_count(s, project, "CALLS", "run", "new", "perlsrc/Lib.pm"), 0); + /* ... and the Rust call still binds the Rust associated function. */ + ASSERT_GTE(named_edge_to_file_count(s, project, "CALLS", "run", "new", "src/store.rs"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_rust_call_never_binds_another_language) { + return rust_registry_rust_only_case(0); +} + +TEST(pipeline_rust_call_never_binds_another_language_parallel) { + return rust_registry_rust_only_case(52); +} + +/* `Opts::write(true)` is an associated function. The LSP leaves it to the + * registry (import_map -> fsx.Opts.write), but its free-function short-name + * fallback bound it to the project's only free `write` (cache.write) first, and + * that LSP row pre-empted the right answer. With the cross-language namesakes + * gone (above), this hijack replaced 600+ correct qualified_suffix/import_map + * edges on the Rust corpus. A type-qualified call never takes that fallback. */ +static int rust_assoc_call_free_namesake_case(int pad_files) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rust_assoc_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"); + write_temp_file(tmp, "src/fsx.rs", + "pub struct Opts {\n" + " pub w: bool,\n" + "}\n" + "\n" + "impl Opts {\n" + " pub fn write(w: bool) -> Opts {\n" + " Opts { w }\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/cache.rs", + "pub fn write(p: &str) -> usize {\n" + " p.len()\n" + "}\n"); + write_temp_file(tmp, "src/main.rs", + "mod cache;\n" + "mod fsx;\n" + "\n" + "use crate::fsx::Opts;\n" + "\n" + "fn run() -> bool {\n" + " let o = Opts::write(true);\n" + " o.w\n" + "}\n" + "\n" + "fn main() {\n" + " run();\n" + " cache::write(\"x\");\n" + "}\n"); + cbm_pipeline_t *p = NULL; + cbm_store_t *s = rust_fixture_run(tmp, pad_files, "rust_assoc.db", &p); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + /* The associated function, never the free namesake ... */ + ASSERT_GTE(named_edge_to_file_count(s, project, "CALLS", "run", "write", "src/fsx.rs"), 1); + ASSERT_EQ(named_edge_to_file_count(s, project, "CALLS", "run", "write", "src/cache.rs"), 0); + /* ... while the module-qualified free call keeps its target. */ + ASSERT_GTE(named_edge_to_file_count(s, project, "CALLS", "main", "write", "src/cache.rs"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_rust_assoc_call_never_binds_free_namesake) { + return rust_assoc_call_free_namesake_case(0); +} + +TEST(pipeline_rust_assoc_call_never_binds_free_namesake_parallel) { + return rust_assoc_call_free_namesake_case(52); +} + TEST(pipeline_go_rw_usage_never_cross_into_c_parallel) { /* Parallel twin of the test above: USAGE and WRITES/READS each have a * second, independent resolver in pass_parallel.c (resolve_file_usages / @@ -13628,6 +13802,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 @@ -24541,6 +24832,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); @@ -24591,6 +24886,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); 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"};