mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
0210ea9794
commit
214a9ac843
2 files changed
+135
-11
No files matched your search
+66
-11
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user