diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 6c89ac01f..2464a6891 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -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. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 1e2b2c85e..4aa00b9f8 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -1430,6 +1430,74 @@ TEST(rubylsp_mixin_walk_visits_each_module_once) { PASS(); } +/* `class Probe; def run; S0::S1::...::S.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); }