mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
The REG_* macros wrote through the return-type vector without checking the allocation, and registered entries whose formatted QN came back NULL. A shared ruby_seed_signature helper now leaves a failed vector out, so the signature types as unknown (the fallback ruby_register_method already uses), and REG_METHOD / REG_SMETHOD skip an entry whose formatted name is NULL, since a NULL QN would reach strcmp in finalize and in every lookup. rubylsp_stdlib_seed_survives_alloc_failure seeds a registry from an arena that cannot allocate. Before: UBSan reported a store to a null pointer and the run crashed with SEGV at ruby_stdlib_data.c:163. After: every registered entry carries a name, the registry finalizes, and the Kernel built-ins with literal names still resolve. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1567 lines
63 KiB
C
1567 lines
63 KiB
C
/*
|
|
* test_ruby_lsp.c — Tests for the Ruby Light Semantic Pass.
|
|
*
|
|
* Coverage mirrors tests/test_perl_lsp.c / tests/test_php_lsp.c, exercising
|
|
* the foundational Ruby resolution scenarios:
|
|
* 1. QN contract (defs carry module_qn.ClassPath.method — the join key
|
|
* the resolver's emitted rows depend on)
|
|
* 2. Constructor typing + edge (a = Animal.new; a.speak)
|
|
* 3. Constructor edge targets CLASS (textual `Widget.new` -> "Widget")
|
|
* 4. Implicit/explicit self dispatch
|
|
* 5. Singleton (class) methods (def self.m; Animal.m)
|
|
* 6. Superclass chain lookup (class B < A)
|
|
* 7. include + prepend mixin lookup (prepend shadows the class's own)
|
|
* 8. extend mixin (class-side) lookup
|
|
* 9. super dispatch
|
|
* 10. Instance-variable typing (@thing = Foo.new; @thing.bar)
|
|
* 11. Chained call typing (Foo.new.bar)
|
|
* 12. Nested modules (lexical nesting + `A::B` references)
|
|
* 12b. Compact `class A::B` names (exact extractor spelling, scope)
|
|
* 13. ActiveRecord model typing (User.find(1).full_name)
|
|
* 14. Top-level function calls
|
|
* 15. Unresolvable receiver emits NO spurious edge (negative)
|
|
* 15b. Scope gates (def, params, blocks, reassignment)
|
|
* 16. send() / the whole dynamic-dispatch family emits NO edge (negative)
|
|
* 16c. Blocks that rebind self emit NO self edge (negative)
|
|
* Fail-closed ancestry (7c) and block-nested defs (7d) sit with mixins.
|
|
*
|
|
* Scope: every row here is SINGLE-FILE, driven through cbm_extract_file so it
|
|
* exercises the per-file resolver exactly as the extraction pipeline calls it.
|
|
* Cross-file resolution (cbm_run_ruby_lsp_cross, wired through
|
|
* pass_lsp_cross.c) is a separate tier and is covered by its own rows.
|
|
*
|
|
* The resolver populates result->resolved_calls with CBMResolvedCall rows.
|
|
* The pipeline joins a row to its caller def by EXACT QN equality, so every
|
|
* row that pins a class-path spelling (nested or compact) asserts the full
|
|
* caller and callee QN with strcmp ("test"/"main.rb" -> module QN test.main).
|
|
* Substring helpers remain for rows where only presence/absence matters.
|
|
*/
|
|
#include "test_framework.h"
|
|
#include "cbm.h"
|
|
#include "../src/pipeline/lsp_resolve.h"
|
|
#include "lsp/ruby_lsp.h"
|
|
#include "lsp/lsp_work.h"
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* ── Helpers (mirror test_perl_lsp.c) ──────────────────────────── */
|
|
|
|
static CBMFileResult *extract_ruby(const char *source) {
|
|
return cbm_extract_file(source, (int)strlen(source), CBM_LANG_RUBY, "test", "main.rb", 0, NULL,
|
|
NULL);
|
|
}
|
|
|
|
static int find_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) {
|
|
for (int i = 0; i < r->resolved_calls.count; i++) {
|
|
const CBMResolvedCall *rc = &r->resolved_calls.items[i];
|
|
if (rc->caller_qn && strstr(rc->caller_qn, callerSub) && rc->callee_qn &&
|
|
strstr(rc->callee_qn, calleeSub))
|
|
return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) {
|
|
int idx = find_resolved(r, callerSub, calleeSub);
|
|
if (idx < 0) {
|
|
printf(" MISSING resolved call: caller~%s -> callee~%s (have %d)\n", callerSub, calleeSub,
|
|
r->resolved_calls.count);
|
|
for (int i = 0; i < r->resolved_calls.count; i++) {
|
|
const CBMResolvedCall *rc = &r->resolved_calls.items[i];
|
|
printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)",
|
|
rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)",
|
|
rc->confidence);
|
|
}
|
|
}
|
|
return idx;
|
|
}
|
|
|
|
static const CBMResolvedCall *find_resolved_with_strategy(const CBMFileResult *r,
|
|
const char *callerSub,
|
|
const char *calleeSub,
|
|
const char *strategy) {
|
|
for (int i = 0; i < r->resolved_calls.count; i++) {
|
|
const CBMResolvedCall *rc = &r->resolved_calls.items[i];
|
|
if (!rc->caller_qn || !rc->callee_qn)
|
|
continue;
|
|
if (!strstr(rc->caller_qn, callerSub))
|
|
continue;
|
|
if (!strstr(rc->callee_qn, calleeSub))
|
|
continue;
|
|
if (strategy && (!rc->strategy || strcmp(rc->strategy, strategy) != 0))
|
|
continue;
|
|
return rc;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* Exact-name match on BOTH ends. The pipeline joins a resolved row to its
|
|
* caller def by exact QN equality, so a row whose caller is spelled even
|
|
* slightly differently from the extractor's def is silently dropped —
|
|
* substring matching would hide exactly that failure. */
|
|
static int find_resolved_exact(const CBMFileResult *r, const char *caller_qn,
|
|
const char *callee_qn) {
|
|
for (int i = 0; i < r->resolved_calls.count; i++) {
|
|
const CBMResolvedCall *rc = &r->resolved_calls.items[i];
|
|
if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 &&
|
|
strcmp(rc->callee_qn, callee_qn) == 0)
|
|
return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static void dump_resolved(const CBMFileResult *r) {
|
|
for (int i = 0; i < r->resolved_calls.count; i++) {
|
|
const CBMResolvedCall *rc = &r->resolved_calls.items[i];
|
|
printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)",
|
|
rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)",
|
|
rc->confidence);
|
|
}
|
|
}
|
|
|
|
static int require_resolved_exact(const CBMFileResult *r, const char *caller_qn,
|
|
const char *callee_qn) {
|
|
int idx = find_resolved_exact(r, caller_qn, callee_qn);
|
|
if (idx < 0) {
|
|
printf(" MISSING exact resolved call: %s -> %s (have %d)\n", caller_qn, callee_qn,
|
|
r->resolved_calls.count);
|
|
dump_resolved(r);
|
|
}
|
|
return idx;
|
|
}
|
|
|
|
/* Number of rows with exactly this caller and callee. */
|
|
static int count_resolved_exact(const CBMFileResult *r, const char *caller_qn,
|
|
const char *callee_qn) {
|
|
int n = 0;
|
|
for (int i = 0; i < r->resolved_calls.count; i++) {
|
|
const CBMResolvedCall *rc = &r->resolved_calls.items[i];
|
|
if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 &&
|
|
strcmp(rc->callee_qn, callee_qn) == 0)
|
|
n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) {
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (d->label && d->name && strcmp(d->label, label) == 0 && strcmp(d->name, name) == 0)
|
|
return d;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* ── 1. QN contract: defs weave the class path into method QNs ─── */
|
|
|
|
TEST(rubylsp_qn_contract) {
|
|
const char *src = "class Animal\n"
|
|
" def speak\n"
|
|
" 'woof'\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
const CBMDefinition *m = find_def(r, "Method", "speak");
|
|
if (!m) {
|
|
printf(" no Method speak def; defs:\n");
|
|
for (int i = 0; i < r->defs.count; i++)
|
|
printf(" [%s] %s qn=%s parent=%s\n", r->defs.items[i].label, r->defs.items[i].name,
|
|
r->defs.items[i].qualified_name,
|
|
r->defs.items[i].parent_class ? r->defs.items[i].parent_class : "(null)");
|
|
}
|
|
ASSERT(m);
|
|
if (strcmp(m->qualified_name, "test.main.Animal.speak") != 0) {
|
|
printf(" QN scheme mismatch: got %s (resolver assumes module_qn.ClassPath.method)\n",
|
|
m->qualified_name);
|
|
}
|
|
ASSERT(strcmp(m->qualified_name, "test.main.Animal.speak") == 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 2. Constructor typing: a = Animal.new; a.speak ────────────── */
|
|
|
|
TEST(rubylsp_method_via_constructor_assignment) {
|
|
const char *src = "class Animal\n"
|
|
" def initialize(name)\n"
|
|
" @name = name\n"
|
|
" end\n"
|
|
" def speak\n"
|
|
" @name\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Runner\n"
|
|
" def run\n"
|
|
" a = Animal.new('rex')\n"
|
|
" a.speak\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved(r, "Runner.run", "Animal.speak") >= 0);
|
|
ASSERT(find_resolved_with_strategy(r, "Runner.run", "Animal.speak", "ruby_method_typed"));
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 3. Constructor edge targets the CLASS node ─────────────────── */
|
|
|
|
TEST(rubylsp_constructor_edge_targets_class) {
|
|
const char *src = "class Widget\n"
|
|
" def initialize\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Maker\n"
|
|
" def build\n"
|
|
" Widget.new\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
const CBMResolvedCall *rc =
|
|
find_resolved_with_strategy(r, "Maker.build", "Widget", "ruby_constructor");
|
|
ASSERT(rc);
|
|
/* Callee is the class QN (leaf "Widget"), matching the textual
|
|
* extractor's Widget.new -> "Widget" rewrite so the row joins. */
|
|
ASSERT(strcmp(rc->callee_qn, "test.main.Widget") == 0);
|
|
ASSERT(rc->confidence >= CBM_LSP_CONFIDENCE_FLOOR);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 4. Implicit + explicit self dispatch ───────────────────────── */
|
|
|
|
TEST(rubylsp_self_dispatch) {
|
|
const char *src = "class Greeter\n"
|
|
" def greet\n"
|
|
" build_greeting('hi')\n"
|
|
" self.build_greeting('yo')\n"
|
|
" end\n"
|
|
" def build_greeting(word)\n"
|
|
" word\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved(r, "Greeter.greet", "Greeter.build_greeting") >= 0);
|
|
ASSERT(find_resolved_with_strategy(r, "Greeter.greet", "Greeter.build_greeting",
|
|
"ruby_self_dispatch"));
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 5. Singleton (class) methods ───────────────────────────────── */
|
|
|
|
TEST(rubylsp_singleton_method_dispatch) {
|
|
const char *src = "class Registry\n"
|
|
" def self.register(key)\n"
|
|
" key\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class App\n"
|
|
" def boot\n"
|
|
" Registry.register(:db)\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved(r, "App.boot", "Registry.register") >= 0);
|
|
ASSERT(find_resolved_with_strategy(r, "App.boot", "Registry.register", "ruby_class_method"));
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Singleton methods must NOT satisfy instance dispatch (and vice versa). */
|
|
TEST(rubylsp_singleton_instance_split) {
|
|
const char *src = "class Config\n"
|
|
" def self.load\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class App\n"
|
|
" def boot\n"
|
|
" c = Config.new\n"
|
|
" c.load\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* c.load is an INSTANCE call; Config only defines a singleton `load`.
|
|
* Zero-edge guarantee: no resolved row may bind them. */
|
|
ASSERT(find_resolved(r, "App.boot", "Config.load") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 6. Superclass chain ────────────────────────────────────────── */
|
|
|
|
TEST(rubylsp_inheritance) {
|
|
const char *src = "class Base\n"
|
|
" def helper\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Child < Base\n"
|
|
" def work\n"
|
|
" helper\n"
|
|
" self.helper\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Driver\n"
|
|
" def drive\n"
|
|
" c = Child.new\n"
|
|
" c.helper\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* Inherited method resolves to Base.helper from both dispatch shapes. */
|
|
ASSERT(require_resolved(r, "Child.work", "Base.helper") >= 0);
|
|
ASSERT(require_resolved(r, "Driver.drive", "Base.helper") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 7. include mixin ───────────────────────────────────────────── */
|
|
|
|
TEST(rubylsp_include_mixin) {
|
|
const char *src = "module Greetable\n"
|
|
" def greet\n"
|
|
" 'hello'\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Person\n"
|
|
" include Greetable\n"
|
|
" def hail\n"
|
|
" greet()\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Caller\n"
|
|
" def run\n"
|
|
" p = Person.new\n"
|
|
" p.greet\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved(r, "Person.hail", "Greetable.greet") >= 0);
|
|
ASSERT(require_resolved(r, "Caller.run", "Greetable.greet") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 7b. prepend mixin shadows the class's own method ───────────── */
|
|
|
|
/* Ruby's lookup order puts prepended modules AHEAD of the class itself, which
|
|
* is the whole point of `prepend` over `include`. Both an own method and a
|
|
* prepended one exist here, so a resolver that walked the class first would
|
|
* still emit an edge — just the wrong one. Pin the winner, not merely that
|
|
* something resolved. */
|
|
TEST(rubylsp_prepend_mixin) {
|
|
const char *src = "module Audited\n"
|
|
" def save\n"
|
|
" 'audited'\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Record\n"
|
|
" prepend Audited\n"
|
|
" def save\n"
|
|
" 'plain'\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class App\n"
|
|
" def run\n"
|
|
" r = Record.new\n"
|
|
" r.save\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* The prepended module wins. */
|
|
ASSERT(require_resolved(r, "App.run", "Audited.save") >= 0);
|
|
/* And the shadowed own method must NOT also be emitted. */
|
|
ASSERT(find_resolved(r, "App.run", "Record.save") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 7c. Unresolved ancestry fails closed ───────────────────────── */
|
|
|
|
/* Three cases where an ancestor the resolver cannot see could define (or
|
|
* shadow) the method being called. Each one has a tempting local answer —
|
|
* the class's own method, a superclass method, a same-named top-level def —
|
|
* and each answer may be wrong, so none may be emitted. */
|
|
TEST(rubylsp_unresolved_ancestry_fails_closed) {
|
|
const char *src = /* (a) unresolved PREPEND blocks an own-method hit: the
|
|
* prepended module may shadow `save`. */
|
|
"class Record\n"
|
|
" prepend UnseenAudit\n"
|
|
" def save\n"
|
|
" 1\n"
|
|
" end\n"
|
|
" def persist\n"
|
|
" self.save\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class RecordUser\n"
|
|
" def run\n"
|
|
" r = Record.new\n"
|
|
" r.save\n"
|
|
" end\n"
|
|
"end\n"
|
|
/* (b) unresolved INCLUDE stops the walk before the superclass chain:
|
|
* the included module may define `helper` ahead of Base. */
|
|
"class Base\n"
|
|
" def helper\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Child < Base\n"
|
|
" include UnseenConcern\n"
|
|
" def work\n"
|
|
" self.helper\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class ChildUser\n"
|
|
" def run\n"
|
|
" c = Child.new\n"
|
|
" c.helper\n"
|
|
" end\n"
|
|
"end\n"
|
|
/* (c) a bare call in a class with an unresolved SUPERCLASS must not
|
|
* fall through to a same-named top-level def. */
|
|
"def util\n"
|
|
" 1\n"
|
|
"end\n"
|
|
"class Orphan < UnseenBase\n"
|
|
" def go\n"
|
|
" util()\n"
|
|
" end\n"
|
|
" def again\n"
|
|
" super\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* (a) */
|
|
ASSERT(find_resolved(r, "Record.persist", "save") < 0);
|
|
ASSERT(find_resolved(r, "RecordUser.run", "save") < 0);
|
|
/* (b) */
|
|
ASSERT(find_resolved(r, "Child.work", "helper") < 0);
|
|
ASSERT(find_resolved(r, "ChildUser.run", "helper") < 0);
|
|
/* (c) */
|
|
ASSERT(find_resolved(r, "Orphan.go", "util") < 0);
|
|
ASSERT(find_resolved(r, "Orphan.again", "again") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Positive controls for the rule above: resolved ancestry still reaches the
|
|
* superclass and still falls through to a top-level def, and a hit found
|
|
* BEFORE the unseen ancestor in Ruby's lookup order is still emitted. */
|
|
TEST(rubylsp_resolved_ancestry_still_resolves) {
|
|
const char *src = "module Seen\n"
|
|
" def greet\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"def util\n"
|
|
" 1\n"
|
|
"end\n"
|
|
"class Base\n"
|
|
" def helper\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Child < Base\n"
|
|
" include Seen\n"
|
|
" def work\n"
|
|
" self.helper\n"
|
|
" util()\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Mixed < UnseenBase\n"
|
|
" include UnseenConcern\n"
|
|
" include Seen\n"
|
|
" def own\n"
|
|
" 2\n"
|
|
" end\n"
|
|
" def work\n"
|
|
" self.own\n"
|
|
" self.greet\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved_exact(r, "test.main.Child.work", "test.main.Base.helper") >= 0);
|
|
ASSERT(require_resolved_exact(r, "test.main.Child.work", "test.main.util") >= 0);
|
|
/* Own method precedes includes and the superclass in lookup order. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Mixed.work", "test.main.Mixed.own") >= 0);
|
|
/* `include Seen` comes AFTER `include UnseenConcern`, so Seen is searched
|
|
* first and its hit is trustworthy. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Mixed.work", "test.main.Seen.greet") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 7d. Methods defined inside blocks are not members of the module ── */
|
|
|
|
/* ActiveSupport's `class_methods do … end` / `included do … end` define
|
|
* methods on whatever class includes the concern (class-side, for
|
|
* class_methods), never instance methods of the concern module itself. A
|
|
* resolver that registered them on the module would then find them through
|
|
* `include Concern` and emit `Foo.new.build` -> Concern.build — an edge to a
|
|
* def the extractor does not even emit. Same for `include X` written INSIDE
|
|
* such a block: it applies to the includer, not the concern. */
|
|
TEST(rubylsp_block_defs_not_module_members) {
|
|
const char *src = "module Other\n"
|
|
" def other_m\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"module Concern\n"
|
|
" class_methods do\n"
|
|
" def build\n"
|
|
" 1\n"
|
|
" end\n"
|
|
" end\n"
|
|
" included do\n"
|
|
" include Other\n"
|
|
" def injected\n"
|
|
" 2\n"
|
|
" end\n"
|
|
" end\n"
|
|
" def real\n"
|
|
" 3\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Foo\n"
|
|
" include Concern\n"
|
|
" def initialize\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class User\n"
|
|
" def run\n"
|
|
" f = Foo.new\n"
|
|
" f.build\n"
|
|
" f.injected\n"
|
|
" f.other_m\n"
|
|
" f.real\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(find_resolved(r, "User.run", "build") < 0);
|
|
ASSERT(find_resolved(r, "User.run", "injected") < 0);
|
|
ASSERT(find_resolved(r, "User.run", "other_m") < 0);
|
|
/* Positive control: a def directly in the module body IS a member. */
|
|
ASSERT(require_resolved_exact(r, "test.main.User.run", "test.main.Concern.real") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* A def inside a top-level block (an RSpec group) has whatever `self` the
|
|
* DSL gave it: bare calls in its body emit nothing, and it is not a
|
|
* file-level function other top-level code can reach. */
|
|
TEST(rubylsp_top_level_block_defs_fail_closed) {
|
|
const char *src = "def util\n"
|
|
" 1\n"
|
|
"end\n"
|
|
"describe 'x' do\n"
|
|
" def helper\n"
|
|
" util()\n"
|
|
" end\n"
|
|
"end\n"
|
|
"def caller_fn\n"
|
|
" helper()\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(find_resolved(r, "helper", "util") < 0);
|
|
ASSERT(find_resolved(r, "caller_fn", "helper") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 8. extend mixin (class-side) ───────────────────────────────── */
|
|
|
|
TEST(rubylsp_extend_mixin) {
|
|
const char *src = "module Findable\n"
|
|
" def locate(id)\n"
|
|
" id\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Record\n"
|
|
" extend Findable\n"
|
|
"end\n"
|
|
"class App\n"
|
|
" def run\n"
|
|
" Record.locate(7)\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved(r, "App.run", "Findable.locate") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 9. super dispatch ──────────────────────────────────────────── */
|
|
|
|
TEST(rubylsp_super_dispatch) {
|
|
const char *src = "class Base\n"
|
|
" def setup\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Child < Base\n"
|
|
" def setup\n"
|
|
" super\n"
|
|
" 2\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
const CBMResolvedCall *rc =
|
|
find_resolved_with_strategy(r, "Child.setup", "Base.setup", "ruby_method_super");
|
|
if (!rc)
|
|
(void)require_resolved(r, "Child.setup", "Base.setup");
|
|
ASSERT(rc);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 10. Instance-variable typing ───────────────────────────────── */
|
|
|
|
TEST(rubylsp_ivar_typing) {
|
|
const char *src = "class Engine\n"
|
|
" def start\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Car\n"
|
|
" def initialize\n"
|
|
" @engine = Engine.new\n"
|
|
" end\n"
|
|
" def drive\n"
|
|
" @engine.start\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved(r, "Car.drive", "Engine.start") >= 0);
|
|
ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method"));
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Conflicting ivar assignments must suppress the type (zero-edge). */
|
|
TEST(rubylsp_ivar_conflict_no_edge) {
|
|
const char *src = "class A\n"
|
|
" def go\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class B\n"
|
|
" def go\n"
|
|
" 2\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Holder\n"
|
|
" def initialize(flag)\n"
|
|
" @x = A.new\n"
|
|
" @x = B.new\n"
|
|
" end\n"
|
|
" def run\n"
|
|
" @x.go\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(find_resolved(r, "Holder.run", ".go") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Any write to @x that is not a resolved constructor (or nil) must latch the
|
|
* conflict, the same way two different constructors do — a method result, a
|
|
* parameter, an unresolved constant's `.new`, a compound operator, or a
|
|
* multiple assignment. Otherwise `@x = Foo.new` in one method silently types
|
|
* every other method's `@x` even after it was reassigned to who-knows-what. */
|
|
TEST(rubylsp_ivar_non_constructor_write_latches) {
|
|
const char *src = "class Engine\n"
|
|
" def start\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class ViaMethod\n"
|
|
" def initialize\n"
|
|
" @e = Engine.new\n"
|
|
" end\n"
|
|
" def reset\n"
|
|
" @e = compute\n"
|
|
" end\n"
|
|
" def go\n"
|
|
" @e.start\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class ViaParam\n"
|
|
" def initialize(e)\n"
|
|
" @e = Engine.new\n"
|
|
" @e = e\n"
|
|
" end\n"
|
|
" def go\n"
|
|
" @e.start\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class ViaUnknownCtor\n"
|
|
" def initialize\n"
|
|
" @e = Engine.new\n"
|
|
" @e = Mystery.new\n"
|
|
" end\n"
|
|
" def go\n"
|
|
" @e.start\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class ViaCompound\n"
|
|
" def initialize\n"
|
|
" @e = Engine.new\n"
|
|
" @e += 1\n"
|
|
" end\n"
|
|
" def go\n"
|
|
" @e.start\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class ViaMulti\n"
|
|
" def initialize(a)\n"
|
|
" @e = Engine.new\n"
|
|
" @e, @f = a\n"
|
|
" end\n"
|
|
" def go\n"
|
|
" @e.start\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class ViaOrUntyped\n"
|
|
" def initialize\n"
|
|
" @e = Engine.new\n"
|
|
" end\n"
|
|
" def lazy\n"
|
|
" @e ||= build\n"
|
|
" end\n"
|
|
" def go\n"
|
|
" @e.start\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(find_resolved(r, "ViaMethod.go", "Engine.start") < 0);
|
|
ASSERT(find_resolved(r, "ViaParam.go", "Engine.start") < 0);
|
|
ASSERT(find_resolved(r, "ViaUnknownCtor.go", "Engine.start") < 0);
|
|
ASSERT(find_resolved(r, "ViaCompound.go", "Engine.start") < 0);
|
|
ASSERT(find_resolved(r, "ViaMulti.go", "Engine.start") < 0);
|
|
ASSERT(find_resolved(r, "ViaOrUntyped.go", "Engine.start") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* `@x ||= Const.new` is the memoised-constructor idiom and counts as a
|
|
* constructor assignment; a `nil` reset says nothing about the type. */
|
|
TEST(rubylsp_ivar_or_assign_constructor_types) {
|
|
const char *src = "class Engine\n"
|
|
" def start\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Car\n"
|
|
" def engine\n"
|
|
" @engine ||= Engine.new\n"
|
|
" end\n"
|
|
" def clear\n"
|
|
" @engine = nil\n"
|
|
" end\n"
|
|
" def drive\n"
|
|
" @engine.start\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved_exact(r, "test.main.Car.drive", "test.main.Engine.start") >= 0);
|
|
ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method"));
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 11. Chained calls ──────────────────────────────────────────── */
|
|
|
|
TEST(rubylsp_chained_constructor_call) {
|
|
const char *src = "class Builder\n"
|
|
" def finish\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class App\n"
|
|
" def run\n"
|
|
" Builder.new.finish\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(require_resolved(r, "App.run", "Builder.finish") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 12. Nested modules + lexical nesting ───────────────────────── */
|
|
|
|
TEST(rubylsp_nested_modules) {
|
|
const char *src = "module Admin\n"
|
|
" class User\n"
|
|
" def self.lookup(id)\n"
|
|
" id\n"
|
|
" end\n"
|
|
" def name\n"
|
|
" 'n'\n"
|
|
" end\n"
|
|
" end\n"
|
|
" class Panel\n"
|
|
" def show\n"
|
|
" User.lookup(1)\n"
|
|
" u = User.new\n"
|
|
" u.name\n"
|
|
" end\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Outside\n"
|
|
" def probe\n"
|
|
" Admin::User.lookup(2)\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* Exact spellings: lexically nested declarations join with '.', matching
|
|
* the extractor's `<enclosing_qn>.<name>` — a substring match would let
|
|
* a wrongly-spelled row pass. */
|
|
/* Lexical reference from sibling class inside the module. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User.lookup") >=
|
|
0);
|
|
ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User") >= 0);
|
|
ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User.name") >=
|
|
0);
|
|
/* Fully-qualified reference from outside. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin.User.lookup") >=
|
|
0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 12b. Compact `class A::B` names match the extractor byte-for-byte ── */
|
|
|
|
/* The structural extractor names a compact declaration with its RAW name text
|
|
* (`Admin::User`), and the pipeline joins a resolved row to its caller def by
|
|
* exact QN equality. A resolver that normalised the name to `Admin.User` would
|
|
* have every row from or to such a class dropped at the join — silently, since
|
|
* substring assertions would still pass. Pin the exact spelling on both ends,
|
|
* and pin that the extractor's def really is spelled that way. */
|
|
TEST(rubylsp_compact_class_name_exact) {
|
|
const char *src = "class Admin::User\n"
|
|
" def initialize\n"
|
|
" end\n"
|
|
" def name\n"
|
|
" 'n'\n"
|
|
" end\n"
|
|
" def shout\n"
|
|
" self.name\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Outside\n"
|
|
" def probe\n"
|
|
" u = Admin::User.new\n"
|
|
" u.name\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* The extractor's contract this resolver must reproduce. */
|
|
const CBMDefinition *m = find_def(r, "Method", "name");
|
|
ASSERT(m);
|
|
ASSERT(strcmp(m->qualified_name, "test.main.Admin::User.name") == 0);
|
|
ASSERT(m->parent_class && strcmp(m->parent_class, "test.main.Admin::User") == 0);
|
|
/* Caller inside the compact class, callee inside it. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Admin::User.shout", "test.main.Admin::User.name") >=
|
|
0);
|
|
/* Caller outside: constructor row lands on the class QN, typed call on
|
|
* the method QN — both in the extractor's spelling. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin::User") >= 0);
|
|
ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin::User.name") >= 0);
|
|
/* And the dotted spelling must NOT appear anywhere. */
|
|
ASSERT(find_resolved(r, "Outside.probe", "Admin.User") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Compact declaration nested inside a lexical module: the extractor joins the
|
|
* enclosing class QN with the raw name, giving `Admin.Foo::Bar`. */
|
|
TEST(rubylsp_compact_class_inside_module_exact) {
|
|
const char *src = "module Admin\n"
|
|
" class Foo::Bar\n"
|
|
" def m\n"
|
|
" 1\n"
|
|
" end\n"
|
|
" end\n"
|
|
" class Panel\n"
|
|
" def show\n"
|
|
" Foo::Bar.new.m\n"
|
|
" end\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
const CBMDefinition *m = find_def(r, "Method", "m");
|
|
ASSERT(m);
|
|
ASSERT(strcmp(m->qualified_name, "test.main.Admin.Foo::Bar.m") == 0);
|
|
ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.Foo::Bar") >=
|
|
0);
|
|
ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.Foo::Bar.m") >=
|
|
0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Inside `class Admin::User`, Ruby's lexical nesting is [Admin::User] only —
|
|
* `Admin` is not opened, so a bare `Helper` does NOT see Admin::Helper. A
|
|
* resolver that probed the dotted prefixes (Admin.User, Admin, "") would
|
|
* invent that constant and emit a wrong constructor edge. The explicit
|
|
* `Admin::Helper` still resolves. */
|
|
TEST(rubylsp_compact_class_does_not_open_outer_scope) {
|
|
const char *src = "module Admin\n"
|
|
" class Helper\n"
|
|
" def initialize\n"
|
|
" end\n"
|
|
" def assist\n"
|
|
" 1\n"
|
|
" end\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Admin::User\n"
|
|
" def bare\n"
|
|
" h = Helper.new\n"
|
|
" h.assist\n"
|
|
" end\n"
|
|
" def explicit\n"
|
|
" h = Admin::Helper.new\n"
|
|
" h.assist\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* Bare `Helper` inside the compact class: no edge at all. */
|
|
ASSERT(find_resolved(r, "Admin::User.bare", "Helper") < 0);
|
|
/* Fully qualified reference resolves, in exact extractor spelling. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Admin::User.explicit", "test.main.Admin.Helper") >=
|
|
0);
|
|
ASSERT(require_resolved_exact(r, "test.main.Admin::User.explicit",
|
|
"test.main.Admin.Helper.assist") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 13. ActiveRecord model typing ──────────────────────────────── */
|
|
|
|
TEST(rubylsp_activerecord_model_typing) {
|
|
const char *src = "class User < ApplicationRecord\n"
|
|
" def full_name\n"
|
|
" 'x'\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class UsersController < ApplicationController\n"
|
|
" def show\n"
|
|
" u = User.find(1)\n"
|
|
" u.full_name\n"
|
|
" end\n"
|
|
" def index\n"
|
|
" User.where(active: true).first.full_name\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* User.find returns User (AR query typing); u.full_name resolves. */
|
|
ASSERT(require_resolved(r, "UsersController.show", "User.full_name") >= 0);
|
|
/* Relation approximation keeps the model type through where/first. */
|
|
ASSERT(require_resolved(r, "UsersController.index", "User.full_name") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 14. Top-level functions ────────────────────────────────────── */
|
|
|
|
TEST(rubylsp_top_level_function) {
|
|
const char *src = "def helper(x)\n"
|
|
" x\n"
|
|
"end\n"
|
|
"def run\n"
|
|
" helper(1)\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
const CBMResolvedCall *rc =
|
|
find_resolved_with_strategy(r, "main.run", "main.helper", "ruby_function_local");
|
|
if (!rc)
|
|
(void)require_resolved(r, "main.run", "main.helper");
|
|
ASSERT(rc);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 15. Negative: unresolvable receiver emits no edge ──────────── */
|
|
|
|
TEST(rubylsp_unknown_receiver_no_edge) {
|
|
const char *src = "class Safe\n"
|
|
" def process(payload)\n"
|
|
" payload.transform\n"
|
|
" mystery = fetch_thing\n"
|
|
" mystery.explode\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* Neither the untyped parameter nor the unresolved local may produce
|
|
* a resolved row. */
|
|
ASSERT(find_resolved(r, "Safe.process", "transform") < 0);
|
|
ASSERT(find_resolved(r, "Safe.process", "explode") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 15b. `def` is a scope gate ─────────────────────────────────── */
|
|
|
|
/* Ruby locals do not cross a `def`: a typed local in the enclosing body (or
|
|
* a sibling method) is invisible inside, and a parameter of the same name is
|
|
* a brand-new, untyped binding. A resolver that chained the method scope to
|
|
* its parent would type `a` from the outside and emit a wrong edge. */
|
|
TEST(rubylsp_def_is_scope_gate) {
|
|
const char *src = "class Foo\n"
|
|
" def bar\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"a = Foo.new\n"
|
|
"def top\n"
|
|
" a.bar\n"
|
|
"end\n"
|
|
"class Worker\n"
|
|
" def run\n"
|
|
" a = Foo.new\n"
|
|
" a.bar\n"
|
|
" helper(a)\n"
|
|
" end\n"
|
|
" def helper(a)\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
" def later\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* Positive control: the typed local inside its own method resolves. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") >= 0);
|
|
/* Top-level `a` is not visible inside `def top`. */
|
|
ASSERT(find_resolved(r, "main.top", "Foo.bar") < 0);
|
|
/* Parameter `a` is untyped, whatever the caller passed. */
|
|
ASSERT(find_resolved(r, "Worker.helper", "Foo.bar") < 0);
|
|
/* Sibling method's local is not visible either. */
|
|
ASSERT(find_resolved(r, "Worker.later", "Foo.bar") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Block parameters shadow the enclosing local for the block's extent only:
|
|
* `|a|` inside the block is untyped, and `a` after the block is still Foo. */
|
|
TEST(rubylsp_block_param_shadows_local) {
|
|
const char *src = "class Foo\n"
|
|
" def bar\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Worker\n"
|
|
" def run\n"
|
|
" a = Foo.new\n"
|
|
" [1].each { |a| a.bar }\n"
|
|
" [1].each do |x, (y, z), *rest; loc|\n"
|
|
" x.bar\n"
|
|
" y.bar\n"
|
|
" rest.bar\n"
|
|
" loc.bar\n"
|
|
" end\n"
|
|
" f = ->(a) { a.bar }\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
/* Exactly ONE row: the `a.bar` after the blocks. Every `.bar` inside a
|
|
* block is on an untyped block parameter. */
|
|
if (count_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") != 1)
|
|
dump_resolved(r);
|
|
ASSERT(count_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") == 1);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Reassigning a typed local to anything the resolver cannot type must drop
|
|
* the old type. `a = Foo.new; a = x; a.bar` is NOT a call on Foo. Covers
|
|
* plain reassignment, multiple assignment, `||=` and other compound forms. */
|
|
TEST(rubylsp_untyped_reassignment_drops_type) {
|
|
const char *src = "class Foo\n"
|
|
" def bar\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Worker\n"
|
|
" def plain(x)\n"
|
|
" a = Foo.new\n"
|
|
" a = x\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
" def multi(x)\n"
|
|
" a = Foo.new\n"
|
|
" a, b = x, 1\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
" def nested_multi(x)\n"
|
|
" a = Foo.new\n"
|
|
" (a, b), c = x, 1\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
" def splat_multi(x)\n"
|
|
" a = Foo.new\n"
|
|
" b, *a = x\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
" def or_assign(x)\n"
|
|
" a = Foo.new\n"
|
|
" a ||= x\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
" def compound(x)\n"
|
|
" a = Foo.new\n"
|
|
" a += x\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
" def in_block(x)\n"
|
|
" a = Foo.new\n"
|
|
" [1].each { a = x }\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
" def fresh_or_assign\n"
|
|
" a ||= Foo.new\n"
|
|
" a.bar\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(find_resolved(r, "Worker.plain", "Foo.bar") < 0);
|
|
ASSERT(find_resolved(r, "Worker.multi", "Foo.bar") < 0);
|
|
ASSERT(find_resolved(r, "Worker.nested_multi", "Foo.bar") < 0);
|
|
ASSERT(find_resolved(r, "Worker.splat_multi", "Foo.bar") < 0);
|
|
ASSERT(find_resolved(r, "Worker.or_assign", "Foo.bar") < 0);
|
|
ASSERT(find_resolved(r, "Worker.compound", "Foo.bar") < 0);
|
|
/* Assignment inside a block rebinds the enclosing local (closure). */
|
|
ASSERT(find_resolved(r, "Worker.in_block", "Foo.bar") < 0);
|
|
/* Positive control: `||=` on a fresh name with a typed RHS is a
|
|
* constructor assignment. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Worker.fresh_or_assign", "test.main.Foo.bar") >= 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 16. Negative: dynamic dispatch emits no edge ───────────────── */
|
|
|
|
TEST(rubylsp_send_no_edge) {
|
|
const char *src = "class Target\n"
|
|
" def hidden\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Meta\n"
|
|
" def invoke\n"
|
|
" t = Target.new\n"
|
|
" t.send(:hidden)\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(find_resolved(r, "Meta.invoke", "hidden") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 16b. Negative: the whole dynamic-dispatch family emits no edge ── */
|
|
|
|
/* `send` has its own row above. The zero-edge guarantee is advertised for the
|
|
* whole reflective family, so pin the rest of it too: a resolver that treated
|
|
* any of these as an ordinary call would invent an edge to `hidden`.
|
|
*
|
|
* `Meta` deliberately defines its OWN `hidden`: inside `instance_eval { }`
|
|
* self is the Target, not the Meta, so a resolver that dispatched the bare
|
|
* `hidden()` on the enclosing class would emit Meta.via_instance_eval ->
|
|
* Meta.hidden — a wrong edge that an empty Meta would never reveal. The
|
|
* bare calls are written with `()` because tree-sitter-ruby parses a lone
|
|
* `hidden` as an identifier, which the extractor never emits as a call. */
|
|
TEST(rubylsp_dynamic_dispatch_family_no_edge) {
|
|
const char *src = "class Target\n"
|
|
" def hidden\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Meta\n"
|
|
" def hidden\n"
|
|
" 0\n"
|
|
" end\n"
|
|
" def via_public_send\n"
|
|
" Target.new.public_send(:hidden)\n"
|
|
" end\n"
|
|
" def via_underscore_send\n"
|
|
" Target.new.__send__(:hidden)\n"
|
|
" end\n"
|
|
" def via_define_method\n"
|
|
" Target.define_method(:hidden) { 2 }\n"
|
|
" end\n"
|
|
" def via_instance_eval\n"
|
|
" Target.new.instance_eval { hidden() }\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(find_resolved(r, "Meta.via_public_send", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_underscore_send", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_define_method", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_instance_eval", "hidden") < 0);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 16c. Negative: blocks that rebind `self` ───────────────────── */
|
|
|
|
/* instance_eval/exec, class_eval/exec, module_eval/exec, define_method and
|
|
* Class.new/Module.new run their block with a different `self`. Bare calls,
|
|
* `self.` calls and `super` inside them must not dispatch on the enclosing
|
|
* class. Every enclosing class here HAS the method being called, so the old
|
|
* behaviour would emit a wrong edge in each row. Receiver and argument
|
|
* expressions of the same call are ordinary and still resolve. */
|
|
TEST(rubylsp_self_rebinding_blocks_no_edge) {
|
|
const char *src = "class Target\n"
|
|
" def hidden\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Meta\n"
|
|
" def hidden\n"
|
|
" 0\n"
|
|
" end\n"
|
|
" def target\n"
|
|
" Target.new\n"
|
|
" end\n"
|
|
" def via_instance_exec\n"
|
|
" target().instance_exec(1) { |x| hidden(); self.hidden }\n"
|
|
" end\n"
|
|
" def via_class_eval\n"
|
|
" Target.class_eval do\n"
|
|
" hidden()\n"
|
|
" end\n"
|
|
" end\n"
|
|
" def via_class_exec\n"
|
|
" Target.class_exec { hidden() }\n"
|
|
" end\n"
|
|
" def via_module_eval\n"
|
|
" Target.module_eval { self.hidden }\n"
|
|
" end\n"
|
|
" def via_module_exec\n"
|
|
" Target.module_exec { hidden() }\n"
|
|
" end\n"
|
|
" def via_define_method_body\n"
|
|
" Target.define_method(:other) { hidden() }\n"
|
|
" end\n"
|
|
" def via_class_new\n"
|
|
" Class.new do\n"
|
|
" hidden()\n"
|
|
" def hidden\n"
|
|
" super\n"
|
|
" end\n"
|
|
" end\n"
|
|
" end\n"
|
|
" def via_module_new\n"
|
|
" Module.new { self.hidden }\n"
|
|
" end\n"
|
|
" def after_block\n"
|
|
" Target.new.instance_eval { hidden() }\n"
|
|
" self.hidden\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(find_resolved(r, "Meta.via_instance_exec", "hidden") < 0);
|
|
/* ...but the receiver expression of that same call still resolves. */
|
|
ASSERT(require_resolved_exact(r, "test.main.Meta.via_instance_exec", "test.main.Meta.target") >=
|
|
0);
|
|
ASSERT(find_resolved(r, "Meta.via_class_eval", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_class_exec", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_module_eval", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_module_exec", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_define_method_body", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_class_new", "hidden") < 0);
|
|
ASSERT(find_resolved(r, "Meta.via_module_new", "hidden") < 0);
|
|
/* The suppression ends with the block: the following statement is an
|
|
* ordinary self dispatch on Meta. */
|
|
ASSERT(count_resolved_exact(r, "test.main.Meta.after_block", "test.main.Meta.hidden") == 1);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* ── 17. Bounded resolver work ────────────────────────────────────
|
|
*
|
|
* Measured with counters, never with a clock (O9): CBM_LSP_WORK
|
|
* (lsp_work.h, seam builds) for lookup work, and the result arena's
|
|
* cumulative allocation for bytes built. Each fixture is measured at n and
|
|
* 2n: linear work doubles (ratio about 2); anything worse lands above the
|
|
* gate of 3. */
|
|
|
|
static uint64_t rubylsp_counted_work(const char *src, size_t *arena_bytes) {
|
|
(void)cbm_lsp_work_take();
|
|
CBMFileResult *r = extract_ruby(src);
|
|
uint64_t work = cbm_lsp_work_take();
|
|
if (arena_bytes)
|
|
*arena_bytes = r ? cbm_arena_total(&r->arena) : 0;
|
|
if (r)
|
|
cbm_free_result(r);
|
|
return work;
|
|
}
|
|
|
|
/* n modules where every module both prepends and includes the next one;
|
|
* the last defines one method. `Probe` reaches M0 through `include`
|
|
* (instance side) or `extend` (class side) and calls a method nobody
|
|
* defines, so the lookup must miss after searching every ancestor. */
|
|
static char *rubylsp_mixin_chain_src(int n, bool class_side) {
|
|
size_t cap = (size_t)n * 96 + 256;
|
|
char *src = (char *)malloc(cap);
|
|
if (!src)
|
|
return NULL;
|
|
size_t off = 0;
|
|
for (int i = 0; i < n; i++) {
|
|
if (i + 1 < n)
|
|
off += (size_t)snprintf(src + off, cap - off,
|
|
"module M%d\n prepend M%d\n include M%d\nend\n", i, i + 1,
|
|
i + 1);
|
|
else
|
|
off += (size_t)snprintf(src + off, cap - off,
|
|
"module M%d\n def leaf\n 1\n end\nend\n", i);
|
|
}
|
|
if (class_side)
|
|
(void)snprintf(src + off, cap - off,
|
|
"class Probe\n extend M0\n def self.run\n nobody_defines_this()\n"
|
|
" end\nend\n");
|
|
else
|
|
(void)snprintf(src + off, cap - off,
|
|
"class Probe\n include M0\n def run\n nobody_defines_this()\n"
|
|
" end\nend\n");
|
|
return src;
|
|
}
|
|
|
|
static uint64_t rubylsp_mixin_chain_work(int n, bool class_side) {
|
|
char *src = rubylsp_mixin_chain_src(n, class_side);
|
|
if (!src)
|
|
return 0;
|
|
uint64_t work = rubylsp_counted_work(src, NULL);
|
|
free(src);
|
|
return work;
|
|
}
|
|
|
|
/* Review ask (a): Ruby linearizes ancestors, so a module reached through
|
|
* two paths (`prepend M; include M`, a diamond) is searched once. Without a
|
|
* visited set every level of the chain doubles the paths: 2^n visits
|
|
* (measured 4 -> 20,744 for n = 2..14). With it a miss costs one visit per
|
|
* module. Positive controls: the doubled pair still resolves through the
|
|
* prepend, and a diamond resolves to the shared module exactly once. */
|
|
TEST(rubylsp_mixin_walk_visits_each_module_once) {
|
|
uint64_t inst_n = rubylsp_mixin_chain_work(6, false);
|
|
uint64_t inst_2n = rubylsp_mixin_chain_work(12, false);
|
|
uint64_t cls_n = rubylsp_mixin_chain_work(6, true);
|
|
uint64_t cls_2n = rubylsp_mixin_chain_work(12, true);
|
|
printf(" mixin chain work: instance n=6 %llu, n=12 %llu; class side n=6 %llu, n=12 %llu\n",
|
|
(unsigned long long)inst_n, (unsigned long long)inst_2n, (unsigned long long)cls_n,
|
|
(unsigned long long)cls_2n);
|
|
ASSERT(inst_n > 0 && cls_n > 0);
|
|
ASSERT(inst_2n <= 3 * inst_n);
|
|
ASSERT(cls_2n <= 3 * cls_n);
|
|
|
|
const char *src = "module Shared\n"
|
|
" def hello\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
"module Left\n"
|
|
" include Shared\n"
|
|
"end\n"
|
|
"module Right\n"
|
|
" include Shared\n"
|
|
"end\n"
|
|
"module Twice\n"
|
|
" def twice\n"
|
|
" 2\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Diamond\n"
|
|
" include Left\n"
|
|
" include Right\n"
|
|
" prepend Twice\n"
|
|
" include Twice\n"
|
|
" def run\n"
|
|
" hello()\n"
|
|
" twice()\n"
|
|
" end\n"
|
|
"end\n";
|
|
CBMFileResult *r = extract_ruby(src);
|
|
ASSERT(r);
|
|
ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Shared.hello") == 1);
|
|
ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Twice.twice") == 1);
|
|
cbm_free_result(r);
|
|
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();
|
|
}
|
|
|
|
/* Review ask (c): the stdlib seed must survive allocation failure. A
|
|
* zero-initialised arena has no blocks and no grow size, so every
|
|
* allocation from it returns NULL. The seed must not write through a NULL
|
|
* return-type vector, and must not register an entry whose formatted name
|
|
* came back NULL (a NULL QN would reach strcmp in finalize and in every later
|
|
* lookup). */
|
|
TEST(rubylsp_stdlib_seed_survives_alloc_failure) {
|
|
CBMArena reg_arena;
|
|
cbm_arena_init(®_arena);
|
|
CBMTypeRegistry reg;
|
|
cbm_registry_init(®, ®_arena);
|
|
CBMArena dead;
|
|
memset(&dead, 0, sizeof(dead));
|
|
cbm_ruby_stdlib_register(®, &dead);
|
|
ASSERT(reg.type_count > 0);
|
|
ASSERT(reg.func_count > 0); /* Kernel built-ins carry literal names */
|
|
for (int i = 0; i < reg.func_count; i++) {
|
|
ASSERT(reg.funcs[i].qualified_name != NULL);
|
|
ASSERT(reg.funcs[i].short_name != NULL);
|
|
}
|
|
cbm_registry_finalize(®);
|
|
ASSERT(cbm_registry_lookup_func(®, "puts") != NULL);
|
|
cbm_arena_destroy(®_arena);
|
|
PASS();
|
|
}
|
|
|
|
/* ── suite ──────────────────────────────────────────────────────── */
|
|
|
|
void suite_ruby_lsp(void) {
|
|
RUN_TEST(rubylsp_qn_contract);
|
|
RUN_TEST(rubylsp_method_via_constructor_assignment);
|
|
RUN_TEST(rubylsp_constructor_edge_targets_class);
|
|
RUN_TEST(rubylsp_self_dispatch);
|
|
RUN_TEST(rubylsp_singleton_method_dispatch);
|
|
RUN_TEST(rubylsp_singleton_instance_split);
|
|
RUN_TEST(rubylsp_inheritance);
|
|
RUN_TEST(rubylsp_include_mixin);
|
|
RUN_TEST(rubylsp_prepend_mixin);
|
|
RUN_TEST(rubylsp_unresolved_ancestry_fails_closed);
|
|
RUN_TEST(rubylsp_resolved_ancestry_still_resolves);
|
|
RUN_TEST(rubylsp_block_defs_not_module_members);
|
|
RUN_TEST(rubylsp_top_level_block_defs_fail_closed);
|
|
RUN_TEST(rubylsp_extend_mixin);
|
|
RUN_TEST(rubylsp_super_dispatch);
|
|
RUN_TEST(rubylsp_ivar_typing);
|
|
RUN_TEST(rubylsp_ivar_conflict_no_edge);
|
|
RUN_TEST(rubylsp_ivar_non_constructor_write_latches);
|
|
RUN_TEST(rubylsp_ivar_or_assign_constructor_types);
|
|
RUN_TEST(rubylsp_chained_constructor_call);
|
|
RUN_TEST(rubylsp_nested_modules);
|
|
RUN_TEST(rubylsp_compact_class_name_exact);
|
|
RUN_TEST(rubylsp_compact_class_inside_module_exact);
|
|
RUN_TEST(rubylsp_compact_class_does_not_open_outer_scope);
|
|
RUN_TEST(rubylsp_activerecord_model_typing);
|
|
RUN_TEST(rubylsp_top_level_function);
|
|
RUN_TEST(rubylsp_unknown_receiver_no_edge);
|
|
RUN_TEST(rubylsp_def_is_scope_gate);
|
|
RUN_TEST(rubylsp_block_param_shadows_local);
|
|
RUN_TEST(rubylsp_untyped_reassignment_drops_type);
|
|
RUN_TEST(rubylsp_send_no_edge);
|
|
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);
|
|
RUN_TEST(rubylsp_stdlib_seed_survives_alloc_failure);
|
|
}
|