fix(ruby-lsp): build constant paths in one pass

ruby_const_path recursed once per :: segment and re-formatted the growing
prefix at every level, so an A::B::C path cost one stack frame per segment
and quadratic bytes in the per-file arena. It now follows the scope fields
in a loop: one pass measures the segments, a second copies them right to
left into a single buffer. The result is the same string as before,
including a leading ::Foo and a scope that is not a constant path.

rubylsp_const_path_is_linear pins the arena bytes for an 800 vs a 1,600
segment path (before: 494,520 -> 1,660,520 bytes already at 400 -> 800
segments; after: 136,248 -> 150,848 at 800 -> 1,600) and the exact class
QN for a five-level path and for a rooted ::Top::Inner.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-11-01 21:17:28 +01:00
1 parent 0210ea9794
commit 214a9ac843
2 files changed
+135 -11

No files matched your search

+66 -11
View File
@@ -114,10 +114,52 @@ static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) {
return cbm_node_text(ctx->arena, node, ctx->source);
}
/* Byte length of a node's source text (0 for an empty node). */
static size_t ruby_node_len(TSNode node) {
uint32_t start = ts_node_start_byte(node);
uint32_t end = ts_node_end_byte(node);
return end > start ? (size_t)(end - start) : 0;
}
/* One step left along a constant path. `*cur` is a scope_resolution whose
* own segment has been taken; on success `*seg` is the next segment to its
* left and `*cur` the node to continue from. The path ends at a missing
* scope (`::Foo`), at a scope that is not a constant path (`expr::Foo`),
* and at an empty segment. A plain `constant` has no scope field, so it is
* always the last segment. */
static bool ruby_const_step(TSNode *cur, TSNode *seg) {
TSNode scope = ts_node_child_by_field_name(*cur, "scope", 5);
if (ts_node_is_null(scope))
return false;
const char *k = ts_node_type(scope);
if (strcmp(k, "constant") == 0) {
if (ruby_node_len(scope) == 0)
return false;
*seg = scope;
*cur = scope;
return true;
}
if (strcmp(k, "scope_resolution") != 0)
return false;
TSNode name = ts_node_child_by_field_name(scope, "name", 4);
if (ts_node_is_null(name) || ruby_node_len(name) == 0)
return false;
*seg = name;
*cur = scope;
return true;
}
/* Convert a constant expression node (constant | scope_resolution) into a
* dotted path ("Foo", "A.B.C"). Returns NULL for anything else. A leading
* "::Foo" (scope_resolution without a scope) yields "Foo" — the resolver's
* table probe covers the top-level meaning. */
* table probe covers the top-level meaning. A scope that is not itself a
* constant path (`expr::Foo`) contributes nothing.
*
* `A::B::C` nests to the LEFT (scope_resolution(scope_resolution(A, B), C)),
* so the path is read from the outermost node along the `scope` fields: one
* pass measures the segments, a second copies them right to left into one
* buffer. No recursion and no re-formatting of a growing prefix, so neither
* stack depth nor bytes grow faster than the path itself. */
static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) {
if (ts_node_is_null(node))
return NULL;
@@ -126,19 +168,32 @@ static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) {
return ruby_node_text(ctx, node);
if (strcmp(k, "scope_resolution") != 0)
return NULL;
TSNode scope = ts_node_child_by_field_name(node, "scope", 5);
TSNode name = ts_node_child_by_field_name(node, "name", 4);
if (ts_node_is_null(name))
if (ts_node_is_null(name) || ruby_node_len(name) == 0)
return NULL;
char *nm = ruby_node_text(ctx, name);
if (!nm || !nm[0])
size_t total = 0;
TSNode cur = node;
TSNode seg = name;
do {
total += ruby_node_len(seg) + 1; /* segment + its '.' (or the NUL) */
} while (ruby_const_step(&cur, &seg));
char *out = (char *)cbm_arena_alloc(ctx->arena, total);
if (!out)
return NULL;
if (ts_node_is_null(scope))
return nm; /* ::Foo */
const char *sp = ruby_const_path(ctx, scope);
if (!sp || !sp[0])
return nm;
return cbm_arena_sprintf(ctx->arena, "%s.%s", sp, nm);
size_t pos = total - 1;
out[pos] = '\0';
cur = node;
seg = name;
do {
size_t len = ruby_node_len(seg);
pos -= len;
memcpy(out + pos, ctx->source + ts_node_start_byte(seg), len);
if (pos > 0)
out[--pos] = '.';
} while (ruby_const_step(&cur, &seg));
return out;
}
/* Join two dotted path fragments; either side may be empty/NULL. */
+69
View File
@@ -1430,6 +1430,74 @@ TEST(rubylsp_mixin_walk_visits_each_module_once) {
PASS();
}
/* `class Probe; def run; S0::S1::...::S<n-1>.new; end; end` */
static char *rubylsp_deep_const_src(int n) {
size_t cap = (size_t)n * 12 + 128;
char *src = (char *)malloc(cap);
if (!src)
return NULL;
size_t off = (size_t)snprintf(src, cap, "class Probe\n def run\n ");
for (int i = 0; i < n; i++)
off += (size_t)snprintf(src + off, cap - off, i ? "::S%d" : "S%d", i);
(void)snprintf(src + off, cap - off, ".new\n end\nend\n");
return src;
}
static size_t rubylsp_deep_const_bytes(int n) {
char *src = rubylsp_deep_const_src(n);
if (!src)
return 0;
size_t bytes = 0;
(void)rubylsp_counted_work(src, &bytes);
free(src);
return bytes;
}
/* Review ask (b): a constant path is built in one pass. Recursing once per
* `::` and re-formatting the growing prefix at every level costs one stack
* frame per segment and quadratic bytes; the loop has no recursion and
* writes the path once. The positive control pins the exact class QN for a
* deep path and for a leading `::`. */
TEST(rubylsp_const_path_is_linear) {
size_t b_n = rubylsp_deep_const_bytes(800);
size_t b_2n = rubylsp_deep_const_bytes(1600);
printf(" const path arena bytes: n=800 %zu, n=1600 %zu\n", b_n, b_2n);
ASSERT(b_n > 0);
ASSERT(b_2n <= 3 * b_n);
const char *src = "module L0\n"
" module L1\n"
" module L2\n"
" module L3\n"
" module L4\n"
" class Leaf\n"
" end\n"
" end\n"
" end\n"
" end\n"
" end\n"
"end\n"
"class Top\n"
" class Inner\n"
" end\n"
"end\n"
"class Caller\n"
" def deep\n"
" L0::L1::L2::L3::L4::Leaf.new\n"
" end\n"
" def rooted\n"
" ::Top::Inner.new\n"
" end\n"
"end\n";
CBMFileResult *r = extract_ruby(src);
ASSERT(r);
ASSERT(require_resolved_exact(r, "test.main.Caller.deep", "test.main.L0.L1.L2.L3.L4.Leaf") >=
0);
ASSERT(require_resolved_exact(r, "test.main.Caller.rooted", "test.main.Top.Inner") >= 0);
cbm_free_result(r);
PASS();
}
/* ── suite ──────────────────────────────────────────────────────── */
void suite_ruby_lsp(void) {
@@ -1467,4 +1535,5 @@ void suite_ruby_lsp(void) {
RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge);
RUN_TEST(rubylsp_self_rebinding_blocks_no_edge);
RUN_TEST(rubylsp_mixin_walk_visits_each_module_once);
RUN_TEST(rubylsp_const_path_is_linear);
}