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/extract_defs.c b/internal/cbm/extract_defs.c index 77e0bbca0..6491a677f 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -6624,11 +6624,16 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * scope. Note the call-scope side (compute_func_qn in extract_unified.c) is * NOT language-gated — it qualifies whenever a scope is pushed — so this gate * and the scope-push rule must move together, or a call QN names a def QN that - * was never minted and the edge is dropped at write. */ + * was never minted and the edge is dropped at write. + * + * Python: the only enclosing scope that reaches here is the def a nested + * def sits in (py_push_def_body), so `def index()` inside create_app is + * proj.mod.create_app.index. */ if (ctx->enclosing_class_qn && (ctx->language == CBM_LANG_CPP || ctx->language == CBM_LANG_CUDA || ctx->language == CBM_LANG_TYPESCRIPT || ctx->language == CBM_LANG_TSX || - ctx->language == CBM_LANG_ARKTS || ctx->language == CBM_LANG_NIX)) { + ctx->language == CBM_LANG_ARKTS || ctx->language == CBM_LANG_NIX || + ctx->language == CBM_LANG_PYTHON)) { def.qualified_name = cbm_arena_sprintf(a, "%s.%s", ctx->enclosing_class_qn, qn_name); } def.label = "Function"; @@ -11667,6 +11672,51 @@ static void push_class_body_children(TSNode node, const CBMLangSpec *spec, wd_st } } +/* Python: a def's body is a scope of its own, and a `def` or `class` statement + * in it is a definition like any other -- closures, decorator wrappers, the + * route handlers of an app factory (`@app.route` inside create_app), local + * classes. Walk the body with the def's QN as the enclosing scope, so a nested + * def is qualified by the def it sits in: proj.mod.create_app.index. That is + * the QN the Python LSP already gives the calls inside it (py_process_function) + * and the one the call-scope walk computes (extract_unified.c + * py_nearest_scope_qn). Only the body is walked: parameters, + * annotations and decorators hold expressions, never statements. A lambda or + * a comprehension holds no statements either, so neither becomes a def. */ +static void py_push_def_body(wd_stack_t *s, TSNode def_node, const char *def_qn) { + TSNode body = ts_node_child_by_field_name(def_node, TS_FIELD("body")); + if (def_qn && !ts_node_is_null(body)) { + wd_push(s, body, def_qn); + } +} + +/* Python methods are extracted by extract_class_methods, not by the walk, so + * their bodies are pushed here, under the QN push_method_def gave the method + * (class_qn.name), in source order. */ +static void py_push_method_bodies(CBMExtractCtx *ctx, TSNode class_node, const char *class_qn, + wd_stack_t *s) { + TSNode body = find_class_member_body(class_node, ctx->language); + if (ts_node_is_null(body) || !class_qn) { + return; + } + for (int i = (int)ts_node_named_child_count(body) - SKIP_CHAR; i >= 0; i--) { + TSNode method = ts_node_named_child(body, (uint32_t)i); + if (strcmp(ts_node_type(method), "decorated_definition") == 0) { + method = ts_node_child_by_field_name(method, TS_FIELD("definition")); + } + if (ts_node_is_null(method) || strcmp(ts_node_type(method), "function_definition") != 0) { + continue; + } + TSNode name_node = resolve_method_name(method, ctx->language); + char *name = + ts_node_is_null(name_node) + ? NULL + : cbm_func_name_node_text(ctx->arena, name_node, ctx->source, ctx->language); + if (name && name[0]) { + py_push_def_body(s, method, cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, name)); + } + } +} + // ASCII case-insensitive equality vs a lowercase literal (CFML attribute names // are case-insensitive, e.g. NAME / Name / name). static bool ascii_ci_equals(const char *s, const char *lower_lit) { @@ -12211,6 +12261,16 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, } if (cbm_kind_in_set(node, spec->function_node_types)) { if (!is_template_class_node(node, ctx->language)) { + if (ctx->language == CBM_LANG_PYTHON) { + int defs_before = ctx->result->defs.count; + extract_func_def(ctx, node, spec); + if (ctx->result->defs.count > defs_before) { + py_push_def_body(&s, node, + ctx->result->defs.items[ctx->result->defs.count - SKIP_ONE] + .qualified_name); + } + continue; + } extract_func_def(ctx, node, spec); // Most languages stop here. JS/TS (and Wolfram) descend into the // function body so NESTED named definitions are also captured — @@ -12268,6 +12328,9 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, extract_class_def(ctx, node, spec); const char *new_enclosing = compute_class_qn(ctx, node, frame.enclosing_class_qn); push_class_body_children(node, spec, &s, new_enclosing, ctx); + if (ctx->language == CBM_LANG_PYTHON && new_enclosing != frame.enclosing_class_qn) { + py_push_method_bodies(ctx, node, new_enclosing, &s); + } continue; } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index b96e60a91..aa3db1e0d 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -916,6 +916,21 @@ static const char *objectscript_get_method_qn(CBMExtractCtx *ctx, TSNode node, return NULL; } +/* Python: the nearest named scope around the node -- a def or a class. A def + * or class nested in a def is qualified by that def (proj.mod.create_app.index), + * exactly as extract_defs.c mints it; enclosing_class_qn alone would skip the + * def and hand a def nested in a method its method's QN. Lambdas push no named + * frame, so they stay transparent. */ +static const char *py_nearest_scope_qn(const WalkState *state) { + for (int i = state->scope_top - SKIP_ONE; i >= 0; i--) { + const CBMWalkScope *f = &state->scopes[i]; + if ((f->kind == SCOPE_FUNC || f->kind == SCOPE_CLASS) && f->qn) { + return f->qn; + } + } + return NULL; +} + // Compute function QN for scope tracking (mirrors cbm_enclosing_func_qn logic). static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, WalkState *state) { @@ -1107,8 +1122,10 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } - if (state->enclosing_class_qn) { - return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, qn_name); + const char *outer_qn = + ctx->language == CBM_LANG_PYTHON ? py_nearest_scope_qn(state) : state->enclosing_class_qn; + if (outer_qn) { + return cbm_arena_sprintf(ctx->arena, "%s.%s", outer_qn, qn_name); } /* Java/Go: directory-based module so this enclosing-func QN matches the def * QN and the LSP caller_qn (the lsp_resolve join keys on exact equality). */ @@ -1176,9 +1193,15 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkS /* Nested class: prefix with the enclosing class QN (Outer.Inner) so this * scope QN matches the def-side class QN (extract_defs.c compute_class_qn / - * extract_class_def), which the lsp_resolve join requires for nested types. */ - if (state && state->enclosing_class_qn) { - return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, name); + * extract_class_def), which the lsp_resolve join requires for nested types. + * Python: a class inside a def is qualified by the def (py_nearest_scope_qn). */ + const char *outer_qn = NULL; + if (state) { + outer_qn = ctx->language == CBM_LANG_PYTHON ? py_nearest_scope_qn(state) + : state->enclosing_class_qn; + } + if (outer_qn) { + return cbm_arena_sprintf(ctx->arena, "%s.%s", outer_qn, name); } /* Java/Go: directory-based module (see compute_func_qn). */ 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/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 6af7dae40..18384d82e 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -246,6 +246,11 @@ static void py_register_lambda(PyLSPContext *ctx, const char *name, TSNode lambd static void py_register_dict_literal(PyLSPContext *ctx, const char *name, TSNode dict_node); static TSNode py_lookup_lambda(PyLSPContext *ctx, const char *name); static const char *py_lookup_dict_dispatch(PyLSPContext *ctx, const char *var, const char *key); +static void py_process_function(PyLSPContext *ctx, TSNode func_node, const char *container_qn); +static void py_process_class(PyLSPContext *ctx, TSNode class_node); +static void py_process_class_in(PyLSPContext *ctx, TSNode class_node, const char *container_qn); +typedef enum { PY_LOCAL_DEFS_BIND, PY_LOCAL_DEFS_PROCESS } PyLocalDefsMode; +static void py_visit_local_defs(PyLSPContext *ctx, TSNode block, PyLocalDefsMode mode, int depth); /* ── Scope mutation wrappers ───────────────────────────────────────────── * @@ -4362,6 +4367,101 @@ static void py_bind_parameters(PyLSPContext *ctx, TSNode params) { } } +/* Bind a def or class statement of the current function body as a local of + * that function (#1548): its graph QN extends the function's + * (enclosing_func_qn.name), the QN extraction gives it. An undecorated def + * registered with that QN becomes an exact callable alias; anything else + * (decorated, overloaded, not registered) is a hard local shadow, so no call + * falls through to a same-named module symbol. A class is bound by its type + * when registered, else shadowed. A parameter of the same name is already a + * local and keeps its own binding. */ +static void py_bind_local_def(PyLSPContext *ctx, TSNode def, bool is_class) { + TSNode name_node = ts_node_child_by_field_name(def, "name", 4); + char *name = ts_node_is_null(name_node) ? NULL : py_node_text(ctx, name_node); + if (!name || !name[0] || !ctx->enclosing_func_qn) { + py_disable_callable_value_proof(ctx); + return; + } + if (cbm_scope_lookup_local(ctx->current_scope, name)) + return; + const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->enclosing_func_qn, name); + if (!qn) { + py_disable_callable_value_proof(ctx); + py_scope_bind(ctx, name, cbm_type_unknown()); + return; + } + if (is_class) { + py_scope_bind(ctx, name, + cbm_registry_lookup_type(ctx->registry, qn) ? cbm_type_named(ctx->arena, qn) + : cbm_type_unknown()); + return; + } + const CBMRegisteredFunc *func = cbm_registry_lookup_func(ctx->registry, qn); + if (py_func_is_exact_callable_value(func)) { + py_scope_bind_callable(ctx, name, cbm_type_unknown(), func->qualified_name); + } else { + py_scope_bind(ctx, name, cbm_type_unknown()); + } +} + +/* Statements a function body nests further statements in. A def inside any of + * them is still a local of the function; a nested def, class or lambda body + * is a scope of its own and is never entered. */ +static bool py_is_statement_block(const char *kind) { + static const char *const kinds[] = {"block", + "if_statement", + "elif_clause", + "else_clause", + "for_statement", + "while_statement", + "try_statement", + "except_clause", + "except_group_clause", + "finally_clause", + "with_statement", + "match_statement", + "case_clause", + NULL}; + for (int i = 0; kinds[i]; i++) { + if (strcmp(kind, kinds[i]) == 0) + return true; + } + return false; +} + +/* Visit the def/class statements of a function body, including those inside + * its compound statements: BIND binds each as a local (py_bind_local_def), + * PROCESS walks each nested def's own body (py_process_function). */ +static void py_visit_local_defs(PyLSPContext *ctx, TSNode block, PyLocalDefsMode mode, int depth) { + if (!ctx || ctx->eval_failure || ts_node_is_null(block) || depth >= cbm_lsp_max_walk_depth()) + return; + PyKids kids = py_kids(ctx->arena, block); + for (uint32_t i = 0; i < kids.n && !ctx->eval_failure; i++) { + TSNode c = py_kid(&kids, i); + const char *ck = ts_node_type(c); + TSNode def = c; + if (strcmp(ck, "decorated_definition") == 0) { + def = ts_node_child_by_field_name(c, "definition", 10); + if (ts_node_is_null(def)) + continue; + ck = ts_node_type(def); + } + if (strcmp(ck, "function_definition") == 0) { + if (mode == PY_LOCAL_DEFS_BIND) + py_bind_local_def(ctx, def, false); + else + py_process_function(ctx, def, ctx->enclosing_func_qn); + } else if (strcmp(ck, "class_definition") == 0) { + if (mode == PY_LOCAL_DEFS_BIND) + py_bind_local_def(ctx, def, true); + else + py_process_class_in(ctx, def, ctx->enclosing_func_qn); + } else if (py_is_statement_block(ck)) { + py_visit_local_defs(ctx, c, mode, depth + 1); + } + } +} + static void py_process_function(PyLSPContext *ctx, TSNode func_node, const char *container_qn) { if (!ctx || ctx->eval_failure) return; @@ -4391,22 +4491,13 @@ static void py_process_function(PyLSPContext *ctx, TSNode func_node, const char TSNode body = ts_node_child_by_field_name(func_node, "body", 4); if (!ts_node_is_null(body)) { + /* Python resolves a bare name in the function's own scope first: every + * def/class statement of the body binds a local for the whole body + * (LEGB), whatever a module-level symbol of the same spelling says. */ + py_visit_local_defs(ctx, body, PY_LOCAL_DEFS_BIND, 0); py_resolve_calls_in(ctx, body); - // Also descend into nested function/class definitions in the body. - PyKids bk = py_kids(ctx->arena, body); - for (uint32_t i = 0; i < bk.n; i++) { - TSNode c = py_kid(&bk, i); - const char *ck = ts_node_type(c); - if (strcmp(ck, "function_definition") == 0) { - py_process_function(ctx, c, ctx->enclosing_func_qn); - } else if (strcmp(ck, "decorated_definition") == 0) { - TSNode def = ts_node_child_by_field_name(c, "definition", 10); - if (!ts_node_is_null(def) && - strcmp(ts_node_type(def), "function_definition") == 0) { - py_process_function(ctx, def, ctx->enclosing_func_qn); - } - } - } + // Also descend into nested function definitions in the body. + py_visit_local_defs(ctx, body, PY_LOCAL_DEFS_PROCESS, 0); } py_scope_restore(ctx, saved); @@ -4536,7 +4627,10 @@ static void py_exec_class_definition(PyLSPContext *ctx, TSNode class_node) { py_scope_restore(ctx, saved_scope); } -static void py_process_class(PyLSPContext *ctx, TSNode class_node) { +/* Walk a class's methods with the class qualified under `container_qn`: the + * module for a module-level class, the enclosing def for a class declared in + * a function body (#1548: extraction gives it the def's QN as prefix). */ +static void py_process_class_in(PyLSPContext *ctx, TSNode class_node, const char *container_qn) { if (!ctx || ctx->eval_failure) return; TSNode name_node = ts_node_child_by_field_name(class_node, "name", 4); @@ -4547,7 +4641,8 @@ static void py_process_class(PyLSPContext *ctx, TSNode class_node) { return; const char *prev_class = ctx->enclosing_class_qn; - ctx->enclosing_class_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, cname); + ctx->enclosing_class_qn = + cbm_arena_sprintf(ctx->arena, "%s.%s", container_qn ? container_qn : ctx->module_qn, cname); TSNode body = ts_node_child_by_field_name(class_node, "body", 4); if (!ts_node_is_null(body)) { @@ -4621,6 +4716,10 @@ static void py_process_class(PyLSPContext *ctx, TSNode class_node) { ctx->enclosing_class_qn = prev_class; } +static void py_process_class(PyLSPContext *ctx, TSNode class_node) { + py_process_class_in(ctx, class_node, NULL); +} + /* Module bindings are replayed in source order before deferred function bodies * are analyzed. Class definitions always replace an earlier callable identity; * the registry decides whether the resulting class type is known precisely. */ @@ -4687,6 +4786,28 @@ static char *py_module_prefix(CBMArena *scratch, const char *module_qn, size_t l return prefix; } +/* A class declared inside a def -- django's per-test `class MyModelAdmin`, an + * app factory's local class, and anything nested in one -- is a local of that + * def, not a global of the module: bound here by its short name, every + * same-named local class of the file collapsed onto whichever registered last. + * Its QN extends a registered function's (proj.mod.Tests.test_x.MyModelAdmin), + * which tells it apart from a class nested in a class (proj.mod.Outer.Inner). + * Only prefixes longer than the module are probed. */ +static bool py_type_is_function_local(const PyLSPContext *ctx, const char *qn, size_t module_len) { + char scope[512]; + size_t len = strlen(qn); + if (len >= sizeof(scope)) + return false; + memcpy(scope, qn, len + 1); + for (char *dot = strrchr(scope, '.'); dot && (size_t)(dot - scope) > module_len; + dot = strrchr(scope, '.')) { + *dot = '\0'; + if (cbm_registry_lookup_func(ctx->registry, scope)) + return true; + } + return false; +} + /* A project registry can contain another file's class in the same logical * Python module. Keep those cross-file globals available, but do not prebind * classes declared by this source: their binding epoch belongs in the ordered @@ -4734,6 +4855,8 @@ static void py_bind_external_module_classes(PyLSPContext *ctx, const PyKids *rk) continue; if (package_scope && strchr(qn + prefix_len + 1, '.')) continue; /* submodule/nested classes are not package globals */ + if (py_type_is_function_local(ctx, qn, prefix_len)) + continue; /* a def-local class is no module global (#1548) */ py_scope_bind(ctx, name, cbm_type_named(ctx->arena, qn)); } if (prefix) diff --git a/internal/cbm/lsp/rust_lsp.c b/internal/cbm/lsp/rust_lsp.c index fda3111de..4a3a5d0d3 100644 --- a/internal/cbm/lsp/rust_lsp.c +++ b/internal/cbm/lsp/rust_lsp.c @@ -4144,6 +4144,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. */ @@ -4530,6 +4556,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. @@ -4540,7 +4569,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)); @@ -4584,7 +4613,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". */ @@ -6341,7 +6370,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; @@ -6349,29 +6381,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/fqn.c b/src/pipeline/fqn.c index f391ce1e9..f4603dc80 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -355,6 +355,30 @@ char *cbm_pipeline_resolve_relative_import(const char *source_rel, const char *m return resolve_js_relative(buf, sizeof(buf), module_path); } +size_t cbm_pipeline_fqn_symbol_prefix_len(const char *project, const char *rel_path) { + if (!project) { + return 0; + } + char path[FQN_PATH_BUF]; + int n = snprintf(path, sizeof(path), "%s", rel_path ? rel_path : ""); + if (n < 0 || (size_t)n >= sizeof(path)) { + return 0; + } + cbm_normalize_path_sep(path); + strip_file_extension(path); + const char *segments[CBM_SZ_256]; + int seg_count = 0; + segments[seg_count++] = project; + seg_count += tokenize_path(path, segments + seg_count, FQN_MAX_PATH_SEGS); + /* Any symbol name: the prefix cbm_pipeline_fqn_compute puts before it. */ + strip_init_or_index(segments, &seg_count, "symbol"); + size_t total = 0; + for (int i = 0; i < seg_count; i++) { + total += strlen(segments[i]) + (i > 0 ? FQN_SEP_LEN : 0); + } + return total; +} + char *cbm_pipeline_fqn_folder(const char *project, const char *rel_dir) { if (!project) { return strdup(""); 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_calls.c b/src/pipeline/pass_calls.c index 1eb3bddff..dfbb8f7fb 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -587,9 +587,18 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca esc_fa); cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props); if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') { - cbm_resolution_t hres = - cbm_registry_resolve_handler(ctx->registry, call->second_arg_name, module_qn, imp_keys, - imp_vals, imp_count, ctx->gbuf, lang); + /* Python: an app factory registers its local defs + * (`app.add_url_rule("/", "index", index)` inside create_app); only the + * lexical lookup sees a function's locals. MUST match pass_parallel.c. */ + cbm_resolution_t hres = {0}; + if (lang == CBM_LANG_PYTHON) { + hres = cbm_registry_resolve_lexical(ctx->registry, call->second_arg_name, + call->enclosing_func_qn, module_qn); + } + if (!hres.qualified_name) { + hres = cbm_registry_resolve_handler(ctx->registry, call->second_arg_name, module_qn, + imp_keys, imp_vals, imp_count, ctx->gbuf, lang); + } if (hres.qualified_name != NULL && hres.qualified_name[0] != '\0') { const cbm_gbuf_node_t *handler = cbm_gbuf_find_by_qn(ctx->gbuf, hres.qualified_name); if (handler != NULL) { @@ -967,8 +976,10 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM lang, lsp->strategy, lsp->callee_qn, ctx->project_name); cbm_resolution_t res = {0}; if (!rust_external && !php_vendor_call) { - res = cbm_registry_resolve_call_lang(ctx->registry, call->callee_name, module_qn, imp_keys, - imp_vals, imp_count, lang); + /* Python LEGB fallback first (MUST match pass_parallel.c). */ + res = + cbm_registry_resolve_call_scoped(ctx->registry, call->callee_name, module_qn, imp_keys, + imp_vals, imp_count, lang, call->enclosing_func_qn); /* Cross-language veto: a name-only guess never binds another * language's symbol (a JS app.get must not become Python's * builtins.dict.get). The vetoed answer becomes EMPTY here, before diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 0f95c7989..7a6b5b271 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -432,7 +432,8 @@ static void link_def_parents(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, * `link_parents` links its DEFINES_METHOD / diagram parent right away; with * the result cache the pass links them once every file is registered. */ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const char *rel, - const char *file_doc, CBMLanguage lang, bool link_parents) { + const char *file_doc, CBMLanguage lang, size_t module_prefix_len, + bool link_parents) { if (!def->qualified_name || !def->name) { return; } @@ -458,7 +459,8 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed * through the same predicate, so the three registries cannot diverge. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang(ctx->registry, def->name, def->qualified_name, def->label, lang); + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, + lang, module_prefix_len); } char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); @@ -1061,10 +1063,15 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t "test_declarations"); } - /* Create nodes for each definition */ + /* Create nodes for each definition. A Python file's module QN bounds + * the registry's function-local detection to this file. */ const char *file_doc = result->module_doc; /* goes with the first def */ + size_t py_prefix_len = lang == CBM_LANG_PYTHON + ? cbm_pipeline_fqn_symbol_prefix_len(ctx->project_name, rel) + : 0; for (int d = 0; d < result->defs.count; d++) { - process_def(ctx, &result->defs.items[d], rel, file_doc, lang, !local_cache); + process_def(ctx, &result->defs.items[d], rel, file_doc, lang, py_prefix_len, + !local_cache); file_doc = NULL; total_defs++; } 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 2c0a9023a..346d4f225 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1773,7 +1773,8 @@ int cbm_parallel_extract(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, * once per file by the caller: computing the file QN and finding its node for * every definition was 700 k allocations and lookups on the Go corpus. */ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, - int64_t file_node_id, CBMLanguage lang, int *reg_entries) { + int64_t file_node_id, CBMLanguage lang, size_t module_prefix_len, + int *reg_entries) { int edges = 0; if (!def->name || !def->qualified_name || !def->label) { return 0; @@ -1781,7 +1782,8 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d /* Registry membership is defined ONCE by cbm_label_is_registry_symbol * (helpers.c) — see pass_definitions.c for the per-label rationale. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang(ctx->registry, def->name, def->qualified_name, def->label, lang); + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, + lang, module_prefix_len); (*reg_entries)++; } char marked[CBM_SZ_4K]; @@ -1988,9 +1990,14 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t int64_t file_node_id = file_node ? file_node->id : 0; free(file_qn); pp_add_file_doc(file_node, result->module_doc); + /* Bounds the function-local detection to this file (pass_definitions.c). */ + size_t py_prefix_len = files[i].language == CBM_LANG_PYTHON + ? cbm_pipeline_fqn_symbol_prefix_len(ctx->project_name, rel) + : 0; for (int d = 0; d < result->defs.count; d++) { - defines_edges += register_and_link_def(ctx, &result->defs.items[d], file_node_id, - files[i].language, ®_entries); + defines_edges += + register_and_link_def(ctx, &result->defs.items[d], file_node_id, + files[i].language, py_prefix_len, ®_entries); } } @@ -2410,8 +2417,18 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou esc_rp); cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props); if (handler_ref && handler_ref[0] != '\0') { - cbm_resolution_t hres = cbm_registry_resolve_handler(registry, handler_ref, module_qn, ik, - iv, ic, main_gbuf, lang); + /* Python: a handler an app factory registers is usually its local def + * (`app.add_url_rule("/", "index", index)` inside create_app); only the + * lexical lookup sees a function's locals. MUST match pass_calls.c. */ + cbm_resolution_t hres = {0}; + if (lang == CBM_LANG_PYTHON) { + hres = cbm_registry_resolve_lexical(registry, handler_ref, call->enclosing_func_qn, + module_qn); + } + if (!hres.qualified_name) { + hres = cbm_registry_resolve_handler(registry, handler_ref, module_qn, ik, iv, ic, + main_gbuf, lang); + } if (hres.qualified_name && hres.qualified_name[0] != '\0') { const cbm_gbuf_node_t *h = cbm_gbuf_find_by_qn(main_gbuf, hres.qualified_name); if (h) { @@ -3267,8 +3284,10 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB lang, lsp->strategy, lsp->callee_qn, rc->project_name); if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution && !rust_external && !php_vendor_call) { - res = cbm_registry_resolve_call_lang(rc->registry, call->callee_name, module_qn, - imp_keys, imp_vals, imp_count, lang); + /* Python LEGB fallback first (MUST match pass_calls.c). */ + res = cbm_registry_resolve_call_scoped(rc->registry, call->callee_name, module_qn, + imp_keys, imp_vals, imp_count, lang, + call->enclosing_func_qn); } /* `p->open(fd)`: the registry does not split a callee on the arrow. * The object's type decides, as for the dot call handled further @@ -3616,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( @@ -3628,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); } } @@ -3683,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_pkgmap.c b/src/pipeline/pass_pkgmap.c index badc27954..303b293b9 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -1819,6 +1819,38 @@ bool cbm_import_symbol_fallback_allowed(CBMLanguage lang) { return lang != CBM_LANG_GO; } +/* A Python def or class declared inside a function body -- a closure, a + * decorator's `wrapper`, a test's local class -- is a local of that function: + * no import can name it. Its QN extends a Function/Method node's + * (proj.tests.mock_inputs.inner.wrapper.mock_getpass.getpass), which is how it + * is recognised; the symbol-name fallback bound `import getpass` to exactly + * that def. Only an enclosing def of the SAME file counts: a package + * __init__.py function can share a submodule's QN (poetry: layouts/__init__.py + * `def layout` vs layouts/layout.py `class Layout`). Mirrors + * cbm_registry_add_lang_in_module, which keeps the same symbols out of the call + * resolver's short-name index. */ +static bool import_candidate_is_python_local(const cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *cand) { + const char *qn = cand->qualified_name; + const char *slash = cand->file_path ? strrchr(cand->file_path, '/') : NULL; + char scope[CBM_SZ_512]; + size_t len = qn ? strlen(qn) : 0; + if (!cand->file_path || len == 0 || len >= sizeof(scope) || + cbm_language_for_filename(slash ? slash + SKIP_ONE : cand->file_path) != CBM_LANG_PYTHON) { + return false; + } + memcpy(scope, qn, len + SKIP_ONE); + for (char *dot = strrchr(scope, '.'); dot; dot = strrchr(scope, '.')) { + *dot = '\0'; + const cbm_gbuf_node_t *outer = cbm_gbuf_find_by_qn(gbuf, scope); + if (outer && outer->label && outer->file_path && + strcmp(outer->file_path, cand->file_path) == 0 && + (strcmp(outer->label, "Function") == 0 || strcmp(outer->label, "Method") == 0)) { + return true; + } + } + return false; +} + static const char *path_leaf(const char *path) { const char *leaf = path; for (const char *p = path; p && *p; p++) { @@ -2516,6 +2548,10 @@ static const cbm_gbuf_node_t *resolve_import_strategies( !import_targetable_label(cand->label)) { continue; } + /* A Python def-local (#1548) is no importable name. */ + if (import_candidate_is_python_local(ctx->gbuf, cand)) { + continue; + } if (source_file_qn && cand->qualified_name && strcmp(cand->qualified_name, source_file_qn) == 0) { continue; /* self */ diff --git a/src/pipeline/pass_tests.c b/src/pipeline/pass_tests.c index 9cade02a0..a763016fb 100644 --- a/src/pipeline/pass_tests.c +++ b/src/pipeline/pass_tests.c @@ -28,6 +28,7 @@ enum { #include "pipeline/pipeline.h" #include "pipeline/pipeline_internal.h" #include "graph_buffer/graph_buffer.h" +#include "discover/discover.h" #include "foundation/log.h" #include @@ -208,6 +209,48 @@ static char *test_to_prod_path(const char *test_path) { return NULL; } +/* #1548: a def declared inside a Python test function (a closure, a local + * helper, a method of a test-local class) is that test's own code, extracted + * as a node of its own. Its calls exercise the target on the test's behalf, so + * TESTS is attributed to the nearest enclosing test function of the same file + * (QN prefix walk). Before #1548 those calls hung on the test FILE node, whose + * name passes the test-name check. NULL when no enclosing test function exists + * (a helper nested in a non-test function stays without TESTS, like a + * module-level helper). */ +static const cbm_gbuf_node_t *enclosing_python_test_func(const cbm_gbuf_t *gb, + const cbm_gbuf_node_t *src) { + if (!src->qualified_name || !src->file_path || !src->label || + (strcmp(src->label, "Function") != 0 && strcmp(src->label, "Method") != 0)) { + return NULL; + } + const char *slash = strrchr(src->file_path, '/'); + if (cbm_language_for_filename(slash ? slash + SKIP_ONE : src->file_path) != CBM_LANG_PYTHON) { + return NULL; + } + char scope[CBM_SZ_512]; + size_t len = strlen(src->qualified_name); + if (len >= sizeof(scope)) { + return NULL; + } + memcpy(scope, src->qualified_name, len + SKIP_ONE); + for (char *dot = strrchr(scope, '.'); dot; dot = strrchr(scope, '.')) { + *dot = '\0'; + const cbm_gbuf_node_t *outer = cbm_gbuf_find_by_qn(gb, scope); + if (!outer) { + continue; + } + if (!outer->file_path || strcmp(outer->file_path, src->file_path) != 0) { + return NULL; /* left the file: no enclosing test function */ + } + if (outer->label && + (strcmp(outer->label, "Function") == 0 || strcmp(outer->label, "Method") == 0) && + cbm_is_test_func_name(outer->name)) { + return outer; + } + } + return NULL; +} + /* Create TESTS edges from test functions to production functions via CALLS edges. */ static int create_tests_edges(cbm_pipeline_ctx_t *ctx) { int count = 0; @@ -238,11 +281,15 @@ static int create_tests_edges(cbm_pipeline_ctx_t *ctx) { continue; } + const cbm_gbuf_node_t *test_src = src; if (!cbm_is_test_func_name(src->name)) { - continue; + test_src = enclosing_python_test_func(ctx->gbuf, src); + if (!test_src) { + continue; + } } - cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, "TESTS", "{}"); + cbm_gbuf_insert_edge(ctx->gbuf, test_src->id, tgt->id, "TESTS", "{}"); count++; } return count; 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/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index b0ba5bc15..072674d8d 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -200,6 +200,12 @@ char *cbm_pipeline_fqn_compute(const char *project, const char *rel_path, const /* Module QN: project.dir.parts (no name). Caller must free(). */ char *cbm_pipeline_fqn_module(const char *project, const char *rel_path); +/* Length of the prefix cbm_pipeline_fqn_compute(project, rel_path, name) puts + * before a symbol `name` (the joining dot excluded): the QN prefix every + * definition of that file carries, __init__ / index already dropped. Computed + * without allocating; 0 when unavailable. */ +size_t cbm_pipeline_fqn_symbol_prefix_len(const char *project, const char *rel_path); + /* Language-aware module QN. When `module_is_dir` is true (Java/Go package * semantics) the module is derived from the CONTAINING DIRECTORY (the filename * stem is dropped), so it agrees with the extraction-side def QNs; when false @@ -253,10 +259,24 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified /* As cbm_registry_add, recording `lang`: the DETECTED language of the file * that defines the symbol (CBM_LANG_COUNT = unknown), which the cross-language - * veto (cbm_registry_name_guess_vetoed) checks against the caller. */ + * veto (cbm_registry_name_guess_vetoed) checks against the caller. Detects no + * function-local symbols: that needs the defining file's module QN, see + * cbm_registry_add_lang_in_module. */ void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label, CBMLanguage lang); +/* As cbm_registry_add_lang. A Python symbol declared inside a function body of + * its own file (cbm_registry_qn_is_function_local, bounded by + * `module_prefix_len`: cbm_pipeline_fqn_symbol_prefix_len of the defining file) + * is registered for exact lookups only, never offered to the short-name + * strategies. The three graph + * registration sites (pass_definitions.c, pass_parallel.c, + * pipeline_incremental.c) all register through this, so full and incremental + * indexes agree. 0 = no local detection. */ +void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, + const char *qualified_name, const char *label, + CBMLanguage lang, size_t module_prefix_len); + /* True when code in `caller` can call a symbol defined in `target` by bare * name: same language, same interop family (JS/TS + script hosts, C/C++/CUDA/ * ObjC, JVM, .NET, ...; the table lives in registry.c), or Swift with the C @@ -268,6 +288,20 @@ bool cbm_lang_resolution_compatible(CBMLanguage caller, CBMLanguage target); * CBM_LANG_COUNT for anything else. */ CBMLanguage cbm_registry_synthetic_path_language(const char *file_path); +/* True when a proper prefix of `qualified_name` longer than + * `module_prefix_len` (cbm_pipeline_fqn_symbol_prefix_len of the defining file) + * is a registered Function or Method, i.e. the symbol was declared inside a + * function body of its own file. False when module_prefix_len is 0. */ +bool cbm_registry_qn_is_function_local(const cbm_registry_t *r, const char *qualified_name, + size_t module_prefix_len); + +/* Python bare-name lexical lookup: the def or class named `callee_name` bound + * in the nearest function scope enclosing `scope_qn` (the caller's QN), + * skipping class bodies, stopping at the caller's module (`module_qn`). + * Strategy "lexical_scope"; qualified_name is NULL when no such local exists. */ +cbm_resolution_t cbm_registry_resolve_lexical(const cbm_registry_t *r, const char *callee_name, + const char *scope_qn, const char *module_qn); + /* Resolve a callee name using prioritized strategies. * import_map: NULL-terminated array of {local_name, resolved_qn} pairs, or NULL. * Returns result with qualified_name="" if unresolved. diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 99df053e4..a860b5e32 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1327,7 +1327,10 @@ static bool incr_label_is_registry_symbol(const char *label) { /* Callback for cbm_gbuf_foreach_node: seed the registry with the existing * project's definition symbols so the resolver can match cross-file symbols * during incremental. Mirrors the full-index registry contents exactly so an - * incremental re-resolve picks the same nodes a full reindex would. */ + * incremental re-resolve picks the same nodes a full reindex would -- including + * a Python function's locals, which cbm_registry_add_lang keeps out of the + * short-name index there too (nodes load ORDER BY id and a file's defs are + * inserted parent first, so the enclosing function is seeded before its locals). */ /* Each symbol is seeded with its file's DETECTED language, exactly what the * full index recorded (pass_definitions.c / pass_parallel.c), so the cross- * language veto judges an incremental re-resolve the same way. Stored nodes carry no @@ -1337,6 +1340,7 @@ static bool incr_label_is_registry_symbol(const char *label) { typedef struct { cbm_registry_t *registry; CBMHashTable *lang_by_path; /* rel_path -> (CBMLanguage + 1); keys borrowed */ + const char *project; /* bounds Python function-local detection per file */ } registry_seed_t; static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { @@ -1351,14 +1355,20 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { lang = (CBMLanguage)((uintptr_t)v - SKIP_ONE); } } - cbm_registry_add_lang(seed->registry, node->name, node->qualified_name, node->label, lang); + /* Same per-file bound as the full index (pass_definitions.c). */ + size_t prefix_len = lang == CBM_LANG_PYTHON + ? cbm_pipeline_fqn_symbol_prefix_len(seed->project, node->file_path) + : 0; + cbm_registry_add_lang_in_module(seed->registry, node->name, node->qualified_name, node->label, + lang, prefix_len); } /* Seed the registry from every registry symbol in the graph buffer, with * languages taken from the discovered files (see registry_visitor). */ static void registry_seed_from_gbuf(cbm_registry_t *registry, const cbm_gbuf_t *gbuf, - const cbm_file_info_t *files, int file_count) { - registry_seed_t seed = {.registry = registry, .lang_by_path = NULL}; + const char *project, const cbm_file_info_t *files, + int file_count) { + registry_seed_t seed = {.registry = registry, .lang_by_path = NULL, .project = project}; if (files && file_count > 0) { seed.lang_by_path = cbm_ht_create((size_t)file_count * PAIR_LEN); } @@ -3302,7 +3312,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char if (!registry) { goto out; } - registry_seed_from_gbuf(registry, gbuf, files, file_count); + registry_seed_from_gbuf(registry, gbuf, project, files, file_count); cbm_log_info("delta.preseed_done", "registry", itoa_buf(cbm_registry_size(registry)), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); @@ -4152,7 +4162,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil /* Step 3-5: Registry + extract + resolve */ cbm_registry_t *registry = cbm_registry_new(); cbm_clock_gettime(CLOCK_MONOTONIC, &t); - registry_seed_from_gbuf(registry, existing, files, file_count); + registry_seed_from_gbuf(registry, existing, project, files, file_count); cbm_log_info("incremental.registry_seed", "symbols", itoa_buf(cbm_registry_size(registry)), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index fbae8044f..0965e0398 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -260,6 +260,16 @@ cbm_resolution_t cbm_registry_resolve_call_lang(const cbm_registry_t *r, const c const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count, CBMLanguage lang); +/* The registry step of BOTH call resolvers once the LSP has not answered: + * for Python, the def or class `callee_name` binds in the nearest def enclosing + * `scope_qn` (cbm_registry_resolve_lexical, "lexical_scope"); otherwise, or + * when there is no such local, cbm_registry_resolve_call_lang. */ +cbm_resolution_t cbm_registry_resolve_call_scoped(const cbm_registry_t *r, const char *callee_name, + const char *module_qn, + const char **import_map_keys, + const char **import_map_vals, + int import_map_count, CBMLanguage lang, + const char *scope_qn); #ifdef CBM_ENABLE_TEST_SEAMS void cbm_pxc_test_import_probe_fail_after(int successful_allocations); #endif diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index f53d1dd12..5dda868a4 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -62,6 +62,8 @@ const char *cbm_confidence_band(double score) { #define CONF_IMPORT_MAP_SUFFIX 0.85 /* Strategy 2: same_module — same file/package → high confidence */ #define CONF_SAME_MODULE 0.90 +/* Python lexical scope — a def bound in an enclosing function (LEGB) */ +#define CONF_LEXICAL_SCOPE 0.95 /* Strategy 3: unique_name — only one candidate project-wide */ #define CONF_UNIQUE_NAME 0.75 /* Strategy 4: suffix_match — multiple candidates, filtered */ @@ -1181,20 +1183,64 @@ static void index_under_name(cbm_registry_t *r, const char *key, const char *own } } -void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, - const char *label) { - cbm_registry_add_lang(r, name, qualified_name, label, CBM_LANG_COUNT); +static bool registry_label_is_callable(const char *label) { + return label && (strcmp(label, "Function") == 0 || strcmp(label, "Method") == 0); } -void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qualified_name, - const char *label, CBMLanguage lang) { - if (!r || !qualified_name || !label) { - return; +/* Length of the QN prefix the defs of the caller's file carry: its module QN + * (cbm_pipeline_fqn_module), minus the trailing `__init__` / `index` segment + * that symbol QNs drop (pkg/__init__.py defines proj.pkg.f). 0 when there is + * no module QN or `qualified_name` does not extend that prefix. */ +static size_t python_def_prefix_len(const char *module_qn, const char *qualified_name) { + static const char *const dropped[] = {".__init__", ".index"}; + size_t n = module_qn ? strlen(module_qn) : 0; + for (size_t i = 0; i < sizeof(dropped) / sizeof(dropped[0]); i++) { + size_t k = strlen(dropped[i]); + if (n > k && strcmp(module_qn + n - k, dropped[i]) == 0) { + n -= k; + break; + } } + if (n == 0 || !qualified_name || strncmp(qualified_name, module_qn, n) != 0 || + qualified_name[n] != '.') { + return 0; + } + return n; +} +/* Whether `qualified_name` sits inside a def of its OWN file: only prefixes + * strictly longer than `module_prefix_len` (cbm_pipeline_fqn_symbol_prefix_len + * of the defining file) count. A package's __init__.py can define a function + * whose QN equals a submodule's (poetry: layouts/__init__.py `def layout` vs + * layouts/layout.py), so an unbounded walk read the submodule's classes as + * that function's locals -- and the answer depended on which file registered + * first. */ +bool cbm_registry_qn_is_function_local(const cbm_registry_t *r, const char *qualified_name, + size_t module_prefix_len) { + char scope[CBM_SZ_512]; + size_t len = qualified_name ? strlen(qualified_name) : 0; + size_t floor = module_prefix_len; + if (!r || floor == 0 || len <= floor || len >= sizeof(scope) || qualified_name[floor] != '.') { + return false; + } + memcpy(scope, qualified_name, len + SKIP_ONE); + for (char *dot = strrchr(scope, '.'); dot && (size_t)(dot - scope) > floor; + dot = strrchr(scope, '.')) { + *dot = '\0'; + if (registry_label_is_callable(cbm_ht_get(r->exact, scope))) { + return true; + } + } + return false; +} + +/* Record `qualified_name` in the exact map. Returns the registry's owned copy + * of the QN, or NULL when it is already registered or cannot be recorded. */ +static const char *registry_add_exact(cbm_registry_t *r, const char *qualified_name, + const char *label) { /* Check for duplicate */ if (cbm_ht_get(r->exact, qualified_name)) { - return; + return NULL; } /* Intern the label (bounded set; linear scan is fine at this size). */ @@ -1211,14 +1257,55 @@ void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qual r->label_pool_n++; } if (!interned) { - return; /* pool exhausted (cannot happen with sane label sets) */ + return NULL; /* pool exhausted (cannot happen with sane label sets) */ } /* Store in exact map: QN → interned label. The key is the registry's ONE - * owned copy of the QN; by_name below borrows it (same lifetime) instead + * owned copy of the QN; by_name borrows it (same lifetime) instead * of a second strdup — this pair of copies was ~280 MB on the kernel. */ cbm_ht_set(r->exact, strdup(qualified_name), (void *)interned); - const char *owned_qn = cbm_ht_get_key(r->exact, qualified_name); + return cbm_ht_get_key(r->exact, qualified_name); +} + +void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, + const char *label) { + cbm_registry_add_lang_in_module(r, name, qualified_name, label, CBM_LANG_COUNT, 0); +} + +void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qualified_name, + const char *label, CBMLanguage lang) { + cbm_registry_add_lang_in_module(r, name, qualified_name, label, lang, 0); +} + +/* Python nests defs: a def or class declared in a function body -- a closure, + * an app factory's route handler, a local class and its methods -- is a local + * of that function, and its QN extends the function's + * (proj.mod.create_app.index). No code outside the function can name it, so it + * stays out of the short-name index: the weak name strategies (unique_name, + * suffix_match) must not offer django's per-test `class MyModelAdmin` or a + * decorator's `def wrapper` to a caller in another function or file. The exact + * map still holds it, for LSP targets and cbm_registry_resolve_lexical. One + * file's defs register parent first, so the enclosing function is already in + * the exact map when its locals arrive; the walk never leaves the symbol's own + * file (`module_prefix_len`, cbm_pipeline_fqn_symbol_prefix_len of the defining + * file; 0 = no local detection). + * Gated to Python: probing every prefix of every symbol would cost a kernel + * index seconds for nothing. */ +void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, + const char *qualified_name, const char *label, + CBMLanguage lang, size_t module_prefix_len) { + if (!r || !qualified_name || !label) { + return; + } + if (lang == CBM_LANG_PYTHON && + cbm_registry_qn_is_function_local(r, qualified_name, module_prefix_len)) { + (void)registry_add_exact(r, qualified_name, label); + return; + } + const char *owned_qn = registry_add_exact(r, qualified_name, label); + if (!owned_qn) { + return; + } /* Index the symbol under the QN's last dot segment, and additionally under * the name its caller passed when that segment carries a '#' fence. @@ -2123,6 +2210,73 @@ cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const cha import_map_count, module_qn ? strlen(module_qn) : 0, false); } +/* Python looks a bare name up in the calling def's own scope, then in each + * enclosing def's scope, and only then in the module and the builtins; a class + * body is never among them. So a bare `index()` in create_app, or `visit(...)` + * in a def nested in method Tree.visit, names the def bound in the nearest + * enclosing def -- create_app.index, Tree.visit.visit -- before anything a + * module-wide strategy would find. Walks scope_qn outward while its prefixes + * are registered defs or classes (skipping the classes) and stops at the + * caller's module (`module_qn`), which the other strategies own -- a + * same-QN function of a package __init__.py is never an enclosing scope. + * Empty unless such a local exists. Uncached: the answer depends on the + * caller, not only on the name. */ +cbm_resolution_t cbm_registry_resolve_lexical(const cbm_registry_t *r, const char *callee_name, + const char *scope_qn, const char *module_qn) { + char scope[CBM_SZ_512]; + char candidate[CBM_SZ_512]; + size_t len = scope_qn ? strlen(scope_qn) : 0; + size_t floor = python_def_prefix_len(module_qn, scope_qn); + if (!r || !callee_name || !callee_name[0] || strchr(callee_name, '.') || + strstr(callee_name, "::") || len == 0 || len >= sizeof(scope) || floor == 0) { + return empty_result(); + } + memcpy(scope, scope_qn, len + SKIP_ONE); + while (strlen(scope) > floor) { + const char *label = cbm_ht_get(r->exact, scope); + if (!label) { + break; + } + if (registry_label_is_callable(label)) { + int n = snprintf(candidate, sizeof(candidate), "%s.%s", scope, callee_name); + const char *stored_key = + n > 0 && (size_t)n < sizeof(candidate) ? cbm_ht_get_key(r->exact, candidate) : NULL; + if (stored_key) { + return (cbm_resolution_t){stored_key, "lexical_scope", CONF_LEXICAL_SCOPE, + REG_RESOLVED}; + } + } + char *dot = strrchr(scope, '.'); + if (!dot) { + break; + } + *dot = '\0'; + } + return empty_result(); +} + +/* The registry step both call resolvers (pass_calls.c, pass_parallel.c) run + * once the LSP has not answered: a Python bare name bound in an enclosing def + * first (the LSP binds those locals itself; this covers the sites it did not + * resolve), then the language-aware chain. One entry point, so the two + * resolvers cannot diverge on the rule. */ +cbm_resolution_t cbm_registry_resolve_call_scoped(const cbm_registry_t *r, const char *callee_name, + const char *module_qn, + const char **import_map_keys, + const char **import_map_vals, + int import_map_count, CBMLanguage lang, + const char *scope_qn) { + if (lang == CBM_LANG_PYTHON) { + cbm_resolution_t lexical = + cbm_registry_resolve_lexical(r, callee_name, scope_qn, module_qn); + if (lexical.qualified_name && lexical.qualified_name[0]) { + return lexical; + } + } + return cbm_registry_resolve_call_lang(r, callee_name, module_qn, import_map_keys, + import_map_vals, import_map_count, lang); +} + /* ── Fuzzy Resolve ──────────────────────────────────────────────── */ /* Filter candidates by import reachability. Returns count of reachable. */ diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 09a8dd208..8c37e8c5d 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4723,6 +4723,298 @@ TEST(python_init_nested_module_qn) { PASS(); } +/* ── #1548: Python defs nested in a function body ─────────────────── */ + +/* How many defs carry exactly this QN. */ +static int pyn_def_count(const CBMFileResult *r, const char *qn) { + int n = 0; + for (int i = 0; i < r->defs.count; i++) { + const char *q = r->defs.items[i].qualified_name; + if (q && strcmp(q, qn) == 0) { + n++; + } + } + return n; +} + +/* The one def with this QN, or NULL when there is none (or more than one). */ +static const CBMDefinition *pyn_def(const CBMFileResult *r, const char *qn) { + if (pyn_def_count(r, qn) != 1) { + return NULL; + } + for (int i = 0; i < r->defs.count; i++) { + const char *q = r->defs.items[i].qualified_name; + if (q && strcmp(q, qn) == 0) { + return &r->defs.items[i]; + } + } + return NULL; +} + +/* Calls of `callee` whose enclosing function is exactly `caller_qn`. */ +static int pyn_calls(const CBMFileResult *r, const char *callee, const char *caller_qn) { + int n = 0; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (c->callee_name && strcmp(c->callee_name, callee) == 0 && c->enclosing_func_qn && + strcmp(c->enclosing_func_qn, caller_qn) == 0) { + n++; + } + } + return n; +} + +/* No def QN is minted twice: a nested def must not also surface as a + * module-level one, and walking a def body must not re-extract anything. */ +static bool pyn_defs_unique(const CBMFileResult *r) { + for (int i = 0; i < r->defs.count; i++) { + const char *q = r->defs.items[i].qualified_name; + if (q && pyn_def_count(r, q) != 1) { + return false; + } + } + return true; +} + +/* The issue's reproducer: `def nested()` inside `def factory()` was never + * extracted. It is now a Function qualified by the def it sits in, the same + * QN the Python LSP gives its calls. Module-level defs, and defs in a + * module-level `if` block, keep their QNs. */ +TEST(extract_python_nested_def_qualified_by_enclosing_def_issue1548) { + CBMFileResult *r = extract("def target():\n" + " return 0\n" + "\n" + "\n" + "def module_level():\n" + " return target()\n" + "\n" + "\n" + "if True:\n" + " def inside_if():\n" + " return target()\n" + "\n" + "\n" + "def factory():\n" + " def nested():\n" + " return target()\n" + " return nested\n" + "\n" + "\n" + "def outer():\n" + " def middle():\n" + " def inner():\n" + " return target()\n" + " return inner\n" + " return middle\n", + CBM_LANG_PYTHON, "t", "app.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *nested = pyn_def(r, "t.app.factory.nested"); + ASSERT_NOT_NULL(nested); + ASSERT_STR_EQ(nested->label, "Function"); + ASSERT_EQ(nested->start_line, 15); + ASSERT_NOT_NULL(pyn_def(r, "t.app.outer.middle")); + ASSERT_NOT_NULL(pyn_def(r, "t.app.outer.middle.inner")); + /* Never minted flat, where it would collide with a module-level name. */ + ASSERT_EQ(pyn_def_count(r, "t.app.nested"), 0); + ASSERT_EQ(pyn_def_count(r, "t.app.inner"), 0); + /* Controls: unchanged. */ + ASSERT_NOT_NULL(pyn_def(r, "t.app.target")); + ASSERT_NOT_NULL(pyn_def(r, "t.app.module_level")); + ASSERT_NOT_NULL(pyn_def(r, "t.app.inside_if")); + ASSERT_NOT_NULL(pyn_def(r, "t.app.factory")); + ASSERT_TRUE(pyn_defs_unique(r)); + cbm_free_result(r); + PASS(); +} + +/* A call inside a nested def belongs to that def. It used to carry the flat + * `t.app.nested`, a QN no node has, so the edge was pinned on the file node: + * inbound traces of target() lost the caller. It is not the outer def's + * either. */ +TEST(extract_python_nested_def_calls_attributed_to_it_issue1548) { + CBMFileResult *r = extract("def target():\n" + " return 0\n" + "\n" + "def factory():\n" + " def nested():\n" + " return target()\n" + " return nested\n" + "\n" + "def outer():\n" + " def middle():\n" + " def inner():\n" + " return target()\n" + " return inner\n" + " return middle\n", + CBM_LANG_PYTHON, "t", "app.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(pyn_calls(r, "target", "t.app.factory.nested"), 1); + ASSERT_EQ(pyn_calls(r, "target", "t.app.outer.middle.inner"), 1); + ASSERT_EQ(pyn_calls(r, "target", "t.app.factory"), 0); + ASSERT_EQ(pyn_calls(r, "target", "t.app.nested"), 0); + ASSERT_EQ(pyn_calls(r, "target", "t.app.inner"), 0); + cbm_free_result(r); + PASS(); +} + +/* The app-factory shape: route handlers are decorated defs inside + * create_app. Each is extracted with its QN and carries the route its + * decorator declares, which is what mints the Route node and its HANDLES + * edge (pipeline_python_app_factory_routes_issue1548 checks the graph). */ +TEST(extract_python_decorated_nested_route_handler_issue1548) { + CBMFileResult *r = extract("from flask import Flask\n" + "\n" + "def create_app():\n" + " app = Flask(__name__)\n" + "\n" + " @app.route(\"/users/\")\n" + " def show_user(uid):\n" + " return render(uid)\n" + "\n" + " @app.get(\"/health\")\n" + " async def health():\n" + " return render(\"ok\")\n" + "\n" + " return app\n", + CBM_LANG_PYTHON, "t", "web.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *show = pyn_def(r, "t.web.create_app.show_user"); + ASSERT_NOT_NULL(show); + ASSERT_STR_EQ(show->route_path, "/users/"); + ASSERT_NOT_NULL(show->decorators); + const CBMDefinition *health = pyn_def(r, "t.web.create_app.health"); + ASSERT_NOT_NULL(health); + ASSERT_STR_EQ(health->route_path, "/health"); + ASSERT_STR_EQ(health->route_method, "GET"); + ASSERT_EQ(pyn_calls(r, "render", "t.web.create_app.show_user"), 1); + ASSERT_EQ(pyn_calls(r, "render", "t.web.create_app.health"), 1); + ASSERT_TRUE(pyn_defs_unique(r)); + cbm_free_result(r); + PASS(); +} + +/* A class declared in a def is qualified by the def; its methods are Methods + * of it, and a call in such a method is the method's. */ +TEST(extract_python_nested_class_issue1548) { + CBMFileResult *r = extract("def create_app():\n" + " class Settings:\n" + " debug = False\n" + "\n" + " def value(self):\n" + " return load(self)\n" + "\n" + " return Settings().value()\n", + CBM_LANG_PYTHON, "t", "web.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *cls = pyn_def(r, "t.web.create_app.Settings"); + ASSERT_NOT_NULL(cls); + ASSERT_STR_EQ(cls->label, "Class"); + const CBMDefinition *value = pyn_def(r, "t.web.create_app.Settings.value"); + ASSERT_NOT_NULL(value); + ASSERT_STR_EQ(value->label, "Method"); + ASSERT_STR_EQ(value->parent_class, "t.web.create_app.Settings"); + ASSERT_EQ(pyn_calls(r, "load", "t.web.create_app.Settings.value"), 1); + ASSERT_EQ(pyn_def_count(r, "t.web.Settings"), 0); + ASSERT_TRUE(pyn_defs_unique(r)); + cbm_free_result(r); + PASS(); +} + +/* A def nested in a method, named like the method (django's + * AbstractBaseUser.check_password holds a `def setter`; Tree.visit here holds + * a `def visit`). It used to share the method's QN, so its calls -- its + * recursion included -- were the method's. It is its own Function now: the + * recursive call and render() are its, only visit(node) is the method's, and + * the recursion seed flags the nested def. */ +TEST(extract_python_nested_def_named_like_its_method_issue1548) { + CBMFileResult *r = extract("class Tree:\n" + " def visit(self, node):\n" + " def visit(n):\n" + " if n:\n" + " visit(n.child)\n" + " render(n)\n" + "\n" + " visit(node)\n", + CBM_LANG_PYTHON, "t", "m.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *method = pyn_def(r, "t.m.Tree.visit"); + ASSERT_NOT_NULL(method); + ASSERT_STR_EQ(method->label, "Method"); + const CBMDefinition *nested = pyn_def(r, "t.m.Tree.visit.visit"); + ASSERT_NOT_NULL(nested); + ASSERT_STR_EQ(nested->label, "Function"); + ASSERT_EQ(nested->start_line, 3); + ASSERT_EQ(pyn_calls(r, "visit", "t.m.Tree.visit.visit"), 1); + ASSERT_EQ(pyn_calls(r, "render", "t.m.Tree.visit.visit"), 1); + ASSERT_EQ(pyn_calls(r, "visit", "t.m.Tree.visit"), 1); + ASSERT_EQ(pyn_calls(r, "render", "t.m.Tree.visit"), 0); + ASSERT_TRUE(nested->is_recursive); + ASSERT_TRUE(pyn_defs_unique(r)); + cbm_free_result(r); + PASS(); +} + +/* Controls: a lambda and the comprehensions are expressions, not defs. They + * mint nothing, and the calls inside them stay the enclosing def's. */ +TEST(extract_python_lambda_and_comprehension_stay_in_enclosing_def_issue1548) { + CBMFileResult *r = extract("def work(items):\n" + " double = lambda x: scale(x)\n" + " squares = [scale(i) for i in items]\n" + " table = {k: scale(k) for k in items}\n" + " return double(squares), table\n", + CBM_LANG_PYTHON, "t", "c.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_NOT_NULL(pyn_def(r, "t.c.work")); + for (int i = 0; i < r->defs.count; i++) { + const char *q = r->defs.items[i].qualified_name; + ASSERT_FALSE(q && strncmp(q, "t.c.work.", strlen("t.c.work.")) == 0); + } + ASSERT_EQ(pyn_calls(r, "scale", "t.c.work"), 3); + ASSERT_TRUE(pyn_defs_unique(r)); + cbm_free_result(r); + PASS(); +} + +/* Records other than calls take their enclosing function from the same + * unified-walk scope frame as the calls. The nested def's env read carries its + * QN; the outer def's reads, before and after the nested def, keep the outer + * QN once its frame is popped. */ +TEST(extract_python_nested_def_env_access_attributed_to_it_issue1548) { + CBMFileResult *r = extract("import os\n" + "\n" + "def outer():\n" + " token = os.environ[\"OUTER_TOKEN\"]\n" + "\n" + " def inner():\n" + " return os.environ[\"INNER_TOKEN\"]\n" + "\n" + " return inner, token, os.environ[\"OUTER_AGAIN\"]\n", + CBM_LANG_PYTHON, "t", "e.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + int seen = 0; + for (int i = 0; i < r->env_accesses.count; i++) { + const CBMEnvAccess *ea = &r->env_accesses.items[i]; + ASSERT_NOT_NULL(ea->enclosing_func_qn); + if (strcmp(ea->env_key, "INNER_TOKEN") == 0) { + ASSERT_STR_EQ(ea->enclosing_func_qn, "t.e.outer.inner"); + seen++; + } else { + ASSERT_STR_EQ(ea->enclosing_func_qn, "t.e.outer"); + seen++; + } + } + ASSERT_EQ(seen, 3); + cbm_free_result(r); + PASS(); +} + TEST(js_index_module_qn_not_collide_with_folder) { /* Same bug for JS/TS index.ts files */ CBMFileResult *r = extract("export function App() { return null; }\n", CBM_LANG_TYPESCRIPT, @@ -12609,6 +12901,13 @@ SUITE(extraction) { /* __init__.py / index.ts Module QN collision regression */ RUN_TEST(python_init_module_qn_not_collide_with_folder); RUN_TEST(python_init_nested_module_qn); + RUN_TEST(extract_python_nested_def_qualified_by_enclosing_def_issue1548); + RUN_TEST(extract_python_nested_def_calls_attributed_to_it_issue1548); + RUN_TEST(extract_python_decorated_nested_route_handler_issue1548); + RUN_TEST(extract_python_nested_class_issue1548); + RUN_TEST(extract_python_nested_def_named_like_its_method_issue1548); + RUN_TEST(extract_python_lambda_and_comprehension_stay_in_enclosing_def_issue1548); + RUN_TEST(extract_python_nested_def_env_access_attributed_to_it_issue1548); RUN_TEST(js_index_module_qn_not_collide_with_folder); RUN_TEST(python_regular_module_qn_unchanged); RUN_TEST(extract_java_method_annotations_issue382); 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 085fa55dd..9a756f688 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); +} + /* F-R4 (#763, Rust): calls inside a method of a generic impl (`impl Gen`, * `impl<'a> Life<'a>`) were sourced from the File node: the call scope kept the * generic arguments the def side strips. Each method must be the source of its @@ -13716,6 +13890,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 @@ -14387,6 +14678,731 @@ static void teardown_lang_repo(void) { g_lang_tmpdir[0] = '\0'; } +/* ── #1548: Python defs nested in a function body ─────────────────── */ + +/* The QN of `rel` in `project` ("web.create_app.health"). Route QNs + * ("__route__GET__/health") carry no project and are taken as they are. */ +static void pyn_qn(char *buf, size_t cap, const char *project, const char *rel) { + if (strncmp(rel, "__route__", strlen("__route__")) == 0) { + snprintf(buf, cap, "%s", rel); + } else { + snprintf(buf, cap, "%s.%s", project, rel); + } +} + +/* True iff an edge of `type` runs from node `src_rel` to node `tgt_rel`. */ +static bool pyn_edge(cbm_store_t *s, const char *project, const char *src_rel, const char *tgt_rel, + const char *type) { + char sq[512]; + char tq[512]; + pyn_qn(sq, sizeof(sq), project, src_rel); + pyn_qn(tq, sizeof(tq), project, tgt_rel); + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + bool found = false; + if (cbm_store_find_node_by_qn(s, project, sq, &src) == CBM_STORE_OK && + cbm_store_find_node_by_qn(s, project, tq, &tgt) == CBM_STORE_OK) { + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_source_type(s, src.id, type, &edges, &ec); + for (int i = 0; i < ec && !found; i++) { + found = edges[i].target_id == tgt.id; + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + return found; +} + +/* The fixtures of the #1548 pipeline tests. */ +static const char pyn_web_py[] = "from flask import Flask\n" + "\n" + "def load_user(uid):\n" + " return uid\n" + "\n" + "def render(name):\n" + " return name\n" + "\n" + "def create_app():\n" + " app = Flask(__name__)\n" + "\n" + " @app.route(\"/users/\")\n" + " def show_user(uid):\n" + " return render(load_user(uid))\n" + "\n" + " @app.get(\"/health\")\n" + " def health():\n" + " return render(\"ok\")\n" + "\n" + " def ping():\n" + " return render(\"pong\")\n" + "\n" + " app.add_url_rule(\"/ping\", \"ping\", ping)\n" + " return app\n"; +/* A nested def named like its method, and a local helper whose name exists + * nowhere else -- b.py calls that name without binding it. */ +static const char pyn_a_py[] = "class Tree:\n" + " def visit(self, node):\n" + " def visit(n):\n" + " if n:\n" + " visit(n.child)\n" + "\n" + " visit(node)\n" + "\n" + "\n" + "def outer():\n" + " def helper_only_here():\n" + " return 1\n" + "\n" + " return helper_only_here()\n"; +static const char pyn_b_py[] = "def elsewhere():\n" + " return helper_only_here()\n"; +/* Two defs, each with its own local class of the same name. */ +static const char pyn_c_py[] = "def make_forms():\n" + " class LocalForm:\n" + " def clean(self):\n" + " return 1\n" + "\n" + " form = LocalForm()\n" + " return form.clean()\n" + "\n" + "\n" + "def make_other_forms():\n" + " class LocalForm:\n" + " def clean(self):\n" + " return 2\n" + "\n" + " form = LocalForm()\n" + " return form.clean()\n"; +/* A local def named like a stdlib function another module imports. */ +static const char pyn_x_py[] = "def outer():\n" + " def getpass(prompt):\n" + " return prompt\n" + "\n" + " return getpass(\"?\")\n"; +static const char pyn_y_py[] = "from getpass import getpass\n" + "\n" + "\n" + "def ask():\n" + " return getpass(\"password: \")\n"; + +/* The issue's headline: an app factory's route handlers are defs inside + * create_app. They were never extracted, so the HTTP layer vanished -- no + * HANDLES, and load_user()'s only caller was pinned on the file node. */ +TEST(pipeline_python_app_factory_routes_issue1548) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_factory_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "web.py", pyn_web_py); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/factory.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_TRUE( + pyn_edge(s, project, "web.create_app.show_user", "__route__ANY__/users/{}", "HANDLES")); + ASSERT_TRUE(pyn_edge(s, project, "web.create_app.health", "__route__GET__/health", "HANDLES")); + ASSERT_TRUE(pyn_edge(s, project, "web.create_app.show_user", "web.load_user", "CALLS")); + ASSERT_TRUE(pyn_edge(s, project, "web.create_app.health", "web.render", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "web.py.__file__", "web.load_user", "CALLS")); + /* A call-registered handler: add_url_rule names create_app's local ping. */ + ASSERT_TRUE(pyn_edge(s, project, "web.create_app.ping", "__route__ANY__/ping", "HANDLES")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* A def nested in a function is a local of it (Python LEGB). A bare call + * resolves to the local bound in the nearest enclosing function: visit(node) + * in Tree.visit reaches the nested visit, whose own recursion stays a + * self-call instead of landing on the method. And no caller outside the + * function can reach the local by name -- b.py's unbound helper_only_here() + * must not be matched to it by a short-name strategy. */ +TEST(pipeline_python_nested_def_is_function_local_issue1548) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_local_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "a.py", pyn_a_py); + write_temp_file(tmp, "b.py", pyn_b_py); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/local.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_TRUE(pyn_edge(s, project, "a.Tree.visit", "a.Tree.visit.visit", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "a.Tree.visit.visit", "a.Tree.visit", "CALLS")); + ASSERT_TRUE(pyn_edge(s, project, "a.outer", "a.outer.helper_only_here", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "b.elsewhere", "a.outer.helper_only_here", "CALLS")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Two defs each declare `class LocalForm`. Each constructor call reaches its + * own def's class, and a method call on the instance never reaches the other + * def's class: the Python LSP must not bind a local class module-wide by its + * short name (every same-named local class collapsed onto one). */ +TEST(pipeline_python_local_classes_stay_in_their_def_issue1548) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_localcls_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "c.py", pyn_c_py); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/localcls.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_TRUE(pyn_edge(s, project, "c.make_forms", "c.make_forms.LocalForm", "CALLS")); + ASSERT_TRUE( + pyn_edge(s, project, "c.make_other_forms", "c.make_other_forms.LocalForm", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "c.make_forms", "c.make_other_forms.LocalForm", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "c.make_other_forms", "c.make_forms.LocalForm", "CALLS")); + ASSERT_FALSE( + pyn_edge(s, project, "c.make_forms", "c.make_other_forms.LocalForm.clean", "CALLS")); + ASSERT_FALSE( + pyn_edge(s, project, "c.make_other_forms", "c.make_forms.LocalForm.clean", "CALLS")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* No import can name a function's local. The symbol-name import fallback + * bound `from getpass import getpass` (the stdlib module, not in the project) + * to x.outer's local `def getpass`, and ask()'s call followed the import. */ +TEST(pipeline_python_import_never_binds_a_local_def_issue1548) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_localimp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "x.py", pyn_x_py); + write_temp_file(tmp, "y.py", pyn_y_py); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/localimp.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_TRUE(pyn_edge(s, project, "x.outer", "x.outer.getpass", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "y.py.__file__", "x.outer.getpass", "IMPORTS")); + ASSERT_FALSE(pyn_edge(s, project, "y.ask", "x.outer.getpass", "CALLS")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* The same four fixtures through the parallel path: >= 50 files and + * CBM_WORKERS=4 force pass_parallel.c and the cross-file Python LSP, which + * register, resolve and bind through their own code. */ +TEST(pipeline_python_nested_defs_parallel_issue1548) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_nested_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "web.py", pyn_web_py); + write_temp_file(tmp, "a.py", pyn_a_py); + write_temp_file(tmp, "b.py", pyn_b_py); + write_temp_file(tmp, "c.py", pyn_c_py); + write_temp_file(tmp, "x.py", pyn_x_py); + write_temp_file(tmp, "y.py", pyn_y_py); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/nested_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_TRUE( + pyn_edge(s, project, "web.create_app.show_user", "__route__ANY__/users/{}", "HANDLES")); + ASSERT_TRUE(pyn_edge(s, project, "web.create_app.health", "__route__GET__/health", "HANDLES")); + ASSERT_TRUE(pyn_edge(s, project, "web.create_app.show_user", "web.load_user", "CALLS")); + ASSERT_TRUE(pyn_edge(s, project, "web.create_app.ping", "__route__ANY__/ping", "HANDLES")); + ASSERT_TRUE(pyn_edge(s, project, "a.Tree.visit", "a.Tree.visit.visit", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "a.Tree.visit.visit", "a.Tree.visit", "CALLS")); + ASSERT_TRUE(pyn_edge(s, project, "a.outer", "a.outer.helper_only_here", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "b.elsewhere", "a.outer.helper_only_here", "CALLS")); + ASSERT_TRUE(pyn_edge(s, project, "c.make_forms", "c.make_forms.LocalForm", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "c.make_forms", "c.make_other_forms.LocalForm", "CALLS")); + ASSERT_FALSE( + pyn_edge(s, project, "c.make_forms", "c.make_other_forms.LocalForm.clean", "CALLS")); + ASSERT_FALSE( + pyn_edge(s, project, "c.make_other_forms", "c.make_forms.LocalForm.clean", "CALLS")); + ASSERT_TRUE(pyn_edge(s, project, "x.outer", "x.outer.getpass", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "y.py.__file__", "x.outer.getpass", "IMPORTS")); + ASSERT_FALSE(pyn_edge(s, project, "y.ask", "x.outer.getpass", "CALLS")); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +/* An incremental re-index seeds its registry from the stored graph, not from + * extraction. It must keep a function's locals out of the short-name index + * exactly as the full index does: re-resolving the touched b.py must not bind + * its unbound helper_only_here() to a.outer's local. */ +TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_local_incr_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "a.py", pyn_a_py); + write_temp_file(tmp, "b.py", pyn_b_py); + /* An extensionless script is Python by its shebang: its locals are locals + * in the incremental seed too, not only in the full index. */ + write_temp_file(tmp, "tool", + "#!/usr/bin/env python3\n" + "def run():\n" + " def helper_in_tool():\n" + " return 1\n" + "\n" + " return helper_in_tool()\n"); + write_temp_file(tmp, "d.py", + "def elsewhere_too():\n" + " return helper_in_tool()\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/local_incr.db", tmp); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + cbm_pipeline_free(p1); + + char b_path[512]; + snprintf(b_path, sizeof(b_path), "%s/b.py", tmp); + ASSERT_EQ(th_append_file(b_path, "\n# touched: re-resolve b.py incrementally\n"), 0); + snprintf(b_path, sizeof(b_path), "%s/d.py", tmp); + ASSERT_EQ(th_append_file(b_path, "\n# touched: re-resolve d.py incrementally\n"), 0); + + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + const char *project = cbm_pipeline_project_name(p2); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_TRUE(pyn_edge(s, project, "a.outer", "a.outer.helper_only_here", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "b.elsewhere", "a.outer.helper_only_here", "CALLS")); + ASSERT_TRUE(pyn_edge(s, project, "tool.run", "tool.run.helper_in_tool", "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, "d.elsewhere_too", "tool.run.helper_in_tool", "CALLS")); + + cbm_store_close(s); + cbm_pipeline_free(p2); + th_rmtree(tmp); + PASS(); +} + +/* As pyn_edge, and the edge's properties also contain `fragment`. */ +static bool pyn_edge_with(cbm_store_t *s, const char *project, const char *src_rel, + const char *tgt_rel, const char *type, const char *fragment) { + char sq[512]; + char tq[512]; + pyn_qn(sq, sizeof(sq), project, src_rel); + pyn_qn(tq, sizeof(tq), project, tgt_rel); + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + bool found = false; + if (cbm_store_find_node_by_qn(s, project, sq, &src) == CBM_STORE_OK && + cbm_store_find_node_by_qn(s, project, tq, &tgt) == CBM_STORE_OK) { + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_source_type(s, src.id, type, &edges, &ec); + for (int i = 0; i < ec && !found; i++) { + found = edges[i].target_id == tgt.id && edges[i].properties_json && + strstr(edges[i].properties_json, fragment); + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + return found; +} + +/* A local def shadows a module def of the same name (Python LEGB), in both + * orders of the two callers within one file: the registry's per-file resolve + * cache is keyed by the callee name only, so a caller-dependent answer must + * never come from it. The undecorated local is bound by the Python LSP itself + * (an lsp_* edge); a decorated one is no exact callable value, so the LSP + * shadows it and the registry's lexical_scope step binds it. */ +static const char pyn_shadow_local_first_py[] = "def helper():\n" + " return 0\n" + "\n" + "def uses_local():\n" + " def helper():\n" + " return 1\n" + " return helper()\n" + "\n" + "def uses_module():\n" + " return helper()\n" + "\n" + "def uses_decorated():\n" + " @staticmethod\n" + " def helper():\n" + " return 2\n" + " return helper()\n"; +static const char pyn_shadow_module_first_py[] = "def helper():\n" + " return 0\n" + "\n" + "def uses_module():\n" + " return helper()\n" + "\n" + "def uses_local():\n" + " if True:\n" + " def helper():\n" + " return 1\n" + " return helper()\n"; + +/* A class declared in a def is walked by the LSP under the def's QN: super() + * and self calls in its methods resolve through the class's own bases. */ +static const char pyn_local_class_methods_py[] = "class Base:\n" + " def greet(self):\n" + " return 1\n" + "\n" + "def factory():\n" + " class Local(Base):\n" + " def greet(self):\n" + " return super().greet()\n" + "\n" + " def other(self):\n" + " return self.greet()\n" + " return Local\n"; + +static int pyn_assert_shadowing(cbm_store_t *s, const char *project) { + ASSERT_TRUE(pyn_edge_with(s, project, "sc.factory.Local.greet", "sc.Base.greet", "CALLS", + "\"strategy\":\"lsp_super\"")); + ASSERT_TRUE(pyn_edge_with(s, project, "sc.factory.Local.other", "sc.factory.Local.greet", + "CALLS", "\"strategy\":\"lsp_")); + /* sa's local def is a plain statement of the body: the LSP binds it as an + * exact callable. sb's sits under `if True:`; at that compound statement's + * join the LSP invalidates what the branch bound (the def may not exist on + * every path), so it emits nothing and the registry's lexical step binds + * the local -- still never the module def. H1 (the LSP walks compound + * statements) may legitimately move sb to an lsp_* strategy; that PR + * must update this expectation deliberately. */ + const char *files[] = {"sa", "sb"}; + const char *local_strategy[] = {"\"strategy\":\"lsp_", "\"strategy\":\"lexical_scope\""}; + for (int f = 0; f < 2; f++) { + char local_caller[64], local_def[64], module_caller[64], module_def[64]; + snprintf(local_caller, sizeof(local_caller), "%s.uses_local", files[f]); + snprintf(local_def, sizeof(local_def), "%s.uses_local.helper", files[f]); + snprintf(module_caller, sizeof(module_caller), "%s.uses_module", files[f]); + snprintf(module_def, sizeof(module_def), "%s.helper", files[f]); + ASSERT_TRUE( + pyn_edge_with(s, project, local_caller, local_def, "CALLS", local_strategy[f])); + ASSERT_FALSE(pyn_edge(s, project, local_caller, module_def, "CALLS")); + ASSERT_TRUE(pyn_edge(s, project, module_caller, module_def, "CALLS")); + ASSERT_FALSE(pyn_edge(s, project, module_caller, local_def, "CALLS")); + } + ASSERT_TRUE(pyn_edge_with(s, project, "sa.uses_decorated", "sa.uses_decorated.helper", + "CALLS", "\"strategy\":\"lexical_scope\"")); + ASSERT_FALSE(pyn_edge(s, project, "sa.uses_decorated", "sa.helper", "CALLS")); + return 0; +} + +static int pyn_run_shadowing(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_shadow_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "sa.py", pyn_shadow_local_first_py); + write_temp_file(tmp, "sb.py", pyn_shadow_module_first_py); + write_temp_file(tmp, "sc.py", pyn_local_class_methods_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/shadow.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + int verdict = s ? pyn_assert_shadowing(s, cbm_pipeline_project_name(p)) : 1; + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + return verdict; +} + +TEST(pipeline_python_local_def_shadows_module_def_issue1548) { + return pyn_run_shadowing(0); +} + +TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548) { + return pyn_run_shadowing(52); +} + +/* L7 review of #1548: a package __init__.py function can share a submodule's QN + * (poetry: layouts/__init__.py `def layout` is the QN prefix of every symbol in + * layouts/layout.py). The function-local detection is bounded at the symbol's + * own file, so class Layout stays a module-level, importable class. src/ layout + * without a manifest: the module resolver misses `rowpkg.layout`, so the import + * relies on the symbol-name fallback, which skips function locals -- unbounded, + * Layout read as a local of layout() and its import and calls vanished. Both + * resolvers; the second run re-indexes incrementally after an edit of + * layout.py, so the incremental registry seed is held to the same bound. */ +static const char pyn_layout_init_py[] = "def layout(name):\n" + " return name\n"; +static const char pyn_layout_mod_py[] = "class Layout:\n" + " def __init__(self, name):\n" + " self.name = name\n" + "\n" + " def create(self):\n" + " return self.name\n"; +static const char pyn_layout_app_py[] = "from rowpkg.layout import Layout\n" + "\n" + "def test_build():\n" + " return Layout(\"demo\").create()\n"; + +static int pyn_run_init_collision(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_initqn_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "src/rowpkg/__init__.py", pyn_layout_init_py); + write_temp_file(tmp, "src/rowpkg/layout.py", pyn_layout_mod_py); + write_temp_file(tmp, "tests/test_app.py", pyn_layout_app_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/initqn.db", tmp); + int rc[2] = {-1, -1}; + bool imports[2] = {false, false}; + bool calls[2] = {false, false}; + bool method[2] = {false, false}; + for (int run = 0; run < 2; run++) { + if (run == 1) { + char path[512]; + snprintf(path, sizeof(path), "%s/src/rowpkg/layout.py", tmp); + th_append_file(path, "\n# edited: the second run re-indexes incrementally\n"); + } + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + rc[run] = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc[run] == 0 ? cbm_store_open_path(db_path) : NULL; + if (s) { + const char *project = cbm_pipeline_project_name(p); + imports[run] = pyn_edge(s, project, "tests.test_app.py.__file__", + "src.rowpkg.layout.Layout", "IMPORTS"); + calls[run] = + pyn_edge(s, project, "tests.test_app.test_build", "src.rowpkg.layout.Layout", + "CALLS"); + method[run] = pyn_edge(s, project, "tests.test_app.test_build", + "src.rowpkg.layout.Layout.create", "CALLS"); + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + for (int run = 0; run < 2; run++) { + ASSERT_EQ(rc[run], 0); + ASSERT_TRUE(imports[run]); /* the class stays importable */ + ASSERT_TRUE(calls[run]); /* Layout("demo") */ + ASSERT_TRUE(method[run]); /* .create() through the constructed type */ + } + PASS(); +} + +TEST(pipeline_python_init_function_shares_submodule_qn_issue1548) { + return pyn_run_init_collision(0); +} + +TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548) { + return pyn_run_init_collision(52); +} + +/* #1548 keeps TESTS for calls made through a test's own nested helper: the + * call now starts at the extracted helper def (not at the test FILE node, as + * before #1548), so TESTS is attributed to the enclosing test function. A + * helper nested in a NON-test function stays without TESTS, like a module-level + * helper. Both resolvers. */ +static const char pyn_tests_prod_py[] = "def target(value):\n" + " return value\n" + "\n" + "def other_target(value):\n" + " return value\n"; +static const char pyn_tests_test_py[] = "from pkg.prod import target, other_target\n" + "\n" + "def test_through_nested_helper():\n" + " def check(v):\n" + " return target(v)\n" + " assert check(1) == 1\n" + "\n" + "class TestGroup:\n" + " def test_method_helper(self):\n" + " def probe():\n" + " return target(2)\n" + " assert probe() == 2\n" + "\n" + "def make_fixture():\n" + " def build():\n" + " return other_target(3)\n" + " return build\n"; + +static int pyn_run_nested_helper_tests(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_ntests_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "pkg/__init__.py", ""); + write_temp_file(tmp, "pkg/prod.py", pyn_tests_prod_py); + write_temp_file(tmp, "tests/test_rows.py", pyn_tests_test_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/ntests.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + bool fn_tests = false, method_tests = false, helper_calls = false, fixture_tests = true; + if (s) { + const char *project = cbm_pipeline_project_name(p); + helper_calls = pyn_edge(s, project, "tests.test_rows.test_through_nested_helper.check", + "pkg.prod.target", "CALLS"); + fn_tests = pyn_edge(s, project, "tests.test_rows.test_through_nested_helper", + "pkg.prod.target", "TESTS"); + method_tests = pyn_edge(s, project, "tests.test_rows.TestGroup.test_method_helper", + "pkg.prod.target", "TESTS"); + fixture_tests = pyn_edge(s, project, "tests.test_rows.make_fixture", + "pkg.prod.other_target", "TESTS") || + pyn_edge(s, project, "tests.test_rows.make_fixture.build", + "pkg.prod.other_target", "TESTS"); + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(helper_calls); /* the call starts at the nested helper (#1548) */ + ASSERT_TRUE(fn_tests); /* ... and the enclosing test function still TESTS the target */ + ASSERT_TRUE(method_tests); /* the same inside a test method */ + ASSERT_FALSE(fixture_tests); /* a helper nested in a non-test function: no TESTS */ + PASS(); +} + +TEST(pipeline_python_nested_helper_keeps_tests_issue1548) { + return pyn_run_nested_helper_tests(0); +} + +TEST(pipeline_python_nested_helper_keeps_tests_parallel_issue1548) { + return pyn_run_nested_helper_tests(52); +} + TEST(pipeline_python_project) { /* Port of TestPipelinePythonProject */ const char *files[] = {"main.py", "utils.py"}; @@ -23816,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_generic_impl_method_sources_its_calls); RUN_TEST(pipeline_rust_generic_impl_method_sources_its_calls_parallel); RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method); @@ -23868,6 +24888,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); @@ -23884,6 +24906,18 @@ SUITE(pipeline) { RUN_TEST(pipeline_c_variants_on_sequential_and_parallel_paths_c1); RUN_TEST(pipeline_cpp_overloads_are_listed_as_variants_c1); RUN_TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1); + RUN_TEST(pipeline_python_app_factory_routes_issue1548); + RUN_TEST(pipeline_python_nested_def_is_function_local_issue1548); + RUN_TEST(pipeline_python_local_classes_stay_in_their_def_issue1548); + RUN_TEST(pipeline_python_import_never_binds_a_local_def_issue1548); + RUN_TEST(pipeline_python_nested_defs_parallel_issue1548); + RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); + RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); + RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); + RUN_TEST(pipeline_python_init_function_shares_submodule_qn_issue1548); + RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); + RUN_TEST(pipeline_python_nested_helper_keeps_tests_issue1548); + RUN_TEST(pipeline_python_nested_helper_keeps_tests_parallel_issue1548); RUN_TEST(pipeline_header_include_target_is_independent_of_registration_order); RUN_TEST(pipeline_imports_multi_symbol_edges); RUN_TEST(pipeline_go_cross_package_call); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 3ee2d8ad5..7a1c460d5 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -3629,12 +3629,16 @@ TEST(pylsp_nested_and_local_classbody_calls_join_extracted_caller) { {"field(35)", "test.main.Controller.build", "test.main.field"}, }; CBMFileResult *r = extract_py(source); - bool correct = pylsp_class_sites(r, source, sites, 7) && - !pylsp_module_has_def(r, "test.main.build.Local") && - !pylsp_module_has_def(r, "test.main.Controller.build.Local"); + /* #1548: a class declared in a function body is extracted, as a local of + * that function; its class-body calls still join the enclosing function + * (the sites above), never the local class. */ + bool sites_ok = pylsp_class_sites(r, source, sites, 7); + bool locals_extracted = r && pylsp_module_has_def(r, "test.main.build.Local") && + pylsp_module_has_def(r, "test.main.Controller.build.Local"); if (r) cbm_free_result(r); - ASSERT_TRUE(correct); + ASSERT_TRUE(sites_ok); + ASSERT_TRUE(locals_extracted); PASS(); } diff --git a/tests/test_registry.c b/tests/test_registry.c index 45fd6db14..0f3e805f2 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1965,6 +1965,172 @@ TEST(registry_python_scope_qualified_terminal_bucket_uncapped) { PASS(); } +/* #1548: a Python def or class declared in a function body is a local of it. + * It is registered for exact lookups but kept out of the short-name index, so + * no weak strategy offers it to a caller elsewhere. A class nested in a class + * (proj.m.Outer.Inner) is an attribute, not a local, and stays nameable; so + * does the same QN shape from another language. */ +TEST(registry_python_function_local_is_exact_only_issue1548) { + cbm_registry_t *r = cbm_registry_new(); + const size_t web = strlen("proj.web"); /* symbol prefix of web.py */ + static const char *const defs[][3] = { + {"create_app", "proj.web.create_app", "Function"}, + {"index", "proj.web.create_app.index", "Function"}, + {"Settings", "proj.web.create_app.Settings", "Class"}, + {"value", "proj.web.create_app.Settings.value", "Method"}, + {"Outer", "proj.web.Outer", "Class"}, + {"Inner", "proj.web.Outer.Inner", "Class"}, + }; + for (size_t i = 0; i < sizeof(defs) / sizeof(defs[0]); i++) { + cbm_registry_add_lang_in_module(r, defs[i][0], defs[i][1], defs[i][2], CBM_LANG_PYTHON, + web); + } + cbm_registry_add_lang_in_module(r, "f", "proj.ts.f", "Function", CBM_LANG_TYPESCRIPT, + strlen("proj.ts")); + cbm_registry_add_lang_in_module(r, "g", "proj.ts.f.g", "Function", CBM_LANG_TYPESCRIPT, + strlen("proj.ts")); + + static const char *const local[] = {"proj.web.create_app.index", "proj.web.create_app.Settings", + "proj.web.create_app.Settings.value"}; + for (int i = 0; i < 3; i++) { + ASSERT_TRUE(cbm_registry_exists(r, local[i])); + ASSERT_TRUE(cbm_registry_qn_is_function_local(r, local[i], web)); + const char *simple = strrchr(local[i], '.') + 1; + const char **out = NULL; + int count = -1; + cbm_registry_find_by_name(r, simple, &out, &count); + ASSERT_EQ(count, 0); + } + static const char *const nameable[][2] = {{"create_app", "proj.web.create_app"}, + {"Inner", "proj.web.Outer.Inner"}, + {"g", "proj.ts.f.g"}}; + for (int i = 0; i < 3; i++) { + const char **out = NULL; + int count = 0; + cbm_registry_find_by_name(r, nameable[i][0], &out, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(out[0], nameable[i][1]); + } + ASSERT_FALSE(cbm_registry_qn_is_function_local(r, "proj.web.Outer.Inner", web)); + /* No module prefix: no local detection. */ + ASSERT_FALSE(cbm_registry_qn_is_function_local(r, "proj.web.create_app.index", 0)); + /* A weak strategy cannot reach the local; before, index() anywhere in + * the project resolved to it by unique_name. */ + cbm_resolution_t res = cbm_registry_resolve(r, "index", "proj.other", NULL, NULL, 0); + ASSERT_NULL(res.qualified_name); + cbm_registry_free(r); + PASS(); +} + +/* Python LEGB for bare names: the caller's own def first, then each enclosing + * def outward; a class body is not a scope its methods see; the module is left + * to the other strategies. A dotted callee is not a bare name. */ +TEST(registry_resolve_lexical_walks_enclosing_defs_issue1548) { + cbm_registry_t *r = cbm_registry_new(); + static const char *const defs[][3] = { + {"Tree", "proj.m.Tree", "Class"}, + {"visit", "proj.m.Tree.visit", "Method"}, + {"visit", "proj.m.Tree.visit.visit", "Function"}, + {"helper", "proj.m.Tree.helper", "Method"}, + {"factory", "proj.m.factory", "Function"}, + {"helper", "proj.m.factory.helper", "Function"}, + {"Local", "proj.m.factory.Local", "Class"}, + {"run", "proj.m.factory.Local.run", "Method"}, + {"top", "proj.m.top", "Function"}, + }; + for (size_t i = 0; i < sizeof(defs) / sizeof(defs[0]); i++) { + cbm_registry_add_lang_in_module(r, defs[i][0], defs[i][1], defs[i][2], CBM_LANG_PYTHON, + strlen("proj.m")); + } + + /* In method Tree.visit, `visit` is its nested def; so it is inside that + * nested def (its own recursion). */ + cbm_resolution_t res = cbm_registry_resolve_lexical(r, "visit", "proj.m.Tree.visit", "proj.m"); + ASSERT_STR_EQ(res.qualified_name, "proj.m.Tree.visit.visit"); + ASSERT_STR_EQ(res.strategy, "lexical_scope"); + res = cbm_registry_resolve_lexical(r, "visit", "proj.m.Tree.visit.visit", "proj.m"); + ASSERT_STR_EQ(res.qualified_name, "proj.m.Tree.visit.visit"); + /* The class body is skipped: Tree.helper is not a bare name in a method. */ + res = cbm_registry_resolve_lexical(r, "helper", "proj.m.Tree.visit.visit", "proj.m"); + ASSERT_NULL(res.qualified_name); + /* A method of a local class sees the def enclosing its class. */ + res = cbm_registry_resolve_lexical(r, "helper", "proj.m.factory.Local.run", "proj.m"); + ASSERT_STR_EQ(res.qualified_name, "proj.m.factory.helper"); + res = cbm_registry_resolve_lexical(r, "Local", "proj.m.factory", "proj.m"); + ASSERT_STR_EQ(res.qualified_name, "proj.m.factory.Local"); + /* Module-level names and dotted callees are not this strategy's. */ + res = cbm_registry_resolve_lexical(r, "top", "proj.m.factory", "proj.m"); + ASSERT_NULL(res.qualified_name); + res = cbm_registry_resolve_lexical(r, "self.helper", "proj.m.factory", "proj.m"); + ASSERT_NULL(res.qualified_name); + res = cbm_registry_resolve_lexical(r, "helper", "proj.m", "proj.m"); + ASSERT_NULL(res.qualified_name); + cbm_registry_free(r); + PASS(); +} + +/* A package __init__.py function can share a submodule's QN: poetry's + * layouts/__init__.py `def layout` is proj.pkg.layout, the QN prefix of every + * symbol in layouts/layout.py. Bounded at its own file, class Layout and its + * methods are no locals of that function, in either registration order (full + * index: discovery order; incremental: node-id order), and the lexical walk of + * a caller in layout.py never enters the __init__ function. */ +TEST(registry_python_local_bound_at_own_file_issue1548) { + const size_t init_prefix = strlen("proj.pkg"); /* pkg/__init__.py */ + const size_t mod_prefix = strlen("proj.pkg.layout"); /* pkg/layout.py */ + for (int order = 0; order < 2; order++) { + cbm_registry_t *r = cbm_registry_new(); + if (order == 0) { + cbm_registry_add_lang_in_module(r, "layout", "proj.pkg.layout", "Function", + CBM_LANG_PYTHON, init_prefix); + } + cbm_registry_add_lang_in_module(r, "Layout", "proj.pkg.layout.Layout", "Class", + CBM_LANG_PYTHON, mod_prefix); + cbm_registry_add_lang_in_module(r, "create", "proj.pkg.layout.Layout.create", "Method", + CBM_LANG_PYTHON, mod_prefix); + cbm_registry_add_lang_in_module(r, "make", "proj.pkg.layout.make", "Function", + CBM_LANG_PYTHON, mod_prefix); + if (order == 1) { + cbm_registry_add_lang_in_module(r, "layout", "proj.pkg.layout", "Function", + CBM_LANG_PYTHON, init_prefix); + } + static const char *const nameable[][2] = {{"Layout", "proj.pkg.layout.Layout"}, + {"create", "proj.pkg.layout.Layout.create"}, + {"make", "proj.pkg.layout.make"}}; + for (size_t i = 0; i < sizeof(nameable) / sizeof(nameable[0]); i++) { + const char **out = NULL; + int count = 0; + cbm_registry_find_by_name(r, nameable[i][0], &out, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(out[0], nameable[i][1]); + ASSERT_FALSE(cbm_registry_qn_is_function_local(r, nameable[i][1], mod_prefix)); + } + /* `Layout()` in layout.py's make(): no lexical local; module strategies own it. */ + cbm_resolution_t res = + cbm_registry_resolve_lexical(r, "Layout", "proj.pkg.layout.make", "proj.pkg.layout"); + ASSERT_NULL(res.qualified_name); + cbm_registry_free(r); + } + PASS(); +} + +/* The prefix every def of a file carries, without allocating; the same as + * cbm_pipeline_fqn_compute(project, path, name) minus ".name". */ +TEST(fqn_symbol_prefix_len_matches_fqn_compute) { + static const char *const paths[] = {"pkg/layout.py", "pkg/__init__.py", "a.py", + "deep/x/y/mod.py", "web/index.py", "noext"}; + for (size_t i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) { + char *qn = cbm_pipeline_fqn_compute("proj", paths[i], "sym"); + ASSERT_NOT_NULL(qn); + size_t want = strlen(qn) - strlen(".sym"); + free(qn); + ASSERT_EQ(cbm_pipeline_fqn_symbol_prefix_len("proj", paths[i]), want); + } + ASSERT_EQ(cbm_pipeline_fqn_symbol_prefix_len("proj", "pkg/__init__.py"), strlen("proj.pkg")); + ASSERT_EQ(cbm_pipeline_fqn_symbol_prefix_len(NULL, "a.py"), 0); + PASS(); +} + SUITE(registry) { RUN_TEST(python_symbol_scope_len_preserves_file_identity); RUN_TEST(registry_python_package_scope_is_explicit); @@ -2069,4 +2235,8 @@ SUITE(registry) { RUN_TEST(python_import_binding_contradicts_only_foreign_chains); RUN_TEST(python_external_base_contradicts_django_shapes); RUN_TEST(weak_call_guards_share_one_drop_list); + RUN_TEST(registry_python_function_local_is_exact_only_issue1548); + RUN_TEST(registry_resolve_lexical_walks_enclosing_defs_issue1548); + RUN_TEST(registry_python_local_bound_at_own_file_issue1548); + RUN_TEST(fqn_symbol_prefix_len_matches_fqn_compute); } 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"};