feat(lsp): add the Ruby per-file resolver

In-process, type-aware call resolution for single Ruby files, mirroring
the perl_lsp.c / php_lsp.c shape and dispatched from cbm_extract_file:

- PASS 1 collects classes and modules with lexical nesting, superclasses,
  and include / prepend / extend mixins, plus method tables with instance
  and singleton methods classified from the AST. Singleton methods key on
  "<class_qn>.self", which cannot collide with a constant path.
- PASS 1.5 infers instance-variable types from `@x = Const.new`;
  conflicting assignments latch to no type rather than guessing.
- PASS 2 resolves constructors (`Const.new` targets the class node, the
  way Python's lsp_constructor does), implicit and explicit self
  dispatch, singleton methods, superclass chains, mixin lookup in Ruby's
  method-resolution order, super, and ivar / local / chained receivers.

The stdlib seed covers Ruby core plus a curated Rails surface, including
ActiveRecord query typing: `User.find(1)` is a User, and relation chains
keep the model type. Dynamic dispatch (send, method_missing,
define_method) and unresolved receivers emit no edge, and only
project-defined targets are emitted.

Cross-file resolution is a separate tier and is not part of this change;
the 18 rows here drive the resolver through cbm_extract_file exactly as
the extraction pipeline calls it.

PROVENANCE: this resolver was authored clean-room from the
tree-sitter-ruby grammar and Ruby language semantics. No Ruby language
server source was consulted. scripts/check-lsp-originality.sh gains
Shopify's ruby-lsp (MIT) as the Ruby reference so the boundary is
auditable alongside the generated data it guards;
`check-lsp-originality.sh --lang ruby` reports CLEAN — no verbatim
string or comment overlap and no structural clones.

Refs #1701

Assisted-by: OpenCode:anthropic/claude-opus-5
Signed-off-by: Ben Fairless <ben@oaf.org.au>
This commit is contained in:
Ben Fairless committed 2026-08-18 16:22:09 +08:00
1 parent 4d7d9f1117
commit e9f87efe6a
9 files changed
+2521 -2

No files matched your search

+2 -1
View File
@@ -579,6 +579,7 @@ TEST_CS_LSP_SRCS = tests/test_cs_lsp.c
TEST_CS_LSP_BENCH_SRCS = tests/test_cs_lsp_bench.c
TEST_PERL_LSP_SRCS = tests/test_perl_lsp.c
TEST_RUBY_LSP_SRCS = tests/test_ruby_lsp.c
TEST_SCOPE_SRCS = tests/test_scope.c
@@ -701,7 +702,7 @@ TEST_REPRO_SRCS = \
tests/repro/repro_lsp_java_cs.c \
tests/repro/repro_lsp_kt_php_rust.c
ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS)
ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_RUBY_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS)
# ── Build directories ────────────────────────────────────────────
+4
View File
@@ -8,6 +8,7 @@
#include "lsp/php_lsp.h"
#include "lsp/perl_lsp.h"
#include "lsp/py_lsp.h"
#include "lsp/ruby_lsp.h"
#include "lsp/ts_lsp.h"
#include "lsp/cs_lsp.h"
#include "lsp/java_lsp.h"
@@ -1320,6 +1321,9 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
if (language == CBM_LANG_PERL) {
cbm_run_perl_lsp(a, result, source, source_len, root);
}
if (language == CBM_LANG_RUBY) {
cbm_run_ruby_lsp(a, result, source, source_len, root);
}
if (language == CBM_LANG_PYTHON) {
cbm_run_py_lsp(a, result, source, source_len, root);
}
@@ -0,0 +1,461 @@
/*
* ruby_stdlib_data.c — hand-written Ruby core + curated Rails type data.
*
* Strategy mirrors perl_stdlib_data.c / php_stdlib_data.c:
* 1. Ruby core classes (String, Array, Hash, Integer, ...) registered as
* types with receiver-keyed instance methods whose return types enable
* call-chain typing ("a,b".split(",").first). Method QNs are bare
* ("String.upcase") — no project prefix — so they can never collide
* with an indexed def; they exist for TYPING, not for edge emission
* (the resolver only emits edges to project-defined methods).
* 2. Kernel built-ins (puts, require, raise, ...) as global functions so
* bare calls to them type-check without producing spurious edges.
* 3. A curated Rails surface: ActiveRecord::Base class-side query methods
* and instance persistence methods, ActionController::Base helpers,
* and ActiveSupport-style predicates on Object. Registered under
* dotted QNs ("ActiveRecord.Base") to match ruby path dotting
* (Foo::Bar -> Foo.Bar). The resolver special-cases ActiveRecord
* query methods to return the *receiving model's* type (User.find ->
* User), which these generic entries cannot express.
*
* Return types are left UNKNOWN where the real type is polymorphic; the
* seed only needs to be right where chaining matters.
*/
#include "../type_rep.h"
#include "../type_registry.h"
#include "../../arena.h"
#include "../ruby_lsp.h"
#include <string.h>
#define MIXED cbm_type_unknown()
/* Register a type by bare dotted QN (e.g. "String", "ActiveRecord.Base"). */
#define REG_TYPE(qn_) \
do { \
CBMRegisteredType rt; \
memset(&rt, 0, sizeof(rt)); \
rt.qualified_name = (qn_); \
rt.short_name = (qn_); \
cbm_registry_add_type(reg, rt); \
} while (0)
/* Register an instance method on receiver type `recv_` returning `ret_`. */
#define REG_METHOD(recv_, name_, ret_) \
do { \
memset(&rf, 0, sizeof(rf)); \
rf.min_params = -1; \
rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \
rf.short_name = (name_); \
rf.receiver_type = (recv_); \
{ \
const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \
rets[0] = (ret_); \
rets[1] = NULL; \
rf.signature = cbm_type_func(arena, NULL, NULL, rets); \
} \
cbm_registry_add_func(reg, rf); \
} while (0)
/* Register a singleton (class-side) method: receiver key "<qn>.self"
* matches ruby_lookup_singleton_method's keying convention. */
#define REG_SMETHOD(recv_, name_, ret_) \
do { \
memset(&rf, 0, sizeof(rf)); \
rf.min_params = -1; \
rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \
rf.short_name = (name_); \
rf.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \
{ \
const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \
rets[0] = (ret_); \
rets[1] = NULL; \
rf.signature = cbm_type_func(arena, NULL, NULL, rets); \
} \
cbm_registry_add_func(reg, rf); \
} while (0)
/* Register a global (Kernel) built-in function returning `ret_`. */
#define REG_BUILTIN(name_, ret_) \
do { \
memset(&rf, 0, sizeof(rf)); \
rf.min_params = -1; \
rf.qualified_name = (name_); \
rf.short_name = (name_); \
{ \
const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \
rets[0] = (ret_); \
rets[1] = NULL; \
rf.signature = cbm_type_func(arena, NULL, NULL, rets); \
} \
cbm_registry_add_func(reg, rf); \
} while (0)
void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena) {
CBMRegisteredFunc rf;
const CBMType *T_STRING = cbm_type_named(arena, "String");
const CBMType *T_ARRAY = cbm_type_named(arena, "Array");
const CBMType *T_HASH = cbm_type_named(arena, "Hash");
const CBMType *T_INT = cbm_type_named(arena, "Integer");
const CBMType *T_FLOAT = cbm_type_named(arena, "Float");
const CBMType *T_SYM = cbm_type_named(arena, "Symbol");
const CBMType *T_BOOL = cbm_type_builtin(arena, "bool");
/* ── Core class constants (resolvable as superclasses / receivers) ── */
static const char *core_types[] = {"Object",
"BasicObject",
"String",
"Array",
"Hash",
"Integer",
"Float",
"Numeric",
"Symbol",
"Range",
"Regexp",
"Time",
"Date",
"DateTime",
"File",
"IO",
"Dir",
"Struct",
"OpenStruct",
"StringIO",
"Set",
"Proc",
"Method",
"Thread",
"Mutex",
"Queue",
"Rational",
"Complex",
"NilClass",
"TrueClass",
"FalseClass",
"Exception",
"StandardError",
"RuntimeError",
"ArgumentError",
"TypeError",
"NameError",
"NoMethodError",
"IOError",
"KeyError",
"IndexError",
"RangeError",
"NotImplementedError",
"StopIteration",
"Comparable",
"Enumerable",
"Kernel",
"Math",
"JSON",
"ERB",
"Logger",
"Enumerator",
NULL};
for (int i = 0; core_types[i]; i++)
REG_TYPE(core_types[i]);
/* ── String ── */
REG_METHOD("String", "upcase", T_STRING);
REG_METHOD("String", "downcase", T_STRING);
REG_METHOD("String", "capitalize", T_STRING);
REG_METHOD("String", "strip", T_STRING);
REG_METHOD("String", "chomp", T_STRING);
REG_METHOD("String", "gsub", T_STRING);
REG_METHOD("String", "sub", T_STRING);
REG_METHOD("String", "split", T_ARRAY);
REG_METHOD("String", "chars", T_ARRAY);
REG_METHOD("String", "lines", T_ARRAY);
REG_METHOD("String", "bytes", T_ARRAY);
REG_METHOD("String", "to_s", T_STRING);
REG_METHOD("String", "to_str", T_STRING);
REG_METHOD("String", "to_i", T_INT);
REG_METHOD("String", "to_f", T_FLOAT);
REG_METHOD("String", "to_sym", T_SYM);
REG_METHOD("String", "length", T_INT);
REG_METHOD("String", "size", T_INT);
REG_METHOD("String", "reverse", T_STRING);
REG_METHOD("String", "include?", T_BOOL);
REG_METHOD("String", "start_with?", T_BOOL);
REG_METHOD("String", "end_with?", T_BOOL);
REG_METHOD("String", "empty?", T_BOOL);
REG_METHOD("String", "match?", T_BOOL);
REG_METHOD("String", "freeze", T_STRING);
REG_METHOD("String", "dup", T_STRING);
/* ── Array ── */
REG_METHOD("Array", "map", T_ARRAY);
REG_METHOD("Array", "flat_map", T_ARRAY);
REG_METHOD("Array", "select", T_ARRAY);
REG_METHOD("Array", "filter", T_ARRAY);
REG_METHOD("Array", "reject", T_ARRAY);
REG_METHOD("Array", "sort", T_ARRAY);
REG_METHOD("Array", "sort_by", T_ARRAY);
REG_METHOD("Array", "uniq", T_ARRAY);
REG_METHOD("Array", "compact", T_ARRAY);
REG_METHOD("Array", "flatten", T_ARRAY);
REG_METHOD("Array", "reverse", T_ARRAY);
REG_METHOD("Array", "concat", T_ARRAY);
REG_METHOD("Array", "push", T_ARRAY);
REG_METHOD("Array", "join", T_STRING);
REG_METHOD("Array", "first", MIXED);
REG_METHOD("Array", "last", MIXED);
REG_METHOD("Array", "sample", MIXED);
REG_METHOD("Array", "length", T_INT);
REG_METHOD("Array", "size", T_INT);
REG_METHOD("Array", "count", T_INT);
REG_METHOD("Array", "sum", MIXED);
REG_METHOD("Array", "min", MIXED);
REG_METHOD("Array", "max", MIXED);
REG_METHOD("Array", "include?", T_BOOL);
REG_METHOD("Array", "empty?", T_BOOL);
REG_METHOD("Array", "any?", T_BOOL);
REG_METHOD("Array", "all?", T_BOOL);
REG_METHOD("Array", "none?", T_BOOL);
REG_METHOD("Array", "each", T_ARRAY);
REG_METHOD("Array", "each_with_index", T_ARRAY);
REG_METHOD("Array", "to_a", T_ARRAY);
REG_METHOD("Array", "group_by", T_HASH);
REG_METHOD("Array", "each_slice", MIXED);
REG_METHOD("Array", "zip", T_ARRAY);
/* ── Hash ── */
REG_METHOD("Hash", "keys", T_ARRAY);
REG_METHOD("Hash", "values", T_ARRAY);
REG_METHOD("Hash", "merge", T_HASH);
REG_METHOD("Hash", "merge!", T_HASH);
REG_METHOD("Hash", "fetch", MIXED);
REG_METHOD("Hash", "dig", MIXED);
REG_METHOD("Hash", "key?", T_BOOL);
REG_METHOD("Hash", "has_key?", T_BOOL);
REG_METHOD("Hash", "include?", T_BOOL);
REG_METHOD("Hash", "empty?", T_BOOL);
REG_METHOD("Hash", "size", T_INT);
REG_METHOD("Hash", "length", T_INT);
REG_METHOD("Hash", "count", T_INT);
REG_METHOD("Hash", "each", T_HASH);
REG_METHOD("Hash", "map", T_ARRAY);
REG_METHOD("Hash", "select", T_HASH);
REG_METHOD("Hash", "reject", T_HASH);
REG_METHOD("Hash", "to_a", T_ARRAY);
REG_METHOD("Hash", "to_h", T_HASH);
REG_METHOD("Hash", "transform_values", T_HASH);
REG_METHOD("Hash", "transform_keys", T_HASH);
REG_METHOD("Hash", "symbolize_keys", T_HASH); /* ActiveSupport */
REG_METHOD("Hash", "stringify_keys", T_HASH); /* ActiveSupport */
REG_METHOD("Hash", "deep_symbolize_keys", T_HASH);
REG_METHOD("Hash", "with_indifferent_access", T_HASH);
/* ── Integer / Float ── */
REG_METHOD("Integer", "to_s", T_STRING);
REG_METHOD("Integer", "to_i", T_INT);
REG_METHOD("Integer", "to_f", T_FLOAT);
REG_METHOD("Integer", "times", MIXED);
REG_METHOD("Integer", "upto", MIXED);
REG_METHOD("Integer", "abs", T_INT);
REG_METHOD("Integer", "zero?", T_BOOL);
REG_METHOD("Integer", "positive?", T_BOOL);
REG_METHOD("Integer", "negative?", T_BOOL);
REG_METHOD("Integer", "even?", T_BOOL);
REG_METHOD("Integer", "odd?", T_BOOL);
REG_METHOD("Float", "to_s", T_STRING);
REG_METHOD("Float", "to_i", T_INT);
REG_METHOD("Float", "round", T_INT);
REG_METHOD("Float", "ceil", T_INT);
REG_METHOD("Float", "floor", T_INT);
REG_METHOD("Float", "abs", T_FLOAT);
/* ── Symbol / misc ── */
REG_METHOD("Symbol", "to_s", T_STRING);
REG_METHOD("Symbol", "to_sym", T_SYM);
REG_METHOD("Symbol", "to_proc", cbm_type_named(arena, "Proc"));
REG_METHOD("Range", "to_a", T_ARRAY);
REG_METHOD("Range", "each", MIXED);
REG_METHOD("Range", "map", T_ARRAY);
REG_METHOD("Range", "include?", T_BOOL);
REG_METHOD("Time", "to_s", T_STRING);
REG_METHOD("Time", "to_i", T_INT);
REG_METHOD("Time", "strftime", T_STRING);
REG_METHOD("Time", "year", T_INT);
REG_METHOD("Time", "month", T_INT);
REG_METHOD("Time", "day", T_INT);
REG_SMETHOD("Time", "now", cbm_type_named(arena, "Time"));
REG_SMETHOD("Time", "at", cbm_type_named(arena, "Time"));
REG_SMETHOD("Time", "parse", cbm_type_named(arena, "Time"));
REG_SMETHOD("Time", "current", cbm_type_named(arena, "Time")); /* ActiveSupport */
REG_SMETHOD("Time", "zone", MIXED);
REG_SMETHOD("File", "read", T_STRING);
REG_SMETHOD("File", "readlines", T_ARRAY);
REG_SMETHOD("File", "open", cbm_type_named(arena, "File"));
REG_SMETHOD("File", "exist?", T_BOOL);
REG_SMETHOD("File", "basename", T_STRING);
REG_SMETHOD("File", "dirname", T_STRING);
REG_SMETHOD("File", "join", T_STRING);
REG_SMETHOD("File", "expand_path", T_STRING);
REG_SMETHOD("JSON", "parse", MIXED);
REG_SMETHOD("JSON", "generate", T_STRING);
REG_SMETHOD("JSON", "pretty_generate", T_STRING);
REG_SMETHOD("Math", "sqrt", T_FLOAT);
REG_SMETHOD("Math", "pow", T_FLOAT);
REG_SMETHOD("Math", "sin", T_FLOAT);
REG_SMETHOD("Math", "cos", T_FLOAT);
REG_SMETHOD("Math", "log", T_FLOAT);
/* ── Object (universal receivers; ActiveSupport predicates) ── */
REG_METHOD("Object", "to_s", T_STRING);
REG_METHOD("Object", "inspect", T_STRING);
REG_METHOD("Object", "freeze", MIXED);
REG_METHOD("Object", "frozen?", T_BOOL);
REG_METHOD("Object", "dup", MIXED);
REG_METHOD("Object", "clone", MIXED);
REG_METHOD("Object", "tap", MIXED);
REG_METHOD("Object", "then", MIXED);
REG_METHOD("Object", "nil?", T_BOOL);
REG_METHOD("Object", "is_a?", T_BOOL);
REG_METHOD("Object", "kind_of?", T_BOOL);
REG_METHOD("Object", "instance_of?", T_BOOL);
REG_METHOD("Object", "respond_to?", T_BOOL);
REG_METHOD("Object", "blank?", T_BOOL); /* ActiveSupport */
REG_METHOD("Object", "present?", T_BOOL); /* ActiveSupport */
REG_METHOD("Object", "presence", MIXED); /* ActiveSupport */
REG_METHOD("Object", "to_json", T_STRING); /* ActiveSupport / json */
/* ── Kernel built-ins (global functions) ── */
REG_BUILTIN("puts", MIXED);
REG_BUILTIN("print", MIXED);
REG_BUILTIN("p", MIXED);
REG_BUILTIN("pp", MIXED);
REG_BUILTIN("require", T_BOOL);
REG_BUILTIN("require_relative", T_BOOL);
REG_BUILTIN("raise", MIXED);
REG_BUILTIN("fail", MIXED);
REG_BUILTIN("loop", MIXED);
REG_BUILTIN("sleep", T_INT);
REG_BUILTIN("rand", MIXED);
REG_BUILTIN("format", T_STRING);
REG_BUILTIN("sprintf", T_STRING);
REG_BUILTIN("gets", T_STRING);
REG_BUILTIN("exit", MIXED);
REG_BUILTIN("abort", MIXED);
REG_BUILTIN("lambda", cbm_type_named(arena, "Proc"));
REG_BUILTIN("proc", cbm_type_named(arena, "Proc"));
REG_BUILTIN("binding", MIXED);
REG_BUILTIN("catch", MIXED);
REG_BUILTIN("throw", MIXED);
REG_BUILTIN("attr_accessor", MIXED);
REG_BUILTIN("attr_reader", MIXED);
REG_BUILTIN("attr_writer", MIXED);
/* ── Rails: ActiveRecord::Base ──────────────────────────────────
* Class-side query interface. Return types here are generic; the
* resolver's AR special case substitutes the receiving model's type
* (User.find -> User) when the receiver's ancestry reaches
* ActiveRecord.Base / ApplicationRecord. */
REG_TYPE("ActiveRecord.Base");
REG_TYPE("ApplicationRecord");
static const char *ar_class_methods[] = {"find",
"find_by",
"find_by!",
"find_or_create_by",
"find_or_initialize_by",
"where",
"all",
"first",
"last",
"take",
"create",
"create!",
"new",
"count",
"order",
"limit",
"offset",
"joins",
"left_joins",
"includes",
"preload",
"eager_load",
"group",
"having",
"distinct",
"pluck",
"ids",
"exists?",
"none",
"unscoped",
"select",
"sum",
"maximum",
"minimum",
"average",
"destroy_all",
"delete_all",
"update_all",
"transaction",
"find_each",
NULL};
for (int i = 0; ar_class_methods[i]; i++)
REG_SMETHOD("ActiveRecord.Base", ar_class_methods[i], MIXED);
REG_METHOD("ActiveRecord.Base", "save", T_BOOL);
REG_METHOD("ActiveRecord.Base", "save!", T_BOOL);
REG_METHOD("ActiveRecord.Base", "update", T_BOOL);
REG_METHOD("ActiveRecord.Base", "update!", T_BOOL);
REG_METHOD("ActiveRecord.Base", "destroy", MIXED);
REG_METHOD("ActiveRecord.Base", "destroy!", MIXED);
REG_METHOD("ActiveRecord.Base", "delete", MIXED);
REG_METHOD("ActiveRecord.Base", "reload", MIXED);
REG_METHOD("ActiveRecord.Base", "valid?", T_BOOL);
REG_METHOD("ActiveRecord.Base", "invalid?", T_BOOL);
REG_METHOD("ActiveRecord.Base", "persisted?", T_BOOL);
REG_METHOD("ActiveRecord.Base", "new_record?", T_BOOL);
REG_METHOD("ActiveRecord.Base", "errors", MIXED);
REG_METHOD("ActiveRecord.Base", "attributes", T_HASH);
REG_METHOD("ActiveRecord.Base", "assign_attributes", MIXED);
REG_METHOD("ActiveRecord.Base", "update_attribute", T_BOOL);
REG_METHOD("ActiveRecord.Base", "touch", T_BOOL);
REG_METHOD("ActiveRecord.Base", "id", T_INT);
/* ── Rails: ActionController::Base / ::API ── */
REG_TYPE("ActionController.Base");
REG_TYPE("ActionController.API");
static const char *ac_receivers[] = {"ActionController.Base", "ActionController.API", NULL};
for (int i = 0; ac_receivers[i]; i++) {
const char *acr = ac_receivers[i];
REG_METHOD(acr, "render", MIXED);
REG_METHOD(acr, "redirect_to", MIXED);
REG_METHOD(acr, "head", MIXED);
REG_METHOD(acr, "params", MIXED);
REG_METHOD(acr, "session", MIXED);
REG_METHOD(acr, "cookies", MIXED);
REG_METHOD(acr, "request", MIXED);
REG_METHOD(acr, "response", MIXED);
REG_METHOD(acr, "respond_to", MIXED);
REG_METHOD(acr, "flash", MIXED);
}
REG_SMETHOD("ActionController.Base", "before_action", MIXED);
REG_SMETHOD("ActionController.Base", "after_action", MIXED);
REG_SMETHOD("ActionController.Base", "skip_before_action", MIXED);
REG_SMETHOD("ActionController.API", "before_action", MIXED);
REG_SMETHOD("ActionController.API", "after_action", MIXED);
/* ── Rails: jobs / mailers (constants only, for superclass chains) ── */
REG_TYPE("ActiveJob.Base");
REG_TYPE("ApplicationJob");
REG_TYPE("ActionMailer.Base");
REG_TYPE("ApplicationMailer");
REG_TYPE("ApplicationController");
REG_SMETHOD("ActiveJob.Base", "perform_later", MIXED);
REG_SMETHOD("ActiveJob.Base", "perform_now", MIXED);
REG_SMETHOD("ActiveJob.Base", "set", MIXED);
REG_METHOD("ActionMailer.Base", "mail", MIXED);
REG_SMETHOD("ActionMailer.Base", "with", MIXED);
/* ── Minitest/RSpec anchors (superclass chains in test files) ── */
REG_TYPE("Minitest.Test");
REG_TYPE("ActiveSupport.TestCase");
REG_TYPE("ActionDispatch.IntegrationTest");
}
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
#ifndef CBM_LSP_RUBY_LSP_H
#define CBM_LSP_RUBY_LSP_H
#include "type_rep.h"
#include "scope.h"
#include "type_registry.h"
#include "../cbm.h"
/* Ruby mixin kinds. Lookup order for an instance method on class C:
* prepended modules (last prepended first) → C's own methods →
* included modules (last included first) → superclass chain.
* `extend` adds a module's instance methods to C's singleton (class-side). */
typedef enum {
RUBY_MIXIN_INCLUDE = 0,
RUBY_MIXIN_PREPEND,
RUBY_MIXIN_EXTEND,
} RubyMixinKind;
/* One class/module discovered in the file (or, cross-file, in the project).
* `path` is the dotted source-level constant path (e.g. "Admin.User" for
* `Admin::User`); `qn` is the full graph QN (module_qn.path for file-local
* classes; the extractor QN for cross-file defs). `superclass_qn` is resolved
* against the class table + stdlib after collection; NULL when unknown. */
typedef struct {
const char *path;
const char *qn;
const char *superclass_ref; /* raw dotted superclass spelling, or NULL */
const char *superclass_qn; /* resolved QN (table/stdlib), or NULL */
bool is_module; /* `module` (mixin source) vs `class` */
} RubyClassInfo;
/* One `include`/`prepend`/`extend` record: owner class QN → module ref. */
typedef struct {
const char *owner_qn; /* class/module whose body held the call */
const char *module_ref; /* raw dotted module spelling */
const char *module_qn; /* resolved QN, or NULL */
RubyMixinKind kind;
} RubyMixinInfo;
/* One inferred instance-variable type: (class QN, @name) → type QN.
* `conflicted` latches when two assignments disagree — lookup then returns
* nothing (zero-edge guarantee beats a coin-flip type). */
typedef struct {
const char *class_qn;
const char *ivar_name; /* including the leading '@' */
const char *type_qn;
bool conflicted;
} RubyIvarInfo;
/* RubyLSPContext — per-file state for Ruby type-aware call resolution.
* Mirrors PerlLSPContext / PHPLSPContext structure.
*
* Ruby specifics that shape this context:
* - Classes/modules nest lexically (`module A; class B`), and constants
* resolve through the lexical nesting outward (A::B sees A's constants
* before top-level ones). `nesting` tracks the current dotted path.
* - Inheritance is `class C < Base`; mixins arrive via include/prepend/
* extend and participate in method lookup (see RubyMixinKind).
* - `Foo.new` constructs a Foo and dispatches to Foo#initialize.
* - `def m` defines an instance method; `def self.m` a singleton (class)
* method. Singleton methods are registered under the receiver key
* "<class_qn>.self" — constants cannot be lowercase, so the suffix can
* never collide with a real nested-constant path. */
typedef struct {
CBMArena *arena;
const char *source;
int source_len;
const CBMTypeRegistry *registry;
/* Mutable alias of `registry`, set only while PASS 1 populates method
* tables (mirrors perl_lsp's mutable-reg build phase). NULL afterwards. */
CBMTypeRegistry *build_reg;
CBMScope *current_scope;
/* Lexical nesting: dotted constant path of the enclosing class/module
* chain ("" at top level, "A.B" inside `module A; class B`). */
const char *nesting;
/* Class/module table (pass 1). */
RubyClassInfo *classes;
int class_count;
int class_cap;
/* Mixin table (pass 1). */
RubyMixinInfo *mixins;
int mixin_count;
int mixin_cap;
/* Instance-variable type table (pass 1). */
RubyIvarInfo *ivars;
int ivar_count;
int ivar_cap;
/* Cross-file import map (require/require_relative resolved by the
* pipeline). Unused per-file: Ruby constants are file-global. */
const char **import_names;
const char **import_qns;
int import_count;
/* Current enclosing context during the resolution walk. */
const char *enclosing_class_qn; /* class QN, or NULL at top level */
const char *enclosing_func_qn; /* enclosing method QN, or NULL */
bool in_singleton_method; /* `def self.m` / class << self scope */
const char *module_qn;
/* Output: resolved calls accumulate here. */
CBMResolvedCallArray *resolved_calls;
/* Recursion guards. */
int eval_depth;
int walk_depth;
/* Debug mode (CBM_LSP_DEBUG env). */
bool debug;
} RubyLSPContext;
/* Initialize a RubyLSPContext for processing one file. */
void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len,
const CBMTypeRegistry *registry, const char *module_qn,
CBMResolvedCallArray *out);
/* Resolve a dotted constant path (e.g. "Foo", "A.B") against the class
* table using the current lexical nesting, then the stdlib. Returns the
* resolved QN or NULL. */
const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path);
/* Look up an instance method on a class, walking prepends → own → includes →
* superclass chain. Returns the resolved CBMRegisteredFunc or NULL. */
const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn,
const char *method_name);
/* Look up a singleton (class-side) method: own singleton methods →
* `extend`ed modules' instance methods → superclass singleton chain. */
const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn,
const char *method_name);
/* Evaluate a Ruby expression's type. May return CBM_TYPE_UNKNOWN. */
const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node);
/* Entry point: build registry from file defs + stdlib, then run resolution.
* Called from cbm_extract_file() via the language dispatch in cbm.c. */
void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len,
TSNode root);
/* Register Ruby core stdlib types/methods + a curated Rails surface
* (ActiveRecord/ActiveSupport/ActionController) into a registry. */
void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena);
#endif /* CBM_LSP_RUBY_LSP_H */
+2
View File
@@ -14,6 +14,8 @@
#include "lsp/generated/php_stdlib_data.c"
#include "lsp/perl_lsp.c"
#include "lsp/generated/perl_stdlib_data.c"
#include "lsp/ruby_lsp.c"
#include "lsp/generated/ruby_stdlib_data.c"
#include "lsp/generated/python_stdlib_data.c"
#include "lsp/py_lsp.c"
#include "lsp/ts_lsp.c"
+5 -1
View File
@@ -27,7 +27,7 @@
# USAGE
# bash scripts/check-lsp-originality.sh [--lang NAME] [--refresh]
# [--list-candidates] [--help]
# --lang NAME scan against ONE reference only (py|ts|go|cs|c|java|kotlin|rust|php|perl)
# --lang NAME scan against ONE reference only (py|ts|go|cs|c|java|kotlin|rust|php|perl|ruby)
# --refresh re-fetch reference sources even if cached
# --list-candidates print the extracted local tokens and exit (no fetch; self-test)
# Exit 0 = no verbatim overlap found. Exit 1 = overlap(s) to review by a human.
@@ -64,6 +64,10 @@ REFS=(
# Perl (PR #461) was authored clean-room. We scan against PerlNavigator (MIT)
# — the leading OSS Perl language server — for defensive copy-detection.
"perl|https://github.com/bscan/PerlNavigator|server/src"
# Ruby was authored clean-room from the tree-sitter-ruby grammar + Ruby
# language semantics. We scan against Shopify's ruby-lsp (MIT) — the
# leading OSS Ruby language server — for defensive copy-detection.
"ruby|https://github.com/Shopify/ruby-lsp|lib"
)
ONLY_LANG=""; REFRESH=0; LIST_ONLY=0
+2
View File
@@ -733,6 +733,7 @@ extern void suite_php_lsp(void);
extern void suite_cs_lsp(void);
extern void suite_cs_lsp_bench(void);
extern void suite_perl_lsp(void);
extern void suite_ruby_lsp(void);
extern void suite_scope(void);
extern void suite_type_rep(void);
extern void suite_py_lsp(void);
@@ -1026,6 +1027,7 @@ int main(int argc, char **argv) {
RUN_SELECTED_SUITE(cs_lsp);
RUN_SELECTED_SUITE_PERF(cs_lsp_bench);
RUN_SELECTED_SUITE(perl_lsp);
RUN_SELECTED_SUITE(ruby_lsp);
RUN_SELECTED_SUITE(py_lsp);
RUN_SELECTED_SUITE(kotlin_lsp);
RUN_SELECTED_SUITE(rust_lsp);
+636
View File
@@ -0,0 +1,636 @@
/*
* 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)
* 13. ActiveRecord model typing (User.find(1).full_name)
* 14. Top-level function calls
* 15. Unresolvable receiver emits NO spurious edge (negative)
* 16. send() / the whole dynamic-dispatch family emits NO edge (negative)
*
* 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.
* Ruby defs weave the class path into the QN (module_qn.Animal.speak), so
* for these single-file fixtures ("test"/"main.rb" -> module QN test.main)
* callee fragments like "main.Animal.speak" are unique join keys.
*/
#include "test_framework.h"
#include "cbm.h"
#include "../src/pipeline/lsp_resolve.h"
#include "lsp/ruby_lsp.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;
}
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();
}
/* ── 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();
}
/* ── 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);
/* Lexical reference from sibling class inside the module. */
ASSERT(require_resolved(r, "Panel.show", "User.lookup") >= 0);
ASSERT(require_resolved(r, "Panel.show", "User.name") >= 0);
/* Fully-qualified reference from outside. */
ASSERT(require_resolved(r, "Outside.probe", "User.lookup") >= 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();
}
/* ── 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`. */
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 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();
}
/* ── 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_extend_mixin);
RUN_TEST(rubylsp_super_dispatch);
RUN_TEST(rubylsp_ivar_typing);
RUN_TEST(rubylsp_ivar_conflict_no_edge);
RUN_TEST(rubylsp_chained_constructor_call);
RUN_TEST(rubylsp_nested_modules);
RUN_TEST(rubylsp_activerecord_model_typing);
RUN_TEST(rubylsp_top_level_function);
RUN_TEST(rubylsp_unknown_receiver_no_edge);
RUN_TEST(rubylsp_send_no_edge);
RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge);
}