diff --git a/internal/cbm/lsp/c_lsp.c b/internal/cbm/lsp/c_lsp.c index 1e6068194..f0e5861f1 100644 --- a/internal/cbm/lsp/c_lsp.c +++ b/internal/cbm/lsp/c_lsp.c @@ -1564,7 +1564,10 @@ static const char *type_to_qn(const CBMType *t) { static const CBMType *c_eval_expr_type_inner(CLSPContext *ctx, TSNode node); -/* Stack guard: the evaluator recurses once per expression nesting level. +/* Stack guard: the evaluator recurses once per expression NESTING level (a + * call argument, a lambda body, an operand outside a chain). The links of a + * left-associative chain are not nesting and do not count against it; see + * c_eval_chain_operands. * There is deliberately NO per-file work budget (#1527): the 10000-step cap * that used to sit here (#323) left every later expression in a large file * unresolved while the file still reported status `indexed`. What it was @@ -1572,7 +1575,7 @@ static const CBMType *c_eval_expr_type_inner(CLSPContext *ctx, TSNode node); * evaluations (the Bitcoin versionbits hang) -- is removed at the root by the * per-evaluation memo below. */ #define C_EVAL_DEPTH_LIMIT 256 -enum { C_EVAL_MEMO_INITIAL_CAP = 64 }; +enum { C_EVAL_MEMO_INITIAL_CAP = 64, C_EVAL_CHAIN_INITIAL_CAP = 64 }; static uint32_t c_eval_memo_hash(const void *id) { uintptr_t v = (uintptr_t)id >> 3; /* node structs are >=8-byte aligned */ @@ -1657,21 +1660,126 @@ static bool c_eval_memo_insert(CLSPContext *ctx, const void *id, const CBMType * return true; } -const CBMType *c_eval_expr_type(CLSPContext *ctx, TSNode node) { - if (ctx->eval_failure || ts_node_is_null(node)) - return cbm_type_unknown(); - if (ctx->eval_depth > C_EVAL_DEPTH_LIMIT) { - ctx->eval_truncations++; - ctx->eval_failure = CBM_LSP_DEPTH_EXCEEDED; - return cbm_type_unknown(); +/* --- Left-associative chains are evaluated iteratively ------------------ + * A chain's length is not nesting. `a + b + ... + z`, `x.a().b()...`, + * `p->f->g`, `m[i][j]`, consequence-nested `c ? (d ? ..) : e`, runs of + * parentheses and comma lists are left-deep trees in which every link first + * evaluates ONE operand and derives its own type from that operand's type. + * Recursing through them spent native stack and a C_EVAL_DEPTH_LIMIT unit per + * link, so a 300-term sum exceeded the guard and rolled back the whole file. + * + * c_eval_expr_type therefore first walks down the chain operands below the + * node, then evaluates them innermost first, each at the caller's depth. When + * a link is evaluated, its chain operand is already memoized, so the + * evaluator's own recursive call into it is a memo hit one frame deep. The + * types are exactly the recursive ones: every node is still evaluated by + * c_eval_expr_type_inner, evaluation never mutates scope or context within + * one outermost call (the memo relies on the same fact), and only an operand + * the inner evaluator evaluates first and unconditionally is pre-evaluated, + * so no node is evaluated that the recursion would not have evaluated. + * Operands in any other position (call arguments, lambda bodies, std::move + * operands, unary operands, ...) are genuine nesting and still count depth. */ + +/* A binary operator whose result is bool: c_eval_expr_type_inner returns + * bool for it WITHOUT evaluating the left operand. Mirrors its operator test + * (the first anonymous child's source text). */ +static bool c_binary_yields_bool(const CLSPContext *ctx, TSNode node) { + static const char *const bool_ops[] = {"==", "!=", "<", ">", "<=", ">=", "&&", "||"}; + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode child = ts_node_child(node, i); + if (ts_node_is_named(child)) { + continue; + } + uint32_t start = ts_node_start_byte(child); + uint32_t end = ts_node_end_byte(child); + if (end > (uint32_t)ctx->source_len) { + return true; /* unreadable operator: do not pre-evaluate */ + } + size_t len = end > start ? (size_t)(end - start) : 0; + for (size_t k = 0; k < sizeof(bool_ops) / sizeof(bool_ops[0]); k++) { + if (strlen(bool_ops[k]) == len && memcmp(ctx->source + start, bool_ops[k], len) == 0) { + return true; + } + } + return false; } - if (ctx->eval_depth == 0) { - c_eval_memo_reset(ctx); /* scope may have changed since the last evaluation */ + return false; +} + +/* The chain operand of a link: the operand c_eval_expr_type_inner evaluates + * first and unconditionally, whose type the link's type is derived from. A + * null node when `node` is not a chain link. */ +static TSNode c_chain_operand(const CLSPContext *ctx, TSNode node) { + const TSNode none = {0}; + const char *kind = ts_node_type(node); + if (strcmp(kind, "field_expression") == 0) { + if (ts_node_is_null(ts_node_child_by_field_name(node, "field", 5))) { + return none; + } + return ts_node_child_by_field_name(node, "argument", 8); } - const CBMType *cached = c_eval_memo_lookup(ctx, node.id); - if (cached) { - return cached; + if (strcmp(kind, "call_expression") == 0) { + TSNode fn = ts_node_child_by_field_name(node, "function", 8); + if (ts_node_is_null(fn)) { + return none; + } + /* A callee name is a leaf, and std::move/std::forward evaluate their + * argument instead of the callee: neither continues a chain. */ + const char *fk = ts_node_type(fn); + if (strcmp(fk, "identifier") == 0 || strcmp(fk, "qualified_identifier") == 0 || + strcmp(fk, "scoped_identifier") == 0 || strcmp(fk, "template_function") == 0) { + return none; + } + return fn; } + if (strcmp(kind, "subscript_expression") == 0) { + return ts_node_child_by_field_name(node, "argument", 8); + } + if (strcmp(kind, "binary_expression") == 0) { + return c_binary_yields_bool(ctx, node) ? none + : ts_node_child_by_field_name(node, "left", 4); + } + if (strcmp(kind, "conditional_expression") == 0) { + return ts_node_child_by_field_name(node, "consequence", 11); + } + if (strcmp(kind, "parenthesized_expression") == 0) { + return ts_node_named_child_count(node) > 0 ? ts_node_named_child(node, 0) : none; + } + if (strcmp(kind, "comma_expression") == 0) { + return ts_node_child_by_field_name(node, "right", 5); + } + return none; +} + +/* Push a pending link. On allocation failure the caller stops collecting: + * the rest of the chain is then evaluated recursively, under the depth + * guard, exactly as before -- slower to fail, never a wrong type. */ +static bool c_eval_chain_push(CLSPContext *ctx, TSNode link) { + if (ctx->eval_chain_len == ctx->eval_chain_cap) { + if (ctx->eval_chain_cap > INT_MAX / 2) { + return false; + } + int new_cap = ctx->eval_chain_cap ? ctx->eval_chain_cap * 2 : C_EVAL_CHAIN_INITIAL_CAP; + if ((size_t)new_cap > SIZE_MAX / sizeof(TSNode)) { + return false; + } + TSNode *grown = cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(TSNode)); + if (!grown) { + return false; + } + if (ctx->eval_chain_len > 0) { + memcpy(grown, ctx->eval_chain, (size_t)ctx->eval_chain_len * sizeof(TSNode)); + } + ctx->eval_chain = grown; + ctx->eval_chain_cap = new_cap; + } + ctx->eval_chain[ctx->eval_chain_len++] = link; + return true; +} + +/* One real evaluation of a node that has no memo entry. */ +static const CBMType *c_eval_expr_type_node(CLSPContext *ctx, TSNode node) { CBM_LSP_WORK(1); /* one real evaluation (complexity tests) */ uint32_t trunc_before = ctx->eval_truncations; ctx->eval_depth++; @@ -1689,6 +1797,49 @@ const CBMType *c_eval_expr_type(CLSPContext *ctx, TSNode node) { return ctx->eval_failure ? cbm_type_unknown() : result; } +/* Evaluate the not-yet-memoized chain operands below `node`, innermost first. + * Nested evaluations push above the links still pending, so the stack is + * shared; a link's index is re-read after each evaluation (it may grow). */ +static void c_eval_chain_operands(CLSPContext *ctx, TSNode node) { + int base = ctx->eval_chain_len; + for (TSNode op = c_chain_operand(ctx, node); !ts_node_is_null(op); + op = c_chain_operand(ctx, op)) { + if (c_eval_memo_lookup(ctx, op.id) || !c_eval_chain_push(ctx, op)) { + break; + } + } + for (int i = ctx->eval_chain_len - 1; i >= base && !ctx->eval_failure; i--) { + TSNode link = ctx->eval_chain[i]; + ctx->eval_chain_len = i; + if (!c_eval_memo_lookup(ctx, link.id)) { + (void)c_eval_expr_type_node(ctx, link); + } + } + ctx->eval_chain_len = base; +} + +const CBMType *c_eval_expr_type(CLSPContext *ctx, TSNode node) { + if (ctx->eval_failure || ts_node_is_null(node)) + return cbm_type_unknown(); + if (ctx->eval_depth > C_EVAL_DEPTH_LIMIT) { + ctx->eval_truncations++; + ctx->eval_failure = CBM_LSP_DEPTH_EXCEEDED; + return cbm_type_unknown(); + } + if (ctx->eval_depth == 0) { + c_eval_memo_reset(ctx); /* scope may have changed since the last evaluation */ + } + const CBMType *cached = c_eval_memo_lookup(ctx, node.id); + if (cached) { + return cached; + } + c_eval_chain_operands(ctx, node); + if (ctx->eval_failure) { + return cbm_type_unknown(); + } + return c_eval_expr_type_node(ctx, node); +} + static const CBMType *c_eval_expr_type_inner(CLSPContext *ctx, TSNode node) { const char *kind = ts_node_type(node); diff --git a/internal/cbm/lsp/c_lsp.h b/internal/cbm/lsp/c_lsp.h index e656c9872..61289715f 100644 --- a/internal/cbm/lsp/c_lsp.h +++ b/internal/cbm/lsp/c_lsp.h @@ -132,6 +132,12 @@ typedef struct { int *eval_memo_used; // slots filled by the current evaluation int eval_memo_cap; // power of two, or 0 int eval_memo_count; + // Pending links of the left-associative chains being evaluated + // iteratively (operator, receiver, subscript, conditional, parenthesis and + // comma chains): one stack shared by nested evaluations, arena-owned. + TSNode *eval_chain; + int eval_chain_len; + int eval_chain_cap; CBMLSPStatus eval_failure; // sticky: stop refinement rather than repeat uncached work #ifdef CBM_ENABLE_TEST_SEAMS int test_memo_allocations_left; // -1 = normal; 0 = fail the next memo allocation diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index a8301b0ef..61fe89b2d 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -16,6 +16,7 @@ #include "lsp_node_iter.h" #include "tree_sitter/api.h" #include +#include #include #include #include @@ -27,6 +28,9 @@ #include "py_builtins.c" /* Stack guard for py_eval_expr_type (#720; same limit as C_EVAL_DEPTH_LIMIT). + * It counts expression NESTING (call arguments, container elements, operands + * outside a chain); the links of a left-associative chain do not count, see + * py_eval_chain_operands. * There is deliberately NO per-file work budget (#1527): the step cap that * used to sit here stopped resolution after 10000 evaluations and left the * rest of a large file silently unresolved while the file still reported @@ -2250,26 +2254,93 @@ static bool py_type_cache_insert(PyLSPContext *ctx, const void *id, const CBMTyp return false; } -/* Memoizing, depth-guarded wrapper — the function every call site in this - * file goes through. */ -static const CBMType *py_eval_expr_type(PyLSPContext *ctx, TSNode node) { - if (!ctx || ctx->eval_failure || ts_node_is_null(node)) - return cbm_type_unknown(); +/* ── Left-associative chains are evaluated iteratively ─────────────────── + * + * A chain's length is not nesting. `a + b + ... + z`, `x.a().b()...`, + * `x.f.g.h`, `m[i][j]`, `a if p else b if q else ...` and runs of + * parentheses are trees in which every link derives its type from ONE chain + * operand. Recursing through them spent native stack and a + * PY_LSP_MAX_EVAL_DEPTH unit per link, so a 300-term sum exceeded the guard + * and rolled back the whole file. + * + * py_eval_expr_type therefore first walks down the chain operands below the + * node, then evaluates them innermost first, each at the caller's depth. When + * a link is evaluated, its chain operand is already cached, so the + * evaluator's own recursive call into it is a cache hit one frame deep. The + * types are exactly the recursive ones: every node is still evaluated by + * py_eval_expr_type_uncached, the cache is generation-checked (evaluation + * never binds or restores scope, and if it did a stale entry would simply + * miss and recurse), and only an operand the uncached evaluator always + * evaluates is pre-evaluated, so no node is evaluated that the recursion + * would not have evaluated. Operands in any other position (call arguments, + * container elements, dictionary keys and values, await operands, ...) are + * genuine nesting and still count depth. */ - /* Depth cap (issue #720): the evaluator recurses once per expression - * nesting level, so a pathologically deep expression (tens of - * thousands of parens) overflowed the native stack. At the existing bound, - * stop this walk explicitly; never memoize a truncated result as complete. */ - if (ctx->eval_depth >= PY_LSP_MAX_EVAL_DEPTH) { - ctx->eval_truncations++; - ctx->eval_failure = CBM_LSP_DEPTH_EXCEEDED; - return cbm_type_unknown(); +/* The chain operand of a link: an operand py_eval_expr_type_uncached always + * evaluates and derives the link's type from. A null node when `node` is not + * a chain link. */ +static TSNode py_chain_operand(TSNode node) { + const TSNode none = {0}; + const char *k = ts_node_type(node); + if (strcmp(k, "attribute") == 0) { + if (ts_node_is_null(ts_node_child_by_field_name(node, "attribute", 9))) + return none; + return ts_node_child_by_field_name(node, "object", 6); } + if (strcmp(k, "call") == 0) { + TSNode fn = ts_node_child_by_field_name(node, "function", 8); + if (ts_node_is_null(fn)) + return none; + const char *fk = ts_node_type(fn); + /* `obj.m(...)` evaluates obj, never the attribute node itself. */ + if (strcmp(fk, "attribute") == 0) { + if (ts_node_is_null(ts_node_child_by_field_name(fn, "attribute", 9))) + return none; + return ts_node_child_by_field_name(fn, "object", 6); + } + /* `f()()` / `(expr)()`: the callee expression is evaluated. */ + if (strcmp(fk, "call") == 0 || strcmp(fk, "parenthesized_expression") == 0) + return fn; + return none; + } + if (strcmp(k, "subscript") == 0) + return ts_node_child_by_field_name(node, "value", 5); + if (strcmp(k, "binary_operator") == 0) + return ts_node_child_by_field_name(node, "left", 4); + if (strcmp(k, "conditional_expression") == 0) { + /* Both branches are evaluated; a conditional chain continues in the + * else branch: `a if p else (b if q else ...)`. */ + return ts_node_named_child_count(node) >= 3 ? ts_node_named_child(node, 2) : none; + } + if (strcmp(k, "parenthesized_expression") == 0) + return ts_node_named_child_count(node) > 0 ? ts_node_named_child(node, 0) : none; + return none; +} - const CBMType *cached = py_type_cache_lookup(ctx, node.id); - if (cached) - return cached; +/* Push a pending link. On allocation failure the caller stops collecting: + * the rest of the chain is then evaluated recursively, under the depth + * guard, exactly as before -- slower to fail, never a wrong type. */ +static bool py_eval_chain_push(PyLSPContext *ctx, TSNode link) { + if (ctx->eval_chain_len == ctx->eval_chain_cap) { + if (ctx->eval_chain_cap > INT_MAX / 2) + return false; + int new_cap = ctx->eval_chain_cap ? ctx->eval_chain_cap * 2 : 64; + if ((size_t)new_cap > SIZE_MAX / sizeof(TSNode)) + return false; + TSNode *grown = (TSNode *)cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(TSNode)); + if (!grown) + return false; + if (ctx->eval_chain_len > 0) + memcpy(grown, ctx->eval_chain, (size_t)ctx->eval_chain_len * sizeof(TSNode)); + ctx->eval_chain = grown; + ctx->eval_chain_cap = new_cap; + } + ctx->eval_chain[ctx->eval_chain_len++] = link; + return true; +} +/* One real evaluation of a node that has no live cache entry. */ +static const CBMType *py_eval_expr_type_node(PyLSPContext *ctx, TSNode node) { /* One real (non-memoized) evaluation: the work the complexity tests count * to prove evaluation stays linear without a budget. */ CBM_LSP_WORK(1); @@ -2291,6 +2362,51 @@ static const CBMType *py_eval_expr_type(PyLSPContext *ctx, TSNode node) { return ctx->eval_failure ? cbm_type_unknown() : result; } +/* Evaluate the uncached chain operands below `node`, innermost first. + * Nested evaluations push above the links still pending, so the stack is + * shared; a link's index is re-read after each evaluation (it may grow). */ +static void py_eval_chain_operands(PyLSPContext *ctx, TSNode node) { + int base = ctx->eval_chain_len; + for (TSNode op = py_chain_operand(node); !ts_node_is_null(op); op = py_chain_operand(op)) { + if (py_type_cache_lookup(ctx, op.id) || !py_eval_chain_push(ctx, op)) + break; + } + for (int i = ctx->eval_chain_len - 1; i >= base && !ctx->eval_failure; i--) { + TSNode link = ctx->eval_chain[i]; + ctx->eval_chain_len = i; + if (!py_type_cache_lookup(ctx, link.id)) + (void)py_eval_expr_type_node(ctx, link); + } + ctx->eval_chain_len = base; +} + +/* Memoizing, depth-guarded wrapper — the function every call site in this + * file goes through. */ +static const CBMType *py_eval_expr_type(PyLSPContext *ctx, TSNode node) { + if (!ctx || ctx->eval_failure || ts_node_is_null(node)) + return cbm_type_unknown(); + + /* Depth cap (issue #720): the evaluator recurses once per expression + * nesting level, so a pathologically deep expression (tens of + * thousands of nested brackets) overflowed the native stack. At the + * existing bound, stop this walk explicitly; never memoize a truncated + * result as complete. Chain links do not nest (see above). */ + if (ctx->eval_depth >= PY_LSP_MAX_EVAL_DEPTH) { + ctx->eval_truncations++; + ctx->eval_failure = CBM_LSP_DEPTH_EXCEEDED; + return cbm_type_unknown(); + } + + const CBMType *cached = py_type_cache_lookup(ctx, node.id); + if (cached) + return cached; + + py_eval_chain_operands(ctx, node); + if (ctx->eval_failure) + return cbm_type_unknown(); + return py_eval_expr_type_node(ctx, node); +} + #ifdef CBM_ENABLE_TEST_SEAMS const CBMType *cbm_py_lsp_test_eval(PyLSPContext *ctx, TSNode node) { return py_eval_expr_type(ctx, node); diff --git a/internal/cbm/lsp/py_lsp.h b/internal/cbm/lsp/py_lsp.h index e7536b8f9..908406ca8 100644 --- a/internal/cbm/lsp/py_lsp.h +++ b/internal/cbm/lsp/py_lsp.h @@ -103,6 +103,12 @@ typedef struct { int eval_depth; // evaluator recursion depth (PY_LSP_MAX_EVAL_DEPTH) uint32_t eval_truncations; // depth-cap cutoff count — gates memo inserts CBMLSPStatus eval_failure; // sticky: memo/depth failure makes this walk incomplete + // Pending links of the left-associative chains being evaluated + // iteratively (operator, receiver, subscript, conditional and + // parenthesis chains): one stack shared by nested evaluations. + TSNode *eval_chain; // arena-allocated + int eval_chain_len; + int eval_chain_cap; #ifdef CBM_ENABLE_TEST_SEAMS int test_memo_allocations_left; int test_root_name_failure; // 1 = NULL, 2 = empty; only the root-name collection diff --git a/tests/test_c_lsp.c b/tests/test_c_lsp.c index 1b60c9e90..2f6bcfab0 100644 --- a/tests/test_c_lsp.c +++ b/tests/test_c_lsp.c @@ -16899,6 +16899,74 @@ TEST(clsp_issue1527_cross_file_memo_failures_propagate) { PASS(); } +/* A chain's LENGTH is not nesting depth (#1527 follow-up). An operator chain + * `a + a + ... + a` and a receiver chain `b.a().a()...` are evaluated link by + * link, so neither consumes the evaluator's depth guard: the file keeps its + * LSP resolution (an unrelated call in another function still resolves), no + * file-level failure is recorded, and the chain's result type still drives + * the call made on it. Before the iterative evaluation, 300 terms already + * exceeded the guard and rolled back every resolution of the file. */ +static const int clsp_long_chain_sizes[] = {300, 2000, 20000}; + +static char *clsp_long_chain_source(bool receiver, int links) { + size_t sz = (size_t)links * 8 + 512; + char *src = malloc(sz); + if (!src) + return NULL; + size_t pos = (size_t)snprintf(src, sz, + "struct V { void done() {} };\n" + "struct B { B& a() { return *this; } void done() {} };\n" + "struct S { int m() { return 1; } };\n"); + if (receiver) { + pos += (size_t)snprintf(src + pos, sz - pos, "void chain(B b) {\n b"); + for (int i = 0; i < links; i++) + pos += (size_t)snprintf(src + pos, sz - pos, ".a()"); + pos += (size_t)snprintf(src + pos, sz - pos, ".done();\n}\n"); + } else { + pos += (size_t)snprintf(src + pos, sz - pos, "void deep(V a) {\n auto x = a"); + for (int i = 1; i < links; i++) + pos += (size_t)snprintf(src + pos, sz - pos, " + a"); + pos += (size_t)snprintf(src + pos, sz - pos, ";\n x.done();\n}\n"); + } + snprintf(src + pos, sz - pos, "void run() { S s; s.m(); }\n"); + return src; +} + +static int clsp_long_chain_case(bool receiver) { + int sizes = (int)(sizeof(clsp_long_chain_sizes) / sizeof(clsp_long_chain_sizes[0])); + for (int i = 0; i < sizes; i++) { + int links = clsp_long_chain_sizes[i]; + char *src = clsp_long_chain_source(receiver, links); + ASSERT_NOT_NULL(src); + (void)cbm_lsp_work_take(); + CBMFileResult *r = extract_cpp(src); + uint64_t work = cbm_lsp_work_take(); + free(src); + ASSERT_NOT_NULL(r); + bool no_error = !r->has_error && !r->lsp_skipped; + bool unrelated = find_resolved(r, "run", "S.m") >= 0; + bool typed = receiver ? find_resolved(r, "chain", "B.done") >= 0 + : find_resolved(r, "deep", "V.done") >= 0; + printf(" %s chain of %d: error=%d run->S.m=%d %s=%d resolved=%d work=%llu\n", + receiver ? "receiver" : "binary", links, (int)r->has_error, (int)unrelated, + receiver ? "B.done" : "V.done", (int)typed, r->resolved_calls.count, + (unsigned long long)work); + cbm_free_result(r); + ASSERT_TRUE(no_error); + ASSERT_TRUE(unrelated); + ASSERT_TRUE(typed); + } + PASS(); +} + +TEST(clsp_issue1527_long_binary_chain_is_not_nesting) { + return clsp_long_chain_case(false); +} + +TEST(clsp_issue1527_long_receiver_chain_is_not_nesting) { + return clsp_long_chain_case(true); +} + TEST(registry_issue1527_index_preserves_registration_order) { CBMArena a; cbm_arena_init(&a); @@ -17011,6 +17079,8 @@ SUITE(c_lsp) { RUN_TEST(clsp_issue1527_preprocessed_depth_failure_is_reported); RUN_TEST(registry_issue1527_index_preserves_registration_order); RUN_TEST(clsp_issue1527_cross_file_memo_failures_propagate); + RUN_TEST(clsp_issue1527_long_binary_chain_is_not_nesting); + RUN_TEST(clsp_issue1527_long_receiver_chain_is_not_nesting); RUN_TEST(clsp_c_reassigned_function_pointer_calls_join_exact_occurrences); RUN_TEST(clsp_c_nested_function_pointer_shadow_restores_outer_target); RUN_TEST(clsp_cpp_repeated_same_leaf_calls_join_exact_occurrences); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 35dfe347b..e06adab72 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -2486,6 +2486,133 @@ TEST(pylsp_issue1527_memo_and_depth_failures_stop_work) { PASS(); } +/* A chain's LENGTH is not nesting depth (#1527 follow-up). An operator chain + * `a + a + ... + a` and a receiver chain `b.a().a()...` are evaluated link by + * link, so neither consumes the evaluator's depth guard: the file keeps its + * LSP resolution (an unrelated call in another function still resolves), no + * file-level failure is recorded, and the chain's result type still drives + * the call made on it. Before the iterative evaluation, 300 terms already + * exceeded the guard and rolled back every resolution of the file. */ +static const int pylsp_long_chain_sizes[] = {300, 2000, 20000}; + +static char *pylsp_long_chain_source(bool receiver, int links) { + size_t sz = (size_t)links * 8 + 512; + char *src = malloc(sz); + if (!src) + return NULL; + size_t pos = (size_t)snprintf(src, sz, + "from typing import Self\n" + "class V:\n" + " def done(self):\n" + " return 1\n" + "class B:\n" + " def a(self) -> Self:\n" + " return self\n" + " def done(self):\n" + " return 1\n" + "class S:\n" + " def m(self):\n" + " return 1\n"); + if (receiver) { + pos += (size_t)snprintf(src + pos, sz - pos, "def chain(b: B):\n b"); + for (int i = 0; i < links; i++) + pos += (size_t)snprintf(src + pos, sz - pos, ".a()"); + pos += (size_t)snprintf(src + pos, sz - pos, ".done()\n"); + } else { + pos += (size_t)snprintf(src + pos, sz - pos, "def deep(a: V):\n x = a"); + for (int i = 1; i < links; i++) + pos += (size_t)snprintf(src + pos, sz - pos, " + a"); + pos += (size_t)snprintf(src + pos, sz - pos, "\n x.done()\n"); + } + snprintf(src + pos, sz - pos, "def run():\n s = S()\n s.m()\n"); + return src; +} + +/* The full per-file extraction runs at the smallest size only: its Python + * usage pass climbs every identifier's ancestors with ts_node_parent, itself + * O(depth), which is cubic in one deep expression -- a separate cost outside + * the LSP. The cross-file LSP dispatch the pipeline uses (it records the + * same file-level error) runs at every size. */ +static int pylsp_long_chain_case(bool receiver) { + CBMLSPDef defs[] = { + {.qualified_name = "test.V", .short_name = "V", .label = "Class", .lang = CBM_LANG_PYTHON}, + {.qualified_name = "test.V.done", + .short_name = "done", + .label = "Method", + .receiver_type = "test.V", + .return_types = "int", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "test.B", .short_name = "B", .label = "Class", .lang = CBM_LANG_PYTHON}, + {.qualified_name = "test.B.a", + .short_name = "a", + .label = "Method", + .receiver_type = "test.B", + .return_types = "test.B", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "test.B.done", + .short_name = "done", + .label = "Method", + .receiver_type = "test.B", + .return_types = "int", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "test.S", .short_name = "S", .label = "Class", .lang = CBM_LANG_PYTHON}, + {.qualified_name = "test.S.m", + .short_name = "m", + .label = "Method", + .receiver_type = "test.S", + .return_types = "int", + .lang = CBM_LANG_PYTHON}, + }; + int def_count = (int)(sizeof(defs) / sizeof(defs[0])); + const char *chain_fn = receiver ? "chain" : "deep"; + const char *done = receiver ? "B.done" : "V.done"; + int sizes = (int)(sizeof(pylsp_long_chain_sizes) / sizeof(pylsp_long_chain_sizes[0])); + for (int i = 0; i < sizes; i++) { + int links = pylsp_long_chain_sizes[i]; + char *src = pylsp_long_chain_source(receiver, links); + ASSERT_NOT_NULL(src); + bool per_file_ok = true; + if (i == 0) { + CBMFileResult *r = extract_py(src); + per_file_ok = r && !r->has_error && !r->lsp_skipped && + find_resolved(r, "run", "S.m") >= 0 && + find_resolved(r, chain_fn, done) >= 0; + printf(" per-file %s chain of %d: error=%d ok=%d\n", + receiver ? "receiver" : "binary", links, r ? (int)r->has_error : -1, + (int)per_file_ok); + if (r) + cbm_free_result(r); + } + CBMFileResult cross = {0}; + cbm_arena_init(&cross.arena); + (void)cbm_lsp_work_take(); + cbm_pxc_run_one(CBM_LANG_PYTHON, &cross, src, (int)strlen(src), "test", defs, def_count, + NULL, NULL, 0); + uint64_t work = cbm_lsp_work_take(); + free(src); + bool no_error = !cross.has_error && !cross.lsp_skipped; + bool unrelated = find_resolved(&cross, "run", "S.m") >= 0; + bool typed = find_resolved(&cross, chain_fn, done) >= 0; + printf(" cross %s chain of %d: error=%d run->S.m=%d %s=%d resolved=%d work=%llu\n", + receiver ? "receiver" : "binary", links, (int)cross.has_error, (int)unrelated, done, + (int)typed, cross.resolved_calls.count, (unsigned long long)work); + cbm_arena_destroy(&cross.arena); + ASSERT_TRUE(per_file_ok); + ASSERT_TRUE(no_error); + ASSERT_TRUE(unrelated); + ASSERT_TRUE(typed); + } + PASS(); +} + +TEST(pylsp_issue1527_long_binary_chain_is_not_nesting) { + return pylsp_long_chain_case(false); +} + +TEST(pylsp_issue1527_long_receiver_chain_is_not_nesting) { + return pylsp_long_chain_case(true); +} + TEST(pylsp_issue1527_root_class_name_failure_binds_no_external_class) { const char *src = "class Local:\n pass\n"; TSParser *parser = ts_parser_new(); @@ -2704,6 +2831,8 @@ SUITE(py_lsp) { RUN_TEST(pylsp_issue1527_root_class_name_failure_binds_no_external_class); RUN_TEST(pylsp_issue1527_memo_and_depth_failures_stop_work); RUN_TEST(pylsp_issue1527_memo_allocation_failure_is_reported); + RUN_TEST(pylsp_issue1527_long_binary_chain_is_not_nesting); + RUN_TEST(pylsp_issue1527_long_receiver_chain_is_not_nesting); RUN_TEST(pylsp_scale_work_is_linear); /* Phase 2 — smoke */ RUN_TEST(pylsp_smoke_empty);