fix(lsp): evaluate operator and receiver chains iteratively

The depth guard of the C/C++ and Python expression evaluators
(C_EVAL_DEPTH_LIMIT, PY_LSP_MAX_EVAL_DEPTH = 256) now reports a sticky
per-file failure that stops the walk and drops every LSP resolution of the
file. Both evaluators recursed once per chain link, so a 300-term sum or a
C++ fluent chain of ~130 calls counted as 256 levels of nesting: one long
expression cost its whole file every LSP call edge. A chain's length is not
nesting, and the guard must not decide graph content for it.

c_eval_expr_type and py_eval_expr_type now first walk down the chain
operands below the node (the operand the evaluator evaluates first and
derives the link's type from), then evaluate them innermost first at the
caller's depth, keeping pending links on a small arena-owned stack in the
context that nested evaluations share. 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. Every node is still evaluated by the
unchanged inner evaluator, evaluation does not mutate scope within one
outermost call, and only operands the recursion itself evaluates are
pre-evaluated, so the types are exactly the recursive ones.

No longer counted as depth (any length):
- C/C++: binary operators other than == != < > <= >= && || (those yield
  bool without evaluating an operand), member access (. and ->), calls on
  a non-name callee, subscripts, conditional consequences, parentheses,
  comma lists.
- Python: binary operators, attribute access, calls on obj.m(...), f()()
  and (expr)(), subscripts, the else branch of a conditional, parentheses.

Still counted, unchanged guard and reporting (genuine nesting):
- C/C++: call arguments used for template argument deduction,
  std::move/std::forward operands, unary, pointer and update operands,
  assignment left sides, lambda return expressions, co_await, fold and
  _Generic operands.
- Python: tuple/list/set elements, dictionary keys and values, the true
  branch of a conditional, await operands, next()/iter()/assert_type()
  arguments, called lambdas.

Proof:
- New c_lsp and py_lsp tests build chains of 300, 2,000 and 20,000 terms
  (a binary + chain and a .a() receiver chain) next to an unrelated s.m().
  On the previous evaluator, and with this change reverted, all four fail
  at 300 terms: the depth error is recorded and the file resolves 0 calls
  (C++ per-file; Python per-file and through the cross-file dispatch).
  With it, every size records no error, resolves run -> S.m, and the
  chain's result type resolves x.done() / the final .done().
- Real input, isolated cache, before and after:
  redis 4f20cb48 (C): 36,876 CALLS, 10,699 LSP-resolved;
  django fb113765 (Python): 60,123 CALLS, 25,153 LSP-resolved.
  Identical CALLS edge sets on both (0 lost, 0 gained, 0 strategy
  changes), 0 files with an LSP error before and after, wall time
  unchanged (redis 5.5 s, django 9.1-10.2 s both ways).

Refs #1527

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-12 21:29:13 +02:00
1 parent c944c80ce9
commit e9e72d093c
6 files changed
+508 -30

No files matched your search

+165 -14
View File
@@ -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);
+6
View File
@@ -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
+132 -16
View File
@@ -16,6 +16,7 @@
#include "lsp_node_iter.h"
#include "tree_sitter/api.h"
#include <ctype.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@@ -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);
+6
View File
@@ -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
+70
View File
@@ -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);
+129
View File
@@ -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);