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 @@ 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 98e048e6f..6359b5aa6 100644 --- a/internal/cbm/lsp/kotlin_lsp.h +++ b/internal/cbm/lsp/kotlin_lsp.h @@ -147,6 +147,13 @@ typedef struct KotlinLSPContext { * each field's REAL def QN here (kotlin class properties are minted with * module-level QNs, so composing . would miss the node). */ const void *cross_field_map; + /* Cross-file only (NULL per-file): when set, `registry` is a per-file + * overlay chained to the cross registry, and kotlin_lsp_process_file + * indexes the overlay into this scratch arena once the file's own + * declarations are collected. The overlay holds the AST registrations + * (typed primary-constructor and body properties), so they are found + * before the extraction-built entries of the same QN (#1555). */ + CBMArena *overlay_index_arena; } KotlinLSPContext; /* Initialize a KotlinLSPContext for processing one file. */ diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index d67918735..647b3f801 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -223,6 +223,24 @@ static inline bool cbm_pipeline_node_is_callable_target(const cbm_gbuf_node_t *n strcmp(node->label, "Constructor") == 0 || strcmp(node->label, "Class") == 0; } +/* The Kotlin resolver claims a property READ on a typed receiver with an exact + * row (lsp_kt_property_ref) so that the raw-name fallback cannot borrow a + * same-named function. That row names a property, never a function value. Its + * QN is not always materialized under that spelling (a property of a nested + * class); the graph lookup then finds nothing, or its unique-callable step + * picks a same-named function elsewhere (#1555: okhttp `server.webSocket` -> a + * test function `webSocket`, a fabricated CALL_REFERENCE). Such a row is not + * admissible: the caller falls back by name, restricted to non-callable + * targets, which binds the property's own node as before the receiver was + * typed and can never borrow a function. */ +static inline bool cbm_pipeline_reference_target_admissible(const CBMResolvedCall *ref, + const cbm_gbuf_node_t *tgt) { + if (!ref || !ref->strategy || strcmp(ref->strategy, "lsp_kt_property_ref") != 0) { + return true; + } + return tgt && !cbm_pipeline_node_is_callable_target(tgt); +} + static inline int cbm_pipeline_qn_class_method_tail_eq(const char *qn, const char *tail) { const char *qt = cbm_pipeline_qn_class_method_tail(qn); return qt && tail && strcmp(qt, tail) == 0; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index cf94926c0..4f47e1b6b 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -1285,6 +1285,68 @@ static void pxc_supersede_occurrence(CBMArena *dst_arena, CBMResolvedCallArray * dst_calls->count = kept; } +/* The Kotlin universal-member placeholder: `x.hashCode()` / `toString()` / + * `equals()` on a receiver the resolver could not type is emitted on kotlin.Any + * (strategy lsp_kt_any, reduced confidence) -- the "no narrower receiver known" + * answer. */ +static bool pxc_is_kt_any_placeholder(const CBMResolvedCall *rc) { + return rc->strategy && strcmp(rc->strategy, "lsp_kt_any") == 0; +} + +static char *pxc_occurrence_key(CBMArena *keys, const CBMResolvedCall *rc) { + return cbm_arena_sprintf(keys, "%u\x1f%s\x1f%u:%u\x1f%u", (unsigned)rc->kind, rc->caller_qn, + rc->site_start_byte, rc->site_end_byte, (unsigned)rc->source_origin); +} + +/* #1555: the per-file pass sees only the file's own declarations, so a receiver + * typed by another file (`val type: ProtoType`) is unknown there and + * `type.hashCode()` gets the kotlin.Any placeholder. The cross pass types the + * receiver and answers with the declared override (`ProtoType.hashCode`), or + * with kotlin.Any again when no class on the receiver's chain declares one. Two + * distinct targets at one occurrence are a tombstone in the call join + * (pass_parallel.c LSP_IDX_AMBIGUOUS), so the occurrence fell to the name-based + * registry guess (wire: `type.hashCode` -> AllTypes.hashCode). A placeholder is + * therefore dropped when a cross row resolves the same occurrence; the cross row + * is then the only evidence. One hash pass per side keeps this linear. */ +static void pxc_drop_superseded_kt_any(CBMArena *keys, CBMResolvedCallArray *dst_calls, + const CBMResolvedCallArray *src_out) { + bool has_placeholder = false; + for (int i = 0; i < dst_calls->count && !has_placeholder; i++) { + has_placeholder = pxc_is_kt_any_placeholder(&dst_calls->items[i]); + } + if (!has_placeholder) { + return; + } + CBMHashTable *resolved = cbm_ht_create((uint32_t)src_out->count + 1); + for (int j = 0; j < src_out->count; j++) { + const CBMResolvedCall *src = &src_out->items[j]; + if (!src->caller_qn || !src->callee_qn || pxc_is_kt_any_placeholder(src) || + src->site_end_byte <= src->site_start_byte) { + continue; + } + char *k = pxc_occurrence_key(keys, src); + if (k) { + cbm_ht_set(resolved, k, (void *)(uintptr_t)1); + } + } + int kept = 0; + for (int i = 0; i < dst_calls->count; i++) { + const CBMResolvedCall *cur = &dst_calls->items[i]; + if (pxc_is_kt_any_placeholder(cur) && cur->caller_qn) { + char *k = pxc_occurrence_key(keys, cur); + if (k && cbm_ht_get(resolved, k)) { + continue; + } + } + if (kept != i) { + dst_calls->items[kept] = *cur; + } + kept++; + } + dst_calls->count = kept; + cbm_ht_free(resolved); +} + static void pxc_append_results(CBMArena *dst_arena, CBMResolvedCallArray *dst_calls, const CBMResolvedCallArray *src_out) { if (!dst_calls || !src_out) @@ -1312,6 +1374,7 @@ static void pxc_append_results(CBMArena *dst_arena, CBMResolvedCallArray *dst_ca } pxc_supersede_occurrence(dst_arena, dst_calls, src); } + pxc_drop_superseded_kt_any(&keys, dst_calls, src_out); for (int i = 0; i < dst_calls->count; i++) { const CBMResolvedCall *rc = &dst_calls->items[i]; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 2c0a9023a..4e398c948 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3616,6 +3616,7 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, const cbm_gbuf_node_t *tgt = NULL; bool precise_call_reference = false; const CBMResolvedCall *semantic_reference = NULL; + bool property_read = false; if (cbm_pipeline_usage_semantic_reference_candidate(usage)) { bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); semantic_reference = cbm_pipeline_find_lsp_reference_indexed_in_graph( @@ -3628,6 +3629,13 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (semantic_reference) { tgt = cbm_pipeline_lsp_target_node(rc->main_gbuf, rc->project_name, semantic_reference->callee_qn, allow_tail); + if (!cbm_pipeline_reference_target_admissible(semantic_reference, tgt)) { + /* a property read: the name fallback below may still bind the + * property's own node, never a callable */ + tgt = NULL; + semantic_reference = NULL; + property_read = true; + } precise_call_reference = cbm_pipeline_node_is_callable_target(tgt); } } @@ -3683,6 +3691,9 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, cbm_pipeline_node_is_callable_target(tgt))) { continue; } + if (property_read && cbm_pipeline_node_is_callable_target(tgt)) { + continue; + } } if (!tgt || src->id == tgt->id) { continue; diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index d47f53de6..972822522 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -213,6 +213,7 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res const cbm_gbuf_node_t *tgt = NULL; bool precise_call_reference = false; const CBMResolvedCall *semantic_reference = NULL; + bool property_read = false; if (cbm_pipeline_usage_semantic_reference_candidate(usage)) { bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); semantic_reference = cbm_pipeline_find_lsp_reference_indexed_in_graph( @@ -225,6 +226,13 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (semantic_reference) { tgt = cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, semantic_reference->callee_qn, allow_tail); + if (!cbm_pipeline_reference_target_admissible(semantic_reference, tgt)) { + /* a property read: the name fallback below may still bind the + * property's own node, never a callable */ + tgt = NULL; + semantic_reference = NULL; + property_read = true; + } precise_call_reference = cbm_pipeline_node_is_callable_target(tgt); } } @@ -285,6 +293,9 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res cbm_pipeline_node_is_callable_target(tgt))) { continue; } + if (property_read && cbm_pipeline_node_is_callable_target(tgt)) { + continue; + } } if (!tgt || src->id == tgt->id) { continue; diff --git a/tests/test_kotlin_typed_calls.c b/tests/test_kotlin_typed_calls.c new file mode 100644 index 000000000..5b0aca528 --- /dev/null +++ b/tests/test_kotlin_typed_calls.c @@ -0,0 +1,359 @@ +/* + * test_kotlin_typed_calls.c — Kotlin member calls through a class PROPERTY + * (primary-constructor `val`, body property, `this.prop`) bind to the + * property's declared type, on both resolver venues (#1555). + * + * The cross-file Kotlin walk resolved a typed method PARAMETER by type, but + * a property-typed receiver fell to the name guess: the cross registry held + * the class from extraction defs (properties untyped, constructor vals + * absent), and the walk's typed AST registration of the same class was + * shadowed by that earlier entry. Each fixture call has a same-named decoy + * method on another type, so a name guess is visible as a wrong target: + * - Service.log: `audit.inserir()` on `audit: Audit`, next to + * `repository: Repo` whose interface also declares inserir (the + * sequential venue's guess picks Repo.inserir); + * - two bounded contexts whose use cases both inject `repository` + * (the parallel venue's receiver-name guess bound the feriado use case + * to CompromissoRepository.inserir, the #1555 report). + * One fixture, padded past MIN_FILES_FOR_PARALLEL, is indexed through the + * sequential resolver (CBM_INDEX_SINGLE_THREAD=1) and the parallel one. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "pipeline/pipeline.h" +#include "sqlite3.h" + +#include +#include +#include + +static const char *const KTC_FILES[][2] = { + {"src/main/kotlin/com/acme/a/Repo.kt", + "package com.acme.a\n\ninterface Repo {\n fun inserir(x: String): Long\n}\n"}, + {"src/main/kotlin/com/acme/b/Audit.kt", + "package com.acme.b\n\nclass Audit {\n fun inserir(x: String): Long = 0L\n}\n"}, + {"src/main/kotlin/com/acme/app/Service.kt", + "package com.acme.app\n\nimport com.acme.a.Repo\nimport com.acme.b.Audit\n\n" + "class Service(private val repository: Repo, private val audit: Audit) {\n" + " fun save(x: String): Long = repository.inserir(x)\n" + " fun log(x: String): Long = audit.inserir(x)\n}\n\n" + "class Holder {\n private val audit: Audit = Audit()\n" + " fun hold(x: String): Long = this.audit.inserir(x)\n}\n"}, + {"src/main/kotlin/com/acme/agenda/domain/CompromissoRepository.kt", + "package com.acme.agenda.domain\n\ninterface CompromissoRepository {\n" + " fun inserir(titulo: String): Long\n}\n"}, + {"src/main/kotlin/com/acme/feriado/domain/FeriadoRepository.kt", + "package com.acme.feriado.domain\n\ninterface FeriadoRepository {\n" + " fun inserir(nome: String): Long\n}\n"}, + {"src/main/kotlin/com/acme/agenda/app/CriarCompromissoUseCase.kt", + "package com.acme.agenda.app\n\nimport com.acme.agenda.domain.CompromissoRepository\n\n" + "class CriarCompromissoUseCase(private val repository: CompromissoRepository) {\n" + " fun executar(titulo: String): Long = repository.inserir(titulo)\n}\n"}, + {"src/main/kotlin/com/acme/feriado/app/CriarFeriadoUseCase.kt", + "package com.acme.feriado.app\n\nimport com.acme.feriado.domain.FeriadoRepository\n\n" + "class CriarFeriadoUseCase(private val repository: FeriadoRepository) {\n" + " fun executar(nome: String): Long = repository.inserir(nome)\n}\n"}, + /* Guard: inside an extension function, a bare call names the receiver's + * MEMBER before a same-named extension of the same file (Kotlin + * resolution); main gets this right and the own-file overlay must too. */ + {"src/main/kotlin/com/acme/b/Builders.kt", + "package com.acme.b\n\nclass Builder {\n fun add(x: Int): Boolean = true\n" + " fun put(k: String, v: Int): Int = v\n}\n\n" + "fun Builder.add(s: String): Boolean = add(s.length)\n\n" + "fun Builder.putAll(k: String) { put(k, 1) }\n\n" + "fun Builder.put(k: String): Int = put(k, 0)\n"}, + /* kotlin.Any members on a receiver typed by ANOTHER file. The per-file + * pass cannot type `type`, so it emits the kotlin.Any placeholder; the + * cross pass names the declared override. Both rows at one occurrence + * were a tombstone in the call join and the call fell to a name guess + * (AllTypes is the decoy). A class with no override keeps kotlin.Any, and + * so does a stdlib receiver type (`count: Int`), whose seeded + * kotlin.Int.toString names no node and left the call to the guess. */ + {"src/main/kotlin/com/acme/c/Proto.kt", + "package com.acme.c\n\nclass ProtoType {\n private val s: String = \"\"\n" + " override fun hashCode() = s.length\n\n override fun toString() = s\n}\n\n" + "class Plain(val n: Int)\n"}, + {"src/main/kotlin/com/acme/d/Member.kt", + "package com.acme.d\n\nimport com.acme.c.Plain\nimport com.acme.c.ProtoType\n\n" + "class Member(val type: ProtoType, val plain: Plain, val count: Int) {\n" + " override fun hashCode() = type.hashCode() * 37\n\n" + " fun label(): String = type.toString()\n\n" + " fun plainHash(): Int = plain.hashCode()\n\n" + " fun countText(): String = count.toString()\n}\n"}, + {"src/main/kotlin/com/acme/e/AllTypes.kt", + "package com.acme.e\n\nclass AllTypes {\n override fun hashCode(): Int = 1\n\n" + " override fun toString(): String = \"all\"\n}\n"}, + /* A property READ on a now-typed receiver (a nested class's property, whose + * QN the graph spells differently): it must keep the property's own node + * (USAGE) and never become a CALL_REFERENCE to a same-named function in + * another file (okhttp `server.webSocket` -> a test fun `webSocket`). */ + {"src/main/kotlin/com/acme/w/SocketTest.kt", + "package com.acme.w\n\nclass SocketTest {\n" + " private val server: Streams = Streams()\n\n" + " fun loop(): Boolean {\n return server.webSocket != null\n }\n\n" + " class Streams {\n var webSocket: String? = null\n }\n}\n"}, + {"src/main/kotlin/com/acme/w2/ModernTest.kt", + "package com.acme.w2\n\nclass ModernTest {\n fun webSocket(): Int = 1\n}\n"}, + /* A constructor val next to a same-named member function: a bare + * `handshake()` calls the function (the String? value has no invoke), as + * main resolves it; typing the val must not shadow the member (okhttp + * RealConnection). Other.handshake is the decoy a guess would hit. */ + {"src/main/kotlin/com/acme/h/Conn.kt", + "package com.acme.h\n\ninterface Connection {\n fun handshake(): String?\n}\n\n" + "class RealConnection(private val handshake: String?) : Connection {\n" + " override fun handshake(): String? = handshake\n\n" + " fun isEligible(): Boolean = check(handshake()!!.length)\n\n" + " private fun check(n: Int): Boolean = n > 0\n}\n"}, + {"src/main/kotlin/com/acme/h2/Other.kt", + "package com.acme.h2\n\nclass Other {\n fun handshake(): String? = null\n}\n"}, + /* Inside `repo.let { .. }` / `repo.run { .. }` the now-typed receiver Repo + * becomes the lambda's implicit receiver; the enclosing class's member + * stays callable bare (Kotlin's implicit-receiver chain). */ + {"src/main/kotlin/com/acme/l/Svc.kt", + "package com.acme.l\n\nimport com.acme.a.Repo\n\n" + "class Svc(private val repo: Repo) {\n fun ping(): Int = 2\n\n" + " fun viaLet(): Int = repo.let { ping() }\n\n" + " fun viaRun(): Int = repo.run { ping() }\n}\n"}, + {"src/main/kotlin/com/acme/l2/Pinger.kt", + "package com.acme.l2\n\nclass Pinger {\n fun ping(): Int = 3\n}\n"}, + /* Guard for the receiver chain: inside a companion object, `Ledger(x)` is + * a constructor call (here the secondary constructor), never a member + * method of the enclosing class. */ + {"src/main/kotlin/com/acme/k/Ledger.kt", + "package com.acme.k\n\nclass Ledger private constructor(val a: String, val b: String) {\n" + " constructor(a: String) : this(a, \"gen\")\n\n" + " companion object {\n fun of(x: String) = Ledger(x)\n }\n}\n"}, +}; + +static int ktc_write_fixture(const char *dir) { + for (size_t i = 0; i < sizeof(KTC_FILES) / sizeof(KTC_FILES[0]); i++) { + if (th_write_file(TH_PATH(dir, KTC_FILES[i][0]), KTC_FILES[i][1]) != 0) { + return -1; + } + } + /* Past MIN_FILES_FOR_PARALLEL (50): the parallel venue is selected. */ + for (int i = 0; i < 55; i++) { + char rel[96]; + char body[128]; + snprintf(rel, sizeof(rel), "src/main/kotlin/com/acme/pad/Pad%d.kt", i); + snprintf(body, sizeof(body), "package com.acme.pad\n\nclass Pad%d {\n fun v%d(): Int = %d\n}\n", + i, i, i); + if (th_write_file(TH_PATH(dir, rel), body) != 0) { + return -1; + } + } + return 0; +} + +/* Target QNs of the CALLS edges whose source QN ends in `.` and whose + * callee text ends in `.`, one per line into out; returns the count. */ +static int ktc_targets(sqlite3 *db, const char *src_tail, const char *leaf, char *out, + size_t cap) { + out[0] = '\0'; + static const char sql[] = + "SELECT t.qualified_name FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'CALLS' " + "AND s.qualified_name LIKE '%.' || ?1 " + "AND (json_extract(e.properties, '$.callee') = ?2 " + "OR json_extract(e.properties, '$.callee') LIKE '%.' || ?2) ORDER BY 1"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + return -1; + } + sqlite3_bind_text(st, 1, src_tail, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, leaf, -1, SQLITE_STATIC); + int n = 0; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *qn = (const char *)sqlite3_column_text(st, 0); + size_t used = strlen(out); + snprintf(out + used, cap - used, "%s\n", qn ? qn : ""); + n++; + } + sqlite3_finalize(st); + return n; +} + +/* Exactly one such edge, and its target QN ends in `.`. */ +static int ktc_expect(sqlite3 *db, const char *venue, const char *src_tail, const char *leaf, + const char *want_tail) { + char got[2048]; + int n = ktc_targets(db, src_tail, leaf, got, sizeof(got)); + size_t gl = strlen(got); + size_t wl = strlen(want_tail); + if (n == 1 && gl > wl + 1 && got[gl - wl - 2] == '.' && + strncmp(got + gl - wl - 1, want_tail, wl) == 0) { + return 1; + } + printf("\n [%s] %s -> .%s: want one edge to ...%s, have %d:\n%s", venue, src_tail, leaf, + want_tail, n, got); + return 0; +} + +/* ktc_expect, and the edge comes from the resolver (an lsp_* strategy), not a + * name guess that happens to land on the same target. */ +static int ktc_expect_lsp(sqlite3 *db, const char *venue, const char *src_tail, const char *leaf, + const char *want_tail) { + if (!ktc_expect(db, venue, src_tail, leaf, want_tail)) { + return 0; + } + static const char sql[] = + "SELECT coalesce(json_extract(e.properties, '$.strategy'), '') FROM edges e " + "JOIN nodes s ON s.id = e.source_id WHERE e.type = 'CALLS' " + "AND s.qualified_name LIKE '%.' || ?1 " + "AND (json_extract(e.properties, '$.callee') = ?2 " + "OR json_extract(e.properties, '$.callee') LIKE '%.' || ?2)"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + return 0; + } + sqlite3_bind_text(st, 1, src_tail, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, leaf, -1, SQLITE_STATIC); + char strategy[64] = ""; + if (sqlite3_step(st) == SQLITE_ROW) { + const char *s = (const char *)sqlite3_column_text(st, 0); + snprintf(strategy, sizeof(strategy), "%s", s ? s : ""); + } + sqlite3_finalize(st); + if (strncmp(strategy, "lsp_", 4) == 0) { + return 1; + } + printf("\n [%s] %s -> .%s: right target, but strategy '%s' (want lsp_*)\n", venue, + src_tail, leaf, strategy); + return 0; +} + +/* The reference edges (USAGE / CALL_REFERENCE) from the method whose QN ends in + * `.` to nodes named `name`: exactly one, a USAGE onto a non-callable. */ +static int ktc_expect_property_usage(sqlite3 *db, const char *venue, const char *src_tail, + const char *name) { + static const char sql[] = + "SELECT e.type || ' ' || t.label || ' ' || t.qualified_name FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type IN ('USAGE', 'CALL_REFERENCE', 'CALLS') " + "AND s.qualified_name LIKE '%.' || ?1 AND t.name = ?2 ORDER BY 1"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + return 0; + } + sqlite3_bind_text(st, 1, src_tail, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC); + char got[1024] = ""; + int n = 0; + bool ok = false; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *row = (const char *)sqlite3_column_text(st, 0); + size_t used = strlen(got); + snprintf(got + used, sizeof(got) - used, "%s\n", row ? row : ""); + ok = row && strncmp(row, "USAGE ", 6) == 0 && strstr(row, " Function ") == NULL && + strstr(row, " Method ") == NULL; + n++; + } + sqlite3_finalize(st); + if (n == 1 && ok) { + return 1; + } + printf("\n [%s] %s -> %s: want one USAGE onto the property, have %d:\n%s", venue, src_tail, + name, n, got); + return 0; +} + +static int ktc_index(const char *dir, const char *db_name, bool single_thread, char *db_out, + size_t cap) { + snprintf(db_out, cap, "%s/%s", dir, db_name); + char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + if (single_thread) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } + int rc = -1; + cbm_pipeline_t *p = cbm_pipeline_new(dir, db_out, CBM_MODE_FULL); + if (p) { + rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + return rc; +} + +static int ktc_check_venue(const char *dir, const char *venue, bool single_thread) { + char db_path[512]; + char name[64]; + snprintf(name, sizeof(name), "ktc-%s.db", venue); + if (ktc_index(dir, name, single_thread, db_path, sizeof(db_path)) != 0) { + printf("\n [%s] index failed\n", venue); + return 0; + } + sqlite3 *db = NULL; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + if (db) { + sqlite3_close(db); + } + return 0; + } + int ok = 1; + /* Constructor vals, each next to a same-named decoy on the other type. */ + ok &= ktc_expect(db, venue, "Service.save", "inserir", "Repo.inserir"); + ok &= ktc_expect(db, venue, "Service.log", "inserir", "Audit.inserir"); + /* Body property read through `this`. */ + ok &= ktc_expect(db, venue, "Holder.hold", "inserir", "Audit.inserir"); + /* #1555: two ports sharing `inserir`, both injected as `repository`. */ + ok &= ktc_expect(db, venue, "CriarFeriadoUseCase.executar", "inserir", + "FeriadoRepository.inserir"); + ok &= ktc_expect(db, venue, "CriarCompromissoUseCase.executar", "inserir", + "CompromissoRepository.inserir"); + /* Guard: a member wins over a same-named extension of the same file. */ + ok &= ktc_expect(db, venue, "Builders.putAll", "put", "Builder.put"); + ok &= ktc_expect(db, venue, "Builders.add", "add", "Builder.add"); + /* kotlin.Any members: the declared override, else kotlin.Any (external). */ + ok &= ktc_expect_lsp(db, venue, "Member.hashCode", "hashCode", "ProtoType.hashCode"); + ok &= ktc_expect_lsp(db, venue, "Member.label", "toString", "ProtoType.toString"); + ok &= ktc_expect_lsp(db, venue, "Member.plainHash", "hashCode", "Any.hashCode"); + ok &= ktc_expect_lsp(db, venue, "Member.countText", "toString", "Any.toString"); + /* A typed property read keeps the property node, never a same-named function. */ + ok &= ktc_expect_property_usage(db, venue, "SocketTest.loop", "webSocket"); + /* A typed constructor val never shadows the same-named member function. */ + ok &= ktc_expect_lsp(db, venue, "RealConnection.isEligible", "handshake", + "RealConnection.handshake"); + /* The enclosing class's member inside a lambda with a typed receiver. */ + ok &= ktc_expect_lsp(db, venue, "Svc.viaLet", "ping", "Svc.ping"); + ok &= ktc_expect_lsp(db, venue, "Svc.viaRun", "ping", "Svc.ping"); + ok &= ktc_expect_lsp(db, venue, "Ledger.kt.__file__", "Ledger", "Ledger.Ledger.Ledger"); + sqlite3_close(db); + return ok; +} + +TEST(kotlin_property_receiver_binds_declared_type_both_venues) { + char *dir = th_mktempdir("cbm_kt_typed_calls"); + ASSERT_NOT_NULL(dir); + if (ktc_write_fixture(dir) != 0) { + th_cleanup(dir); + FAIL("fixture"); + } + int seq = ktc_check_venue(dir, "seq", true); + int par = ktc_check_venue(dir, "par", false); + th_cleanup(dir); + ASSERT_TRUE(seq); + ASSERT_TRUE(par); + PASS(); +} + +SUITE(kotlin_typed_calls) { + RUN_TEST(kotlin_property_receiver_binds_declared_type_both_venues); +} diff --git a/tests/test_main.c b/tests/test_main.c index d6e3c2cb2..e0526cf85 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1812,6 +1812,7 @@ extern void suite_ts_lsp(void); extern void suite_java_lsp(void); extern void suite_java_lsp_coverage(void); extern void suite_kotlin_lsp(void); +extern void suite_kotlin_typed_calls(void); extern void suite_rust_lsp(void); extern void suite_store_arch(void); extern void suite_architecture_projection(void); @@ -2394,6 +2395,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(perl_lsp); RUN_SELECTED_SUITE(py_lsp); RUN_SELECTED_SUITE(kotlin_lsp); + RUN_SELECTED_SUITE(kotlin_typed_calls); RUN_SELECTED_SUITE(rust_lsp); RUN_SELECTED_SUITE_PERF(py_lsp_bench); RUN_SELECTED_SUITE(py_lsp_stress);