From 67ff40d5cbe88d3fe933b846ab228340e8bb2f7d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 16:42:30 +0200 Subject: [PATCH] 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 b4b8c88277..6d19f430ae 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 e860e88574..be86587ca0 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 @@ const CBMType *kotlin_eval_expr_type(KotlinLSPContext *ctx, TSNode node) { 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 98e048e6ff..6359b5aa6b 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 d679187354..647b3f8012 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 cf94926c0d..4f47e1b6b1 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 2c0a9023a5..4e398c9488 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 d47f53de6e..972822522f 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 0000000000..5b0aca5286 --- /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 d6e3c2cb2c..e0526cf858 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);