diff --git a/Makefile.cbm b/Makefile.cbm index 9fc93b73d..7f93ffc9b 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -832,6 +832,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 @@ -955,7 +956,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 ──────────────────────────────────────────── diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 23b871dbc..427b20830 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -16,6 +16,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" @@ -3943,6 +3944,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C 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); } diff --git a/internal/cbm/lsp/generated/ruby_stdlib_data.c b/internal/cbm/lsp/generated/ruby_stdlib_data.c new file mode 100644 index 000000000..83e853e65 --- /dev/null +++ b/internal/cbm/lsp/generated/ruby_stdlib_data.c @@ -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 + +#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 ".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"); +} diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c new file mode 100644 index 000000000..a9ae8f2ff --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.c @@ -0,0 +1,2020 @@ +/* + * ruby_lsp.c — Ruby Light Semantic Pass. + * + * In-process type-aware call resolver for Ruby. Mirrors the perl_lsp.c / + * php_lsp.c shape: + * 1. Build a CBMTypeRegistry from file-local definitions + stdlib + * (Ruby core classes + curated Rails surface), with per-class method + * tables keyed by receiver QN. Singleton (class-side) methods are + * keyed ".self" — Ruby constants cannot be lowercase, so + * the suffix never collides with a real nested-constant path. + * 2. ruby_lsp_process_file does a multi-pass walk: + * PASS 1 — collect class/module declarations (with lexical nesting), + * superclasses, include/prepend/extend mixins, and method tables + * (instance vs singleton classified from the AST — `def m` vs + * `def self.m` / `class << self`). Defs and mixin calls inside a + * BLOCK (`class_methods do … end`, `included do … end`) belong to + * some other module and are not recorded. + * PASS 1.5 — infer instance-variable types from `@x = Const.new` + * (and `@x ||= Const.new`) inside method bodies. Any other write + * to the ivar latches it as untypable. + * PASS 2 — walk method bodies, track local variable types through + * assignments with Ruby's scope gates (fresh scope per def/class, + * chained scope per block, parameters unknown, untyped writes + * rebind to unknown), and resolve call expressions into + * CBMResolvedCall edges via Ruby method lookup (prepends → own → + * includes → superclass chain; extends feed the singleton side). + * The walk fails closed the moment an ancestor is unresolved. + * + * Verified tree-sitter-ruby node/field names (vendored compiled grammar at + * internal/cbm/vendored/grammars/ruby/parser.c — ts_symbol_names and + * ts_field_names tables): + * - call : fields `receiver`, `method`, `arguments`, `block`. Both + * `foo(x)` and command calls (`foo x`) surface as "call" nodes. A lone + * `foo` is an `identifier`, never a call — and the extractor emits no + * call for it either, so the resolver does not treat it as one. + * - class : fields `name` (constant | scope_resolution), `superclass` + * (a `superclass` wrapper node), `body` (body_statement). + * - module : fields `name`, `body`. + * - method : fields `name`, `parameters`, `body`. + * - singleton_method : fields `object` (self | constant), `name`, `body`. + * - singleton_class : `class << self` — fields `value`, body children. + * - assignment : fields `left`, `right` (`left` may be a + * left_assignment_list for `a, b = …`). + * - operator_assignment : fields `left`, `operator`, `right` (`||=` etc.). + * - block / do_block / lambda : field `parameters` (block_parameters / + * lambda_parameters), body. + * - method_parameters children: identifier | optional_parameter | + * keyword_parameter | splat_parameter | hash_splat_parameter | + * block_parameter (each with a `name` field) | destructured_parameter. + * - scope_resolution : fields `scope` (may be absent for ::Foo), `name`. + * - leaves: constant, identifier, self, instance_variable, super, + * string, array, hash, integer, float, simple_symbol, regex. + * + * QN scheme (matches the structural extractor): Ruby HAS class node types + * (class/module), so defs are named `.` with + * parent_class = ``, where a class QN is the enclosing class QN + * (or the module QN at top level) joined with the declaration's RAW name + * text. A compact `class Admin::User` therefore yields `module_qn.Admin::User` + * while `module Admin; class User` yields `module_qn.Admin.User` — both are + * reproduced here byte-for-byte because the pipeline joins resolved rows to + * defs by exact caller name. The dotted form is used only for constant + * lookup. Constructor calls: the textual extractor rewrites `Widget.new` to + * callee "Widget" (extract_calls.c), so constructor rows emit the CLASS QN — + * the edge lands on the Class node, exactly like Python's lsp_constructor. + * + * Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge + * is emitted (false edges are worse than missing edges). Concretely: + * - dynamic dispatch (`send`, `public_send`, `__send__`, `method_missing`, + * `define_method`, the eval/exec family) is never guessed through; + * - a block that rebinds `self` (instance_eval/exec, class_eval/exec, + * module_eval/exec, define_method, Class.new/Module.new) suppresses bare, + * `self.` and `super` dispatch inside it; + * - an unresolved prepend blocks even an own-method hit, an unresolved + * include/extend/superclass stops the walk, and a blocked walk never + * falls through to a same-named top-level def or the Object fallback; + * - a local or ivar written with anything the resolver cannot type loses + * its type rather than keeping a stale one. + */ + +#include "ruby_lsp.h" +#include "../helpers.h" +#include "../arena.h" +#include +#include +#include +#include + +/* Recursion cap for ruby_eval_expr_type — mirrors perl/php (unknown past 8). */ +#define RUBY_EVAL_MAX_DEPTH 8 + +/* Confidence levels. */ +#define RUBY_CONF_HIGH 0.95f /* constructor / static constant dispatch */ +#define RUBY_CONF_TYPED 0.90f /* inferred receiver type (locals, ivars, chains) */ + +/* Maximum AST-walk recursion depth (see scope.h rationale — graceful + * degradation on pathologically nested sources, never a crash). */ +#define CBM_LSP_RUBY_MAX_WALK_DEPTH 512 + +/* ── forward declarations ───────────────────────────────────────── */ + +static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node); +static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node); +static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node); +static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node); +static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node); +static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node); +static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit); +static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call); + +/* ── helpers ────────────────────────────────────────────────────── */ + +static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) { + return cbm_node_text(ctx->arena, node, ctx->source); +} + +/* Convert a constant expression node (constant | scope_resolution) into a + * dotted path ("Foo", "A.B.C"). Returns NULL for anything else. A leading + * "::Foo" (scope_resolution without a scope) yields "Foo" — the resolver's + * table probe covers the top-level meaning. */ +static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return NULL; + const char *k = ts_node_type(node); + if (strcmp(k, "constant") == 0) + return ruby_node_text(ctx, node); + if (strcmp(k, "scope_resolution") != 0) + return NULL; + TSNode scope = ts_node_child_by_field_name(node, "scope", 5); + TSNode name = ts_node_child_by_field_name(node, "name", 4); + if (ts_node_is_null(name)) + return NULL; + char *nm = ruby_node_text(ctx, name); + if (!nm || !nm[0]) + return NULL; + if (ts_node_is_null(scope)) + return nm; /* ::Foo */ + const char *sp = ruby_const_path(ctx, scope); + if (!sp || !sp[0]) + return nm; + return cbm_arena_sprintf(ctx->arena, "%s.%s", sp, nm); +} + +/* Join two dotted path fragments; either side may be empty/NULL. */ +static const char *ruby_path_join(CBMArena *a, const char *left, const char *right) { + if (!left || !left[0]) + return right; + if (!right || !right[0]) + return left; + return cbm_arena_sprintf(a, "%s.%s", left, right); +} + +/* Dotted form of a raw constant spelling: every "::" becomes ".". Used to + * turn nesting frames (raw) into class-table paths (dotted). */ +static const char *ruby_dotted(CBMArena *a, const char *raw) { + if (!raw) + return NULL; + size_t n = strlen(raw); + char *out = (char *)cbm_arena_alloc(a, n + 1); + if (!out) + return raw; + size_t j = 0; + for (size_t i = 0; i < n; i++) { + if (raw[i] == ':' && i + 1 < n && raw[i + 1] == ':') { + out[j++] = '.'; + i++; + continue; + } + out[j++] = raw[i]; + } + out[j] = '\0'; + return out; +} + +/* Class-declaration frame shared by every pass, so PASS 1, PASS 1.5 and + * PASS 2 compute identical nesting + class QNs. `raw_name` is the name + * field's source text (the extractor's spelling, "Admin::User"); `qn` is + * the extractor-shaped class QN; `path` is the dotted constant-table path. */ +typedef struct { + const char *raw_name; + const char *path; + const char *qn; + const char *saved_nesting; + const char *saved_class; + bool saved_singleton; + bool saved_self_unknown; + int saved_block_depth; +} RubyClassFrame; + +/* Enter a class/module node: compute names, push nesting + enclosing class. + * Returns false (nothing pushed) when the name is not a constant path. */ +static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *fr) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return false; + memset(fr, 0, sizeof(*fr)); + fr->raw_name = ruby_node_text(ctx, name); + if (!fr->raw_name || !fr->raw_name[0]) + return false; + fr->saved_nesting = ctx->nesting; + fr->saved_class = ctx->enclosing_class_qn; + fr->saved_singleton = ctx->in_singleton_method; + fr->saved_self_unknown = ctx->self_unknown; + fr->saved_block_depth = ctx->block_depth; + + fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); + if (fr->saved_class) + fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", fr->saved_class, fr->raw_name); + else if (ctx->module_qn) + fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fr->raw_name); + else + fr->qn = fr->raw_name; + + ctx->nesting = ruby_path_join(ctx->arena, fr->saved_nesting, fr->raw_name); + ctx->enclosing_class_qn = fr->qn; + ctx->in_singleton_method = false; + ctx->self_unknown = false; /* a class body names its self again */ + ctx->block_depth = 0; + return true; +} + +static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { + ctx->nesting = fr->saved_nesting; + ctx->enclosing_class_qn = fr->saved_class; + ctx->in_singleton_method = fr->saved_singleton; + ctx->self_unknown = fr->saved_self_unknown; + ctx->block_depth = fr->saved_block_depth; +} + +/* True when a QN belongs to the indexed project (module_qn's first segment + * is its prefix). Stdlib seed QNs ("String.upcase", "ActiveRecord.Base") + * never carry the project prefix, so edges are only ever emitted at + * project-defined targets. */ +static bool ruby_is_project_qn(RubyLSPContext *ctx, const char *qn) { + if (!qn || !ctx->module_qn || !ctx->module_qn[0]) + return false; + const char *dot = strchr(ctx->module_qn, '.'); + size_t plen = dot ? (size_t)(dot - ctx->module_qn) : strlen(ctx->module_qn); + return strncmp(qn, ctx->module_qn, plen) == 0 && qn[plen] == '.'; +} + +/* ── table growth helpers ───────────────────────────────────────── */ + +static RubyClassInfo *ruby_add_class(RubyLSPContext *ctx, const char *path, const char *qn, + bool is_module) { + if (!ctx || !path || !qn) + return NULL; + /* Reopened class/module: reuse the existing record. Keyed on the + * extractor QN, so `module A; class B` and `class A::B` in one file each + * keep their own spelling (constant lookup returns whichever was + * declared first; a miss on the other fails closed at the join). */ + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].qn, qn) == 0) + return &ctx->classes[i]; + } + if (ctx->class_count >= ctx->class_cap) { + int newcap = ctx->class_cap ? ctx->class_cap * 2 : 8; + RubyClassInfo *nc = + (RubyClassInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyClassInfo)); + if (!nc) + return NULL; + for (int i = 0; i < ctx->class_count; i++) + nc[i] = ctx->classes[i]; + ctx->classes = nc; + ctx->class_cap = newcap; + } + RubyClassInfo *ci = &ctx->classes[ctx->class_count++]; + memset(ci, 0, sizeof(*ci)); + ci->path = cbm_arena_strdup(ctx->arena, path); + ci->qn = cbm_arena_strdup(ctx->arena, qn); + ci->is_module = is_module; + return ci; +} + +static void ruby_add_mixin(RubyLSPContext *ctx, const char *owner_qn, const char *module_ref, + RubyMixinKind kind) { + if (!ctx || !owner_qn || !module_ref) + return; + if (ctx->mixin_count >= ctx->mixin_cap) { + int newcap = ctx->mixin_cap ? ctx->mixin_cap * 2 : 8; + RubyMixinInfo *nm = + (RubyMixinInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyMixinInfo)); + if (!nm) + return; + for (int i = 0; i < ctx->mixin_count; i++) + nm[i] = ctx->mixins[i]; + ctx->mixins = nm; + ctx->mixin_cap = newcap; + } + RubyMixinInfo *mi = &ctx->mixins[ctx->mixin_count++]; + memset(mi, 0, sizeof(*mi)); + mi->owner_qn = cbm_arena_strdup(ctx->arena, owner_qn); + mi->module_ref = cbm_arena_strdup(ctx->arena, module_ref); + mi->kind = kind; +} + +/* Find-or-create the (class, @ivar) record. `type_qn` may be NULL when the + * caller only wants to latch a conflict on a name never typed. */ +static RubyIvarInfo *ruby_ivar_slot(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + for (int i = 0; i < ctx->ivar_count; i++) { + RubyIvarInfo *iv = &ctx->ivars[i]; + if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) + return iv; + } + if (ctx->ivar_count >= ctx->ivar_cap) { + int newcap = ctx->ivar_cap ? ctx->ivar_cap * 2 : 8; + RubyIvarInfo *ni = + (RubyIvarInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyIvarInfo)); + if (!ni) + return NULL; + for (int i = 0; i < ctx->ivar_count; i++) + ni[i] = ctx->ivars[i]; + ctx->ivars = ni; + ctx->ivar_cap = newcap; + } + RubyIvarInfo *iv = &ctx->ivars[ctx->ivar_count++]; + memset(iv, 0, sizeof(*iv)); + iv->class_qn = cbm_arena_strdup(ctx->arena, class_qn); + iv->ivar_name = cbm_arena_strdup(ctx->arena, ivar_name); + return iv; +} + +/* Record `@x = ` (a resolved constructor). A second, different + * type latches the conflict. */ +static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name, + const char *type_qn) { + if (!ctx || !class_qn || !ivar_name || !type_qn) + return; + RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name); + if (!iv) + return; + if (!iv->type_qn) + iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); + else if (strcmp(iv->type_qn, type_qn) != 0) + iv->conflicted = true; /* disagreeing assignments — no type */ +} + +/* Latch `@x` as untypable: some write to it was not a resolved constructor + * (or `nil`). Lookup then returns nothing regardless of other assignments — + * the zero-edge guarantee beats a coin-flip type. */ +static void ruby_ivar_mark_conflict(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + if (!ctx || !class_qn || !ivar_name) + return; + RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name); + if (iv) + iv->conflicted = true; +} + +static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + if (!ctx || !class_qn || !ivar_name) + return NULL; + for (int i = 0; i < ctx->ivar_count; i++) { + RubyIvarInfo *iv = &ctx->ivars[i]; + if (!iv->conflicted && iv->type_qn && strcmp(iv->class_qn, class_qn) == 0 && + strcmp(iv->ivar_name, ivar_name) == 0) + return iv->type_qn; + } + return NULL; +} + +/* ── shared (project-wide) class table ──────────────────────────── */ + +static unsigned ruby_hash(const char *s) { + unsigned h = 2166136261u; + for (; *s; s++) + h = (h ^ (unsigned char)*s) * 16777619u; + return h; +} + +/* Probe one of the shared table's hash indexes. `by_path` selects the key. */ +static const RubyClassInfo *ruby_shared_find(const CBMRubyCrossRegistry *sh, const int *slots, + const char *key, bool by_path) { + if (!sh || !slots || !key) + return NULL; + unsigned i = ruby_hash(key) & sh->slot_mask; + while (slots[i]) { + const RubyClassInfo *c = &sh->classes[slots[i] - 1]; + if (strcmp(by_path ? c->path : c->qn, key) == 0) + return c; + i = (i + 1) & sh->slot_mask; + } + return NULL; +} + +/* This file's own declaration of a class wins; otherwise the project table. + * The own-file scan is bounded by the file, the shared probe is O(1). */ +static const RubyClassInfo *ruby_class_by_qn(RubyLSPContext *ctx, const char *qn) { + if (!qn) + return NULL; + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].qn, qn) == 0) + return &ctx->classes[i]; + } + return ruby_shared_find(ctx->shared, ctx->shared ? ctx->shared->by_qn : NULL, qn, false); +} + +/* Conventional Rails superclasses for stdlib-seeded types the class table + * does not carry (per-file extraction cannot see app/models/ + * application_record.rb from another file; the convention is fixed). */ +static const char *ruby_stdlib_superclass(const char *qn) { + if (!qn) + return NULL; + if (strcmp(qn, "ApplicationRecord") == 0) + return "ActiveRecord.Base"; + if (strcmp(qn, "ApplicationController") == 0) + return "ActionController.Base"; + if (strcmp(qn, "ApplicationJob") == 0) + return "ActiveJob.Base"; + if (strcmp(qn, "ApplicationMailer") == 0) + return "ActionMailer.Base"; + return NULL; +} + +/* Superclass QN of a class: table entry first, then the conventional + * stdlib chain above. */ +static const char *ruby_superclass_qn(RubyLSPContext *ctx, const char *class_qn) { + const RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci) + return ci->superclass_qn; + return ruby_stdlib_superclass(class_qn); +} + +/* ── constant resolution ────────────────────────────────────────── */ + +/* Class-table probe by dotted path: this file first, then the project. */ +static const char *ruby_class_qn_by_path(RubyLSPContext *ctx, const char *path) { + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return ctx->classes[i].qn; + } + const RubyClassInfo *c = + ruby_shared_find(ctx->shared, ctx->shared ? ctx->shared->by_path : NULL, path, true); + return c ? c->qn : NULL; +} + +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path) { + if (!ctx || !path || !path[0]) + return NULL; + /* Lexical nesting probe, innermost frame first — Ruby's constant lookup + * order. The nesting string holds one RAW frame per declaration joined + * by '.', so stripping at the last '.' drops exactly one frame: inside + * `module M; class A::B` (nesting "M.A::B") the probes are "M.A.B.path", + * "M.path", "path" — never "M.A.path", because `class A::B` does not + * open A's scope. */ + const char *nest = ctx->nesting ? ctx->nesting : ""; + char prefix[512]; + size_t nlen = strlen(nest); + if (nlen < sizeof(prefix)) { + memcpy(prefix, nest, nlen + 1); + while (prefix[0]) { + const char *cand = + cbm_arena_sprintf(ctx->arena, "%s.%s", ruby_dotted(ctx->arena, prefix), path); + const char *qn = ruby_class_qn_by_path(ctx, cand); + if (qn) + return qn; + char *last_dot = strrchr(prefix, '.'); + if (last_dot) + *last_dot = '\0'; + else + prefix[0] = '\0'; + } + } + const char *qn = ruby_class_qn_by_path(ctx, path); + if (qn) + return qn; + /* Stdlib types are keyed by their dotted bare path. */ + const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, path); + if (t) + return t->qualified_name; + return NULL; +} + +/* ── method lookup (Ruby MRO approximation) ─────────────────────── */ + +/* Depth cap shared by the instance/singleton lookup walks. */ +#define RUBY_LOOKUP_MAX_DEPTH CBM_LSP_MAX_LOOKUP_DEPTH + +/* Own method of one exact class (no ancestry walk), instance or singleton + * (".self" receiver key). A class reopened in another file has that file's + * QN, so when the class's own QN has no such method the other reopenings of + * the same constant path are searched too. */ +static const CBMRegisteredFunc *ruby_own_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name, bool singleton) { + const char *key = singleton ? cbm_arena_sprintf(ctx->arena, "%s.self", class_qn) : class_qn; + const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, key, method_name); + if (f || !ctx->shared) + return f; + const RubyClassInfo *ci = ruby_shared_find(ctx->shared, ctx->shared->by_qn, class_qn, false); + if (!ci || !ci->group_head) + return NULL; + for (int j = ci->group_head; j; j = ctx->shared->classes[j - 1].shard_next) { + const char *qn = ctx->shared->classes[j - 1].qn; + if (strcmp(qn, class_qn) == 0) + continue; + key = singleton ? cbm_arena_sprintf(ctx->arena, "%s.self", qn) : qn; + f = cbm_registry_lookup_method(ctx->registry, key, method_name); + if (f) + return f; + } + return NULL; +} + +static const CBMRegisteredFunc *ruby_own_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return ruby_own_method(ctx, class_qn, method_name, false); +} + +static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return ruby_own_method(ctx, class_qn, method_name, true); +} + +/* Fail-closed ancestry. Every lookup below carries a `blocked` flag that is + * set the moment the walk meets an ancestor it cannot see: a mixin whose + * module did not resolve, or a superclass reference that did not resolve. + * An unseen ancestor may define (or, for a prepend, shadow) the method, so + * from that point on no hit is trustworthy and callers must emit nothing — + * including NOT falling through to a same-named top-level def or to the + * Object typing fallback. Hits found BEFORE the unseen ancestor in Ruby's + * lookup order are still returned. */ + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth, bool *blocked); + +/* True when the class declares a superclass the finalize step could not + * resolve (stdlib-seeded conventions like ApplicationRecord DO resolve). */ +static bool ruby_superclass_unresolved(RubyLSPContext *ctx, const char *class_qn) { + const RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + return ci && ci->superclass_ref && !ci->superclass_qn; +} + +/* Search a mixin kind on `owner_qn` (most recent first — later mixins win), + * recursing into each module's own include chain. An UNRESOLVED mixin met + * before any hit blocks the walk. */ +static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, + RubyMixinKind kind, const char *method_name, + int depth, bool *blocked) { + for (int i = ctx->mixin_count - 1; i >= 0; i--) { + RubyMixinInfo *mi = &ctx->mixins[i]; + if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0) + continue; + if (!mi->module_qn) { + *blocked = true; /* unseen module — it may define/shadow the method */ + return NULL; + } + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, blocked); + if (f || *blocked) + return f; + } + return NULL; +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth, bool *blocked) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + /* Prepended modules shadow the class's own methods — an unresolved + * prepend therefore blocks even an own-method hit. */ + const CBMRegisteredFunc *f = + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, blocked); + if (f || *blocked) + return f; + f = ruby_own_instance_method(ctx, class_qn, method_name); + if (f) + return f; + /* An unresolved include stops the walk before the superclass chain. */ + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, blocked); + if (f || *blocked) + return f; + if (ruby_superclass_unresolved(ctx, class_qn)) { + *blocked = true; + return NULL; + } + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, blocked); + return NULL; +} + +/* Instance lookup with the universal-receiver typing fallback. Skipped when + * the ancestry is blocked: nothing below the unseen ancestor is trustworthy. */ +static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + bool *blocked) { + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, blocked); + if (f || *blocked) + return f; + /* Universal receiver fallback (Object / ActiveSupport predicates) — + * typing only; Object methods are stdlib entries, never project defs. */ + if (class_qn && strcmp(class_qn, "Object") != 0) + return cbm_registry_lookup_method(ctx->registry, "Object", method_name); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + bool blocked = false; + return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &blocked); +} + +static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth, bool *blocked) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + const CBMRegisteredFunc *f = ruby_own_singleton_method(ctx, class_qn, method_name); + if (f) + return f; + /* `extend Mod` adds Mod's instance methods class-side. */ + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, blocked); + if (f || *blocked) + return f; + if (ruby_superclass_unresolved(ctx, class_qn)) { + *blocked = true; + return NULL; + } + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, blocked); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + bool blocked = false; + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &blocked); +} + +/* ── ActiveRecord model detection (typing special case) ─────────── */ + +/* True when class_qn's superclass chain reaches ActiveRecord::Base (or the + * conventional ApplicationRecord intermediary, which per-file extraction + * usually cannot see through). */ +static bool ruby_is_ar_model(RubyLSPContext *ctx, const char *class_qn, int depth) { + if (!class_qn || depth > RUBY_LOOKUP_MAX_DEPTH) + return false; + if (strcmp(class_qn, "ActiveRecord.Base") == 0 || strcmp(class_qn, "ApplicationRecord") == 0) + return true; + const RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci && ci->superclass_ref && + (strcmp(ci->superclass_ref, "ApplicationRecord") == 0 || + strcmp(ci->superclass_ref, "ActiveRecord.Base") == 0)) + return true; + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_is_ar_model(ctx, sup, depth + 1); + return false; +} + +/* ActiveRecord class-side query methods that return the model (or a + * relation we approximate as the model, so chains like + * `User.where(...).first` keep their type). */ +static bool ruby_ar_returns_model(const char *m) { + static const char *names[] = {"find", + "find_by", + "find_by!", + "find_or_create_by", + "find_or_initialize_by", + "where", + "all", + "first", + "last", + "take", + "create", + "create!", + "new", + "order", + "limit", + "offset", + "joins", + "left_joins", + "includes", + "preload", + "eager_load", + "group", + "having", + "distinct", + "none", + "unscoped", + "find_each", + NULL}; + for (int i = 0; names[i]; i++) { + if (strcmp(m, names[i]) == 0) + return true; + } + return false; +} + +/* ── emit ───────────────────────────────────────────────────────── */ + +static void ruby_emit_resolved(RubyLSPContext *ctx, const char *callee_qn, const char *strategy, + float confidence, TSNode site) { + if (!ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn) + return; + /* Only project-defined targets become edges (stdlib rows would never + * land on a node and merely add noise). */ + if (!ruby_is_project_qn(ctx, callee_qn)) + return; + CBMResolvedCall rc = {0}; + rc.caller_qn = ctx->enclosing_func_qn; + rc.callee_qn = callee_qn; + rc.strategy = strategy; + rc.confidence = confidence; + rc.reason = NULL; + rc.kind = CBM_RESOLVED_INVOCATION; + if (!ts_node_is_null(site)) { + rc.site_start_byte = ts_node_start_byte(site); + rc.site_end_byte = ts_node_end_byte(site); + } + cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); +} + +/* ── expression typing ──────────────────────────────────────────── */ + +/* Return type of a registered func's signature, or unknown. */ +static const CBMType *ruby_func_return_type(const CBMRegisteredFunc *f) { + if (f && f->signature && f->signature->kind == CBM_TYPE_FUNC && + f->signature->data.func.return_types && f->signature->data.func.return_types[0]) { + return f->signature->data.func.return_types[0]; + } + return cbm_type_unknown(); +} + +const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return cbm_type_unknown(); + if (ctx->eval_depth >= RUBY_EVAL_MAX_DEPTH) + return cbm_type_unknown(); + ctx->eval_depth++; + const CBMType *result = cbm_type_unknown(); + const char *k = ts_node_type(node); + + if (strcmp(k, "identifier") == 0) { + char *txt = ruby_node_text(ctx, node); + if (txt) { + const CBMType *t = cbm_scope_lookup(ctx->current_scope, txt); + if (t) + result = t; + } + } else if (strcmp(k, "instance_variable") == 0) { + char *txt = ruby_node_text(ctx, node); + const char *tqn = ruby_ivar_type(ctx, ctx->enclosing_class_qn, txt); + if (tqn) + result = cbm_type_named(ctx->arena, tqn); + } else if (strcmp(k, "self") == 0) { + if (ctx->enclosing_class_qn && !ctx->in_singleton_method && !ctx->self_unknown) + result = cbm_type_named(ctx->arena, ctx->enclosing_class_qn); + } else if (strcmp(k, "call") == 0) { + result = ruby_eval_call_type(ctx, node, false); + } else if (strcmp(k, "assignment") == 0) { + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(right)) + result = ruby_eval_expr_type(ctx, right); + } else if (strcmp(k, "string") == 0 || strcmp(k, "string_content") == 0 || + strcmp(k, "heredoc_body") == 0 || strcmp(k, "chained_string") == 0) { + result = cbm_type_named(ctx->arena, "String"); + } else if (strcmp(k, "array") == 0 || strcmp(k, "string_array") == 0 || + strcmp(k, "symbol_array") == 0) { + result = cbm_type_named(ctx->arena, "Array"); + } else if (strcmp(k, "hash") == 0) { + result = cbm_type_named(ctx->arena, "Hash"); + } else if (strcmp(k, "integer") == 0) { + result = cbm_type_named(ctx->arena, "Integer"); + } else if (strcmp(k, "float") == 0) { + result = cbm_type_named(ctx->arena, "Float"); + } else if (strcmp(k, "simple_symbol") == 0 || strcmp(k, "delimited_symbol") == 0) { + result = cbm_type_named(ctx->arena, "Symbol"); + } else if (strcmp(k, "regex") == 0) { + result = cbm_type_named(ctx->arena, "Regexp"); + } else if (strcmp(k, "parenthesized_statements") == 0 || strcmp(k, "begin") == 0) { + /* Type of the last meaningful child. */ + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = nc; i > 0; i--) { + TSNode c = ts_node_named_child(node, i - 1); + if (ts_node_is_null(c)) + continue; + const char *ck = ts_node_type(c); + if (strcmp(ck, "comment") == 0) + continue; + result = ruby_eval_expr_type(ctx, c); + break; + } + } + + ctx->eval_depth--; + return result; +} + +/* ── call resolution core ───────────────────────────────────────── */ + +/* Dynamic-dispatch method names the resolver must never guess through. */ +static bool ruby_is_dynamic_dispatch(const char *m) { + return strcmp(m, "send") == 0 || strcmp(m, "public_send") == 0 || strcmp(m, "__send__") == 0 || + strcmp(m, "method_missing") == 0 || strcmp(m, "define_method") == 0 || + strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 || + strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 || + strcmp(m, "instance_variable_get") == 0 || strcmp(m, "instance_variable_set") == 0; +} + +/* True when `call` carries a block whose body runs with a DIFFERENT `self` + * than the enclosing lexical scope: the eval/exec family, define_method + * (self is the future receiver), and `Class.new { }` / `Module.new { }` + * (self is the anonymous class). Bare and `self.` calls in such a block + * must not dispatch on the enclosing class. */ +static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call) { + if (ts_node_is_null(ts_node_child_by_field_name(call, "block", 5))) + return false; + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + if (ts_node_is_null(meth)) + return false; + char *m = ruby_node_text(ctx, meth); + if (!m) + return false; + if (strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 || + strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 || + strcmp(m, "define_method") == 0) + return true; + if (strcmp(m, "new") == 0) { + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "constant") == 0) { + char *r = ruby_node_text(ctx, recv); + if (r && (strcmp(r, "Class") == 0 || strcmp(r, "Module") == 0)) + return true; + } + } + return false; +} + +/* Class-body macro-ish names PASS 1 already interpreted (or that never + * denote resolvable user calls). */ +static bool ruby_is_body_macro(const char *m) { + return strcmp(m, "include") == 0 || strcmp(m, "extend") == 0 || strcmp(m, "prepend") == 0 || + strcmp(m, "require") == 0 || strcmp(m, "require_relative") == 0 || + strcmp(m, "attr_accessor") == 0 || strcmp(m, "attr_reader") == 0 || + strcmp(m, "attr_writer") == 0 || strcmp(m, "private") == 0 || strcmp(m, "public") == 0 || + strcmp(m, "protected") == 0 || strcmp(m, "module_function") == 0 || + strcmp(m, "alias_method") == 0 || strcmp(m, "loop") == 0 || strcmp(m, "lambda") == 0 || + strcmp(m, "proc") == 0; +} + +/* Resolve a singleton-side dispatch on a resolved class QN. Emits (when + * `emit`) and returns the call's type. Also owns the ActiveRecord typing + * special case (User.find -> User). */ +static const CBMType *ruby_dispatch_singleton(RubyLSPContext *ctx, const char *class_qn, + const char *mname, bool emit, TSNode site) { + const CBMRegisteredFunc *f = ruby_lookup_singleton_method(ctx, class_qn, mname); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_class_method", RUBY_CONF_HIGH, site); + const CBMType *rt = ruby_func_return_type(f); + if (!cbm_type_is_unknown(rt)) + return rt; + /* Stdlib AR query entries return unknown; substitute the model. */ + if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname)) + return cbm_type_named(ctx->arena, class_qn); + return rt; + } + if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname)) + return cbm_type_named(ctx->arena, class_qn); + return cbm_type_unknown(); +} + +/* Resolve an instance-side dispatch on a receiver type QN. */ +static const CBMType *ruby_dispatch_instance(RubyLSPContext *ctx, const char *class_qn, + const char *mname, const char *strategy, bool emit, + TSNode site) { + const CBMRegisteredFunc *f = ruby_lookup_instance_method(ctx, class_qn, mname); + if (!f) + return cbm_type_unknown(); + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, strategy, RUBY_CONF_TYPED, site); + return ruby_func_return_type(f); +} + +/* Resolve one `call` node. When `emit` is set, resolved project targets are + * appended as CBMResolvedCall edges; the call's result type is returned + * either way (so chains and assignments can type through it). */ +static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit) { + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (ts_node_is_null(meth)) + return cbm_type_unknown(); + + /* `super(...)` is dispatched by the walker (ruby_resolve_super); as an + * expression its type is not modeled. */ + if (strcmp(ts_node_type(meth), "super") == 0) + return cbm_type_unknown(); + + char *mname = ruby_node_text(ctx, meth); + if (!mname || !mname[0]) + return cbm_type_unknown(); + if (ruby_is_dynamic_dispatch(mname)) + return cbm_type_unknown(); /* zero-edge guarantee */ + + /* Constructor: Const.new / A::B.new — the textual extractor rewrites + * the callee to the constant, so the row targets the CLASS QN. */ + if (strcmp(mname, "new") == 0 && !ts_node_is_null(recv)) { + const char *cpath = ruby_const_path(ctx, recv); + if (cpath) { + const char *cqn = ruby_resolve_constant(ctx, cpath); + if (cqn) { + if (emit) + ruby_emit_resolved(ctx, cqn, "ruby_constructor", RUBY_CONF_HIGH, call); + return cbm_type_named(ctx->arena, cqn); + } + return cbm_type_unknown(); + } + /* Fall through: `expr.new` on a non-constant receiver. */ + } + + if (ts_node_is_null(recv)) { + /* Bare call: self-dispatch inside methods, then file-level funcs. */ + if (ruby_is_body_macro(mname)) + return cbm_type_unknown(); + if (ctx->self_unknown) + return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ + if (ctx->enclosing_class_qn) { + bool blocked = false; + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, + &blocked) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, + &blocked); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + /* An unseen ancestor may define this method: do NOT fall through + * to a same-named top-level def. */ + if (blocked) + return cbm_type_unknown(); + } + const CBMRegisteredFunc *f = + cbm_registry_lookup_symbol(ctx->registry, ctx->module_qn, mname); + if (!f) + f = cbm_registry_lookup_func(ctx->registry, mname); /* Kernel builtins */ + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_function_local", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + return cbm_type_unknown(); + } + + const char *rk = ts_node_type(recv); + + /* self.m — explicit self dispatch. */ + if (strcmp(rk, "self") == 0) { + if (!ctx->enclosing_class_qn || ctx->self_unknown) + return cbm_type_unknown(); + bool blocked = false; + const CBMRegisteredFunc *f = ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth( + ctx, ctx->enclosing_class_qn, mname, 0, &blocked) + : ruby_lookup_instance_method_depth( + ctx, ctx->enclosing_class_qn, mname, 0, &blocked); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + return cbm_type_unknown(); + } + + /* Const.m / A::B.m — singleton dispatch on the resolved class. */ + if (strcmp(rk, "constant") == 0 || strcmp(rk, "scope_resolution") == 0) { + const char *cpath = ruby_const_path(ctx, recv); + const char *cqn = cpath ? ruby_resolve_constant(ctx, cpath) : NULL; + if (!cqn) + return cbm_type_unknown(); /* unknown constant — zero-edge */ + return ruby_dispatch_singleton(ctx, cqn, mname, emit, call); + } + + /* Typed receiver: locals, ivars, chained calls, literals. */ + const CBMType *rt = ruby_eval_expr_type(ctx, recv); + if (!rt || rt->kind != CBM_TYPE_NAMED) + return cbm_type_unknown(); /* unknown receiver — zero-edge */ + const char *strategy = "ruby_method_typed"; + if (strcmp(rk, "instance_variable") == 0) + strategy = "ruby_ivar_method"; + else if (strcmp(rk, "call") == 0) + strategy = "ruby_method_chained"; + const CBMType *out = + ruby_dispatch_instance(ctx, rt->data.named.qualified_name, mname, strategy, emit, call); + if (!cbm_type_is_unknown(out)) + return out; + /* AR relation approximation: chains on a model type keep the model. */ + if (ruby_is_ar_model(ctx, rt->data.named.qualified_name, 0) && ruby_ar_returns_model(mname)) + return rt; + return out; +} + +/* `super` / `super(...)`: resolve the enclosing method's name starting at + * the enclosing class's ancestry (own def skipped — that IS the caller). */ +static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { + if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn || ctx->self_unknown) + return; + const char *fq = ctx->enclosing_func_qn; + const char *mname = strrchr(fq, '.'); + mname = mname ? mname + 1 : fq; + const CBMRegisteredFunc *f = NULL; + bool blocked = false; + /* Search order mirrors Ruby: includes of the class, then superclass + * chain — the class's OWN def is the caller and must be skipped. An + * unresolved mixin or superclass on the way blocks the walk. */ + f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, + ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, + mname, 0, &blocked); + if (!f && !blocked && !ruby_superclass_unresolved(ctx, ctx->enclosing_class_qn)) { + const char *sup = ruby_superclass_qn(ctx, ctx->enclosing_class_qn); + if (sup) { + f = ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &blocked) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &blocked); + } + } + if (f && !blocked) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); + /* No known parent or unresolved — zero-edge guarantee. */ +} + +/* ── local scope (Ruby scope gates) ─────────────────────────────── */ + +/* Ruby scoping, as modeled here: + * - `def`, `class` and `module` bodies are SCOPE GATES: no outer local is + * visible inside, so each gets a fresh, unparented scope. + * - Blocks (`{ }`, `do end`, `-> { }`) close over the enclosing locals, so + * they get a CHAINED child scope. Block parameters shadow outer names + * for the block's extent only. + * - Assigning to a name that is already bound in an enclosing frame + * rebinds THAT frame (closure assignment), otherwise binds locally. + * - Parameters and any reassignment whose right-hand side has no proven + * type bind the name to UNKNOWN. A stale type must never survive a + * write the resolver could not read (zero-edge guarantee). */ + +/* Assign `type` to `name`: rebind the nearest existing binding in place, or + * bind in the current frame when the name is new. */ +static void ruby_scope_assign(RubyLSPContext *ctx, const char *name, const CBMType *type) { + if (!ctx->current_scope || !name || !name[0]) + return; + for (CBMScope *s = ctx->current_scope; s != NULL; s = s->parent) { + for (CBMScopeChunk *c = s->chunks; c != NULL; c = c->next) { + for (int i = 0; i < c->used; i++) { + if (c->bindings[i].name && strcmp(c->bindings[i].name, name) == 0) { + c->bindings[i].type = type; + c->bindings[i].callable_qn = NULL; + return; + } + } + } + } + cbm_scope_bind(ctx->current_scope, name, type); +} + +/* Bind every name introduced by a parameter list (method, block or lambda + * parameters, including destructured groups and block-local `; x` names) to + * UNKNOWN in the current frame. Parameters arrive untyped, and a parameter + * that shares its name with an outer local must shadow it. */ +static void ruby_bind_params_unknown(RubyLSPContext *ctx, TSNode params) { + if (ts_node_is_null(params)) + return; + const char *k = ts_node_type(params); + if (strcmp(k, "identifier") == 0) { + char *nm = ruby_node_text(ctx, params); + if (nm && nm[0]) + cbm_scope_bind(ctx->current_scope, nm, cbm_type_unknown()); + return; + } + if (strcmp(k, "optional_parameter") == 0 || strcmp(k, "keyword_parameter") == 0 || + strcmp(k, "splat_parameter") == 0 || strcmp(k, "hash_splat_parameter") == 0 || + strcmp(k, "block_parameter") == 0) { + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(params, "name", 4)); + return; + } + if (strcmp(k, "method_parameters") == 0 || strcmp(k, "block_parameters") == 0 || + strcmp(k, "lambda_parameters") == 0 || strcmp(k, "destructured_parameter") == 0) { + uint32_t nc = ts_node_named_child_count(params); + for (uint32_t i = 0; i < nc; i++) + ruby_bind_params_unknown(ctx, ts_node_named_child(params, i)); + } +} + +/* Rebind every local named on the left of a multiple assignment + * (`a, b = ...`, `a, (b, c) = ...`, `a, *rest = ...`) to UNKNOWN. */ +static void ruby_unbind_assignment_list(RubyLSPContext *ctx, TSNode lhs) { + if (ts_node_is_null(lhs)) + return; + const char *k = ts_node_type(lhs); + if (strcmp(k, "identifier") == 0) { + char *nm = ruby_node_text(ctx, lhs); + if (nm && nm[0]) + ruby_scope_assign(ctx, nm, cbm_type_unknown()); + return; + } + if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 || + strcmp(k, "rest_assignment") == 0) { + uint32_t nc = ts_node_named_child_count(lhs); + for (uint32_t i = 0; i < nc; i++) + ruby_unbind_assignment_list(ctx, ts_node_named_child(lhs, i)); + } +} + +/* ── assignment observer (scope binding) ────────────────────────── */ + +static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { + TSNode left = ts_node_child_by_field_name(assign, "left", 4); + TSNode right = ts_node_child_by_field_name(assign, "right", 5); + if (ts_node_is_null(left) || ts_node_is_null(right)) + return; + const char *lk = ts_node_type(left); + if (strcmp(lk, "left_assignment_list") == 0) { + ruby_unbind_assignment_list(ctx, left); /* no per-element typing */ + return; + } + if (strcmp(lk, "identifier") != 0) + return; /* only simple local targets are tracked */ + char *vname = ruby_node_text(ctx, left); + if (!vname || !vname[0]) + return; + const CBMType *rt = ruby_eval_expr_type(ctx, right); + /* An untyped right-hand side REBINDS to unknown — `a = Foo.new; a = x` + * must not leave `a` typed Foo. */ + ruby_scope_assign(ctx, vname, (rt && rt->kind == CBM_TYPE_NAMED) ? rt : cbm_type_unknown()); +} + +/* `a ||= expr` and the other compound assignments. `||=` on a name with no + * binding yet takes the right-hand type; on a name already bound to the same + * type it keeps it; every other case (untyped right-hand side, different + * type, or any non-`||=` operator) fails closed to UNKNOWN. */ +static void ruby_process_operator_assignment(RubyLSPContext *ctx, TSNode assign) { + TSNode left = ts_node_child_by_field_name(assign, "left", 4); + TSNode op = ts_node_child_by_field_name(assign, "operator", 8); + TSNode right = ts_node_child_by_field_name(assign, "right", 5); + if (ts_node_is_null(left) || ts_node_is_null(right)) + return; + if (strcmp(ts_node_type(left), "identifier") != 0) + return; + char *vname = ruby_node_text(ctx, left); + if (!vname || !vname[0]) + return; + char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op); + const CBMType *result = cbm_type_unknown(); + if (optxt && strcmp(optxt, "||=") == 0) { + const CBMType *rt = ruby_eval_expr_type(ctx, right); + if (rt && rt->kind == CBM_TYPE_NAMED) { + const CBMVarBinding *cur = cbm_scope_lookup_binding(ctx->current_scope, vname); + if (!cur) + result = rt; /* nil || Foo.new → Foo */ + else if (cur->type && cur->type->kind == CBM_TYPE_NAMED && + strcmp(cur->type->data.named.qualified_name, rt->data.named.qualified_name) == + 0) + result = rt; + } + } + ruby_scope_assign(ctx, vname, result); +} + +/* ── PASS 2: resolution walk ────────────────────────────────────── */ + +/* Process one method/singleton_method definition node. `def` is a scope + * gate: the body starts from a FRESH scope holding only its parameters. */ +static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton) { + CBMScope *saved_scope = ctx->current_scope; + const char *saved_func = ctx->enclosing_func_qn; + bool saved_singleton = ctx->in_singleton_method; + + ctx->current_scope = cbm_scope_push(ctx->arena, NULL); + ctx->in_singleton_method = singleton; + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10)); + + TSNode name = ts_node_child_by_field_name(node, "name", 4); + char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); + if (mname && mname[0]) { + const char *owner = ctx->enclosing_class_qn ? ctx->enclosing_class_qn : ctx->module_qn; + if (owner) + ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname); + else + ctx->enclosing_func_qn = cbm_arena_strdup(ctx->arena, mname); + } + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_resolve_calls_in_node(ctx, body); + + ctx->current_scope = saved_scope; + ctx->enclosing_func_qn = saved_func; + ctx->in_singleton_method = saved_singleton; +} + +/* Process a block / do-block / lambda: a CHAINED scope (closures see the + * enclosing locals) in which the block parameters shadow as UNKNOWN. */ +static void ruby_process_block(RubyLSPContext *ctx, TSNode node) { + CBMScope *saved_scope = ctx->current_scope; + ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope); + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10)); + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_null(c)) + continue; + /* Parameters were consumed above; walking them would be harmless + * (identifiers are not calls) but default values may hold calls. */ + ruby_resolve_calls_in_node(ctx, c); + } + ctx->current_scope = saved_scope; +} + +/* Process a class/module node during PASS 2: update nesting + enclosing + * class, then walk the body from a fresh scope (class bodies are scope + * gates too). */ +static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) + return; + CBMScope *saved_scope = ctx->current_scope; + ctx->current_scope = cbm_scope_push(ctx->arena, NULL); + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_resolve_calls_in_node(ctx, body); + + ctx->current_scope = saved_scope; + ruby_class_leave(ctx, &fr); +} + +static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_resolve_calls_in_node_inner(ctx, node); + ctx->walk_depth--; +} + +static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { + ruby_process_class_body(ctx, node); + return; + } + if (strcmp(k, "singleton_class") == 0) { + /* `class << self` — methods inside are singleton methods. */ + bool saved = ctx->in_singleton_method; + ctx->in_singleton_method = true; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) + ruby_resolve_calls_in_node(ctx, ts_node_named_child(node, i)); + ctx->in_singleton_method = saved; + return; + } + if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; + if (ctx->block_depth > 0) { + /* A def inside a block. Inside a class body the extractor emits + * no def for it, so no row could ever join: skip it. At top + * level the extractor emits a plain Function, so typed-receiver + * calls in its body may resolve — but its `self` is whatever + * the block's DSL made it, so bare and self. calls emit nothing. */ + if (ctx->enclosing_class_qn) + return; + bool saved = ctx->self_unknown; + ctx->self_unknown = true; + ruby_process_method(ctx, node, singleton); + ctx->self_unknown = saved; + return; + } + ruby_process_method(ctx, node, singleton); + return; + } + if (strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0) { + ctx->block_depth++; + ruby_process_block(ctx, node); + ctx->block_depth--; + return; + } + + if (strcmp(k, "assignment") == 0) + ruby_process_assignment(ctx, node); + else if (strcmp(k, "operator_assignment") == 0) + ruby_process_operator_assignment(ctx, node); + + if (strcmp(k, "call") == 0) { + TSNode meth = ts_node_child_by_field_name(node, "method", 6); + if (!ts_node_is_null(meth) && strcmp(ts_node_type(meth), "super") == 0) + ruby_resolve_super(ctx, node); + else + ruby_eval_call_type(ctx, node, true); + if (ruby_call_rebinds_self(ctx, node)) { + /* Receiver and arguments are ordinary expressions; the block + * body runs with a `self` this resolver cannot name. */ + TSNode blk = ts_node_child_by_field_name(node, "block", 5); + uint32_t cc = ts_node_child_count(node); + for (uint32_t i = 0; i < cc; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_null(c)) + continue; + if (ts_node_eq(c, blk)) { + bool saved = ctx->self_unknown; + ctx->self_unknown = true; + ruby_resolve_calls_in_node(ctx, c); + ctx->self_unknown = saved; + } else { + ruby_resolve_calls_in_node(ctx, c); + } + } + return; + } + /* Recurse into receiver/arguments/blocks for nested calls. */ + } else if (strcmp(k, "super") == 0) { + /* Bare `super` (no argument list) appears as a standalone node. */ + ruby_resolve_super(ctx, node); + return; + } + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_resolve_calls_in_node(ctx, c); + } +} + +/* ── PASS 1: class/mixin/method collection ──────────────────────── */ + +/* Register one method into the registry with the proper receiver key. */ +static void ruby_register_method(RubyLSPContext *ctx, CBMTypeRegistry *reg, const char *owner_qn, + const char *mname, bool singleton) { + if (!mname || !mname[0]) + return; + CBMRegisteredFunc rf; + memset(&rf, 0, sizeof(rf)); + rf.min_params = -1; + const char *owner = owner_qn ? owner_qn : ctx->module_qn; + rf.qualified_name = owner ? cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname) + : cbm_arena_strdup(ctx->arena, mname); + rf.short_name = cbm_arena_strdup(ctx->arena, mname); + if (owner_qn) { + rf.receiver_type = singleton ? cbm_arena_sprintf(ctx->arena, "%s.self", owner_qn) + : cbm_arena_strdup(ctx->arena, owner_qn); + } + const CBMType **rets = (const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets)); + if (rets) { + rets[0] = cbm_type_unknown(); + rets[1] = NULL; + } + rf.signature = cbm_type_func(ctx->arena, NULL, NULL, rets); + cbm_registry_add_func(reg, rf); +} + +/* PASS-1 refs are nesting-scoped: the finalize step resolves superclass and + * mixin references once the class table is complete, so PASS 1 records raw + * dotted refs plus the nesting string active at the declaration site + * (packed as "|" when nesting is set). */ +static const char *ruby_pack_ref(RubyLSPContext *ctx, const char *ref) { + const char *nest = ctx->nesting ? ctx->nesting : ""; + if (!nest[0]) + return ref; + return cbm_arena_sprintf(ctx->arena, "%s|%s", nest, ref); +} + +/* Resolve a packed "|" (or bare "") against the class + * table + stdlib, honoring the recorded lexical nesting. */ +static const char *ruby_resolve_packed_ref(RubyLSPContext *ctx, const char *packed) { + if (!packed) + return NULL; + const char *bar = strchr(packed, '|'); + const char *saved_nesting = ctx->nesting; + const char *ref = packed; + if (bar) { + ctx->nesting = cbm_arena_strndup(ctx->arena, packed, (size_t)(bar - packed)); + ref = bar + 1; + } else { + ctx->nesting = ""; + } + const char *qn = ruby_resolve_constant(ctx, ref); + ctx->nesting = saved_nesting; + return qn; +} + +/* Bare ref portion of a packed "|" string. */ +static const char *ruby_packed_bare_ref(const char *packed) { + if (!packed) + return NULL; + const char *bar = strchr(packed, '|'); + return bar ? bar + 1 : packed; +} + +/* Interpret a class-body `include X` / `prepend X` / `extend X` call, + * recording the mixin with the nesting packed for finalize-time resolve. */ +static void ruby_collect_mixin_call(RubyLSPContext *ctx, TSNode call) { + if (!ctx->enclosing_class_qn) + return; + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (ts_node_is_null(meth)) + return; + if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "self") != 0) + return; /* not a bare/self class-body macro */ + char *mname = ruby_node_text(ctx, meth); + if (!mname) + return; + RubyMixinKind kind; + if (strcmp(mname, "include") == 0) + kind = RUBY_MIXIN_INCLUDE; + else if (strcmp(mname, "prepend") == 0) + kind = RUBY_MIXIN_PREPEND; + else if (strcmp(mname, "extend") == 0) + kind = RUBY_MIXIN_EXTEND; + else + return; + TSNode args = ts_node_child_by_field_name(call, "arguments", 9); + if (ts_node_is_null(args)) + return; + uint32_t nc = ts_node_named_child_count(args); + for (uint32_t i = 0; i < nc; i++) { + const char *mpath = ruby_const_path(ctx, ts_node_named_child(args, i)); + if (!mpath) + continue; /* `extend self` etc. — not a constant */ + ruby_add_mixin(ctx, ctx->enclosing_class_qn, ruby_pack_ref(ctx, mpath), kind); + } +} + +static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) + return; + RubyClassInfo *ci = ruby_add_class(ctx, fr.path, fr.qn, is_module); + + /* Superclass: `class C < Base` — the `superclass` field wraps the + * expression after `<`. The ref is resolved from the DECLARING scope + * (the frame just pushed does not apply to its own superclass), so pack + * it with the outer nesting. */ + if (ci && !is_module && !ci->superclass_ref) { + TSNode sup = ts_node_child_by_field_name(node, "superclass", 10); + if (!ts_node_is_null(sup)) { + uint32_t sc = ts_node_named_child_count(sup); + for (uint32_t i = 0; i < sc; i++) { + const char *spath = ruby_const_path(ctx, ts_node_named_child(sup, i)); + if (spath) { + const char *inner = ctx->nesting; + ctx->nesting = fr.saved_nesting; + ci->superclass_ref = ruby_pack_ref(ctx, spath); + ctx->nesting = inner; + break; + } + } + } + } + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_pass1_scan(ctx, body); + ruby_class_leave(ctx, &fr); +} + +static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_pass1_scan_inner(ctx, node); + ctx->walk_depth--; +} + +/* Registry under construction during PASS 1 (methods are registered as + * they are discovered). Held in a file-scope slot only for the duration of + * cbm_run_ruby_lsp's pass-1 invocation — single-threaded per file. */ +static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0) { + ruby_pass1_class(ctx, node, false); + return; + } + if (strcmp(k, "module") == 0) { + ruby_pass1_class(ctx, node, true); + return; + } + if (strcmp(k, "singleton_class") == 0) { + bool saved = ctx->in_singleton_method; + ctx->in_singleton_method = true; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) + ruby_pass1_scan(ctx, ts_node_named_child(node, i)); + ctx->in_singleton_method = saved; + return; + } + if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + /* A def inside a block (`class_methods do … end`, `included do … + * end`, a describe group) is not a method of the enclosing + * class/module — and not a file-level function either. */ + if (ctx->block_depth > 0) + return; + bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; + TSNode name = ts_node_child_by_field_name(node, "name", 4); + char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); + if (ctx->build_reg) + ruby_register_method(ctx, ctx->build_reg, ctx->enclosing_class_qn, mname, singleton); + return; /* method bodies are PASS-2 territory */ + } + if (strcmp(k, "call") == 0 && ctx->block_depth == 0) { + /* `include X` inside a block (`included do include X end`, + * `Foo.class_eval { include X }`) applies to some other module. */ + TSNode meth = ts_node_child_by_field_name(node, "method", 6); + if (!ts_node_is_null(meth)) { + char *mn = ruby_node_text(ctx, meth); + if (mn && (strcmp(mn, "include") == 0 || strcmp(mn, "prepend") == 0 || + strcmp(mn, "extend") == 0)) { + ruby_collect_mixin_call(ctx, node); + return; + } + } + } + + bool is_block = + strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0; + if (is_block) + ctx->block_depth++; + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_pass1_scan(ctx, c); + } + if (is_block) + ctx->block_depth--; +} + +/* Finalize PASS 1: resolve superclass + mixin references now that the class + * table is complete. */ +static void ruby_finalize_refs(RubyLSPContext *ctx) { + for (int i = 0; i < ctx->class_count; i++) { + RubyClassInfo *ci = &ctx->classes[i]; + if (ci->superclass_ref) { + ci->superclass_qn = ruby_resolve_packed_ref(ctx, ci->superclass_ref); + /* Keep the bare ref spelling for AR-model heuristics. */ + ci->superclass_ref = ruby_packed_bare_ref(ci->superclass_ref); + } + } + for (int i = 0; i < ctx->mixin_count; i++) { + RubyMixinInfo *mi = &ctx->mixins[i]; + mi->module_qn = ruby_resolve_packed_ref(ctx, mi->module_ref); + mi->module_ref = ruby_packed_bare_ref(mi->module_ref); + } +} + +/* ── PASS 1.5: instance-variable type inference ─────────────────── */ + +/* Statically evaluate `Const.new` / `A::B.new` without scope context. */ +static const char *ruby_static_ctor_type(RubyLSPContext *ctx, TSNode expr) { + if (ts_node_is_null(expr) || strcmp(ts_node_type(expr), "call") != 0) + return NULL; + TSNode meth = ts_node_child_by_field_name(expr, "method", 6); + TSNode recv = ts_node_child_by_field_name(expr, "receiver", 8); + if (ts_node_is_null(meth) || ts_node_is_null(recv)) + return NULL; + char *mname = ruby_node_text(ctx, meth); + if (!mname || strcmp(mname, "new") != 0) + return NULL; + const char *cpath = ruby_const_path(ctx, recv); + if (!cpath) + return NULL; + return ruby_resolve_constant(ctx, cpath); +} + +/* Observe one write to `@x` (the `left` node) with right-hand side `right`. + * Only a RESOLVED `Const.new` types the ivar; `nil` is neutral (a common + * reset that says nothing about the type); every other right-hand side — + * an unresolved constant's `.new`, a method result, a parameter, a literal + * — latches the conflict, exactly like two different constructors do. */ +static void ruby_ivar_observe_write(RubyLSPContext *ctx, TSNode left, TSNode right) { + char *ivar = ruby_node_text(ctx, left); + if (!ivar || !ivar[0]) + return; + if (!ts_node_is_null(right) && strcmp(ts_node_type(right), "nil") == 0) + return; + const char *tqn = ruby_static_ctor_type(ctx, right); + if (tqn) + ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + else + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); +} + +/* Latch every instance variable named on the left of a multiple assignment + * (`@a, @b = ...`): per-element typing is not attempted. */ +static void ruby_ivar_conflict_assignment_list(RubyLSPContext *ctx, TSNode lhs) { + if (ts_node_is_null(lhs)) + return; + const char *k = ts_node_type(lhs); + if (strcmp(k, "instance_variable") == 0) { + char *ivar = ruby_node_text(ctx, lhs); + if (ivar && ivar[0]) + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); + return; + } + if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 || + strcmp(k, "rest_assignment") == 0) { + uint32_t nc = ts_node_named_child_count(lhs); + for (uint32_t i = 0; i < nc; i++) + ruby_ivar_conflict_assignment_list(ctx, ts_node_named_child(lhs, i)); + } +} + +static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_ivar_scan_inner(ctx, node); + ctx->walk_depth--; +} + +static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) + return; + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_ivar_scan(ctx, body); + ruby_class_leave(ctx, &fr); + return; + } + + if (ctx->enclosing_class_qn) { + if (strcmp(k, "assignment") == 0) { + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left)) { + const char *lk = ts_node_type(left); + if (strcmp(lk, "instance_variable") == 0) + ruby_ivar_observe_write(ctx, left, right); + else if (strcmp(lk, "left_assignment_list") == 0) + ruby_ivar_conflict_assignment_list(ctx, left); + } + } else if (strcmp(k, "operator_assignment") == 0) { + /* `@x ||= Const.new` is the memoised-constructor idiom and counts + * as a constructor assignment; any other operator is an untyped + * write. */ + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode op = ts_node_child_by_field_name(node, "operator", 8); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left) && strcmp(ts_node_type(left), "instance_variable") == 0) { + char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op); + if (optxt && strcmp(optxt, "||=") == 0) { + ruby_ivar_observe_write(ctx, left, right); + } else { + char *ivar = ruby_node_text(ctx, left); + if (ivar && ivar[0]) + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); + } + } + } + } + + /* A block that rebinds `self` writes ivars on some OTHER object; do not + * attribute them to the enclosing class. */ + TSNode skip_blk = {0}; + if (strcmp(k, "call") == 0 && ruby_call_rebinds_self(ctx, node)) + skip_blk = ts_node_child_by_field_name(node, "block", 5); + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c) && !(!ts_node_is_null(skip_blk) && ts_node_eq(c, skip_blk))) + ruby_ivar_scan(ctx, c); + } +} + +/* ── public API ─────────────────────────────────────────────────── */ + +void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len, + const CBMTypeRegistry *registry, const char *module_qn, + CBMResolvedCallArray *out) { + memset(ctx, 0, sizeof(*ctx)); + ctx->arena = arena; + ctx->source = source; + ctx->source_len = source_len; + ctx->registry = registry; + ctx->module_qn = module_qn; + ctx->nesting = ""; + ctx->resolved_calls = out; + ctx->current_scope = cbm_scope_push(arena, NULL); + + const char *dbg = getenv("CBM_LSP_DEBUG"); + ctx->debug = (dbg && dbg[0]); +} + +/* ── entry: cbm_run_ruby_lsp ────────────────────────────────────── */ + +void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root) { + if (!result || !arena || ts_node_is_null(root)) + return; + + CBMTypeRegistry reg; + cbm_registry_init(®, arena); + + /* Phase A: stdlib (Ruby core + curated Rails surface). */ + cbm_ruby_stdlib_register(®, arena); + + const char *module_qn = result->module_qn; + + RubyLSPContext ctx; + ruby_lsp_init(&ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); + + /* Phase B: PASS 1 — collect classes/modules, mixins, and methods from + * the AST (methods register directly into `reg`; the AST is ground + * truth for the instance-vs-singleton split the defs don't carry). */ + ctx.build_reg = ® + ruby_pass1_scan(&ctx, root); + ctx.build_reg = NULL; + ruby_finalize_refs(&ctx); + + /* Finalize the registry for O(1) lookups — index allocations go to a + * per-call scratch arena that dies with this call (see perl_lsp.c). */ + CBMArena idx_arena; + cbm_arena_init(&idx_arena); + cbm_registry_finalize_into(®, &idx_arena); + + /* Phase B.5: PASS 1.5 — instance-variable types (needs the finished + * class table + registry for constant resolution). */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ruby_ivar_scan(&ctx, root); + + /* Phase C: PASS 2 — resolution walk. */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ctx.enclosing_func_qn = NULL; + ctx.in_singleton_method = false; + ruby_resolve_calls_in_node(&ctx, root); + + if (ctx.debug) { + fprintf(stderr, "[ruby_lsp] module_qn=%s defs=%d resolved=%d classes=%d mixins=%d\n", + module_qn ? module_qn : "(null)", result->defs.count, result->resolved_calls.count, + ctx.class_count, ctx.mixin_count); + for (int i = 0; i < result->resolved_calls.count; i++) { + CBMResolvedCall *r = &result->resolved_calls.items[i]; + fprintf(stderr, "[ruby_lsp] %s -> %s [%s %.2f]\n", r->caller_qn, r->callee_qn, + r->strategy, r->confidence); + } + } + + cbm_arena_destroy(&idx_arena); +} + +/* ── cross-file: shared registry ────────────────────────────────── */ + +extern const TSLanguage *tree_sitter_ruby(void); + +/* Raw spelling of a cross-file class def below its module: the part of its QN + * after the module QN ("proj.app.models.user.Admin.Sub::User" with module + * "proj.app.models.user" → "Admin.Sub::User"), i.e. the extractor's frames + * joined by '.', each frame as written. NULL when the shape is odd. */ +static const char *ruby_cross_class_suffix(const CBMLSPDef *d) { + if (!d->qualified_name) + return NULL; + if (d->def_module_qn && d->def_module_qn[0]) { + size_t mlen = strlen(d->def_module_qn); + if (strncmp(d->qualified_name, d->def_module_qn, mlen) == 0 && + d->qualified_name[mlen] == '.') { + return d->qualified_name + mlen + 1; + } + } + return d->short_name; +} + +/* The superclass a class def carries (first "|"-separated base, since Ruby + * has single inheritance; a legacy "< Base" spelling is tolerated), in the + * dotted form constant lookup uses. NULL when none. */ +static const char *ruby_def_superclass_ref(CBMArena *arena, const CBMLSPDef *d) { + if (!d->embedded_types || !d->embedded_types[0]) + return NULL; + const char *start = d->embedded_types; + while (*start == '<' || *start == ' ' || *start == '\t') + start++; + const char *bar = strchr(start, '|'); + char *raw = bar ? cbm_arena_strndup(arena, start, (size_t)(bar - start)) + : cbm_arena_strdup(arena, start); + if (!raw || !raw[0]) + return NULL; + return ruby_dotted(arena, raw); +} + +/* Insert entry `idx` (already stored in sh->classes) into one hash index. */ +static void ruby_shared_insert(CBMRubyCrossRegistry *sh, int *slots, int idx, bool by_path) { + const RubyClassInfo *c = &sh->classes[idx]; + unsigned i = ruby_hash(by_path ? c->path : c->qn) & sh->slot_mask; + while (slots[i]) + i = (i + 1) & sh->slot_mask; + slots[i] = idx + 1; +} + +CBMRubyCrossRegistry *cbm_ruby_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, + int def_count) { + if (!arena) + return NULL; + CBMRubyCrossRegistry *sh = (CBMRubyCrossRegistry *)cbm_arena_alloc(arena, sizeof(*sh)); + if (!sh) + return NULL; + memset(sh, 0, sizeof(*sh)); + cbm_registry_init(&sh->reg, arena); + cbm_ruby_stdlib_register(&sh->reg, arena); + + int max_classes = 0; + for (int i = 0; i < def_count; i++) { + const CBMLSPDef *d = &defs[i]; + if (d->lang == CBM_LANG_RUBY && d->label && + (strcmp(d->label, "Class") == 0 || strcmp(d->label, "Module") == 0)) + max_classes++; + } + unsigned slots = 16; + while (slots < (unsigned)max_classes * 2u) + slots <<= 1; + sh->slot_mask = slots - 1; + sh->by_qn = (int *)cbm_arena_alloc(arena, slots * sizeof(int)); + sh->by_path = (int *)cbm_arena_alloc(arena, slots * sizeof(int)); + sh->classes = (RubyClassInfo *)cbm_arena_alloc(arena, (size_t)(max_classes ? max_classes : 1) * + sizeof(RubyClassInfo)); + const char **decl_nesting = (const char **)cbm_arena_alloc( + arena, (size_t)(max_classes ? max_classes : 1) * sizeof(const char *)); + if (!sh->by_qn || !sh->by_path || !sh->classes || !decl_nesting) + return NULL; + memset(sh->by_qn, 0, slots * sizeof(int)); + memset(sh->by_path, 0, slots * sizeof(int)); + + /* Ruby constants live in one global namespace, so every project class is + * a resolution candidate. Methods register under their parent-class + * receiver key; the instance-vs-singleton split is not carried by + * CBMLSPDef, so each registers on BOTH keys (an over-registration the + * per-file pass corrects for same-file dispatch). */ + for (int i = 0; i < def_count; i++) { + CBMLSPDef *d = &defs[i]; + if (!d->qualified_name || !d->short_name || !d->label) + continue; + if (d->lang != CBM_LANG_RUBY) + continue; + if (strcmp(d->label, "Class") == 0 || strcmp(d->label, "Module") == 0) { + const char *suffix = ruby_cross_class_suffix(d); + if (!suffix) + continue; + if (ruby_shared_find(sh, sh->by_qn, d->qualified_name, false)) + continue; /* the same class body seen again — one entry per QN */ + const char *path = ruby_dotted(arena, suffix); + const RubyClassInfo *head = ruby_shared_find(sh, sh->by_path, path, true); + int n = sh->class_count++; + RubyClassInfo *ci = &sh->classes[n]; + memset(ci, 0, sizeof(*ci)); + ci->path = path; + ci->qn = d->qualified_name; + ci->is_module = strcmp(d->label, "Module") == 0; + if (!ci->is_module) + ci->superclass_ref = ruby_def_superclass_ref(arena, d); + /* Declaring scope = every frame but the last; the superclass is + * resolved from there, as in the per-file pass. */ + const char *last = strrchr(suffix, '.'); + decl_nesting[n] = last ? cbm_arena_strndup(arena, suffix, (size_t)(last - suffix)) : ""; + ruby_shared_insert(sh, sh->by_qn, n, false); + if (head) { + /* The same constant reopened in another file: join its group. */ + int h = (int)(head - sh->classes); + ci->group_head = h + 1; + int t = h; + while (sh->classes[t].shard_next) + t = sh->classes[t].shard_next - 1; + sh->classes[t].shard_next = n + 1; + } else { + ci->group_head = n + 1; + ruby_shared_insert(sh, sh->by_path, n, true); + } + } else if (strcmp(d->label, "Function") == 0 || strcmp(d->label, "Method") == 0) { + CBMRegisteredFunc rf; + memset(&rf, 0, sizeof(rf)); + rf.min_params = -1; + rf.qualified_name = d->qualified_name; + rf.short_name = d->short_name; + rf.receiver_type = d->receiver_type; + const CBMType **rets = + (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(const CBMType *)); + if (rets) { + rets[0] = cbm_type_unknown(); + rets[1] = NULL; + } + rf.signature = cbm_type_func(arena, NULL, NULL, rets); + cbm_registry_add_func(&sh->reg, rf); + if (d->receiver_type) { + CBMRegisteredFunc rs = rf; + rs.receiver_type = cbm_arena_sprintf(arena, "%s.self", d->receiver_type); + cbm_registry_add_func(&sh->reg, rs); + } + } + } + + cbm_registry_finalize(&sh->reg); + sh->reg.read_only = true; /* sealed: shared across resolve workers */ + + /* Resolve each superclass against the complete table, once, from the + * scope that declared it. */ + RubyLSPContext ctx; + ruby_lsp_init(&ctx, arena, "", 0, &sh->reg, NULL, NULL); + ctx.shared = sh; + for (int i = 0; i < sh->class_count; i++) { + RubyClassInfo *ci = &sh->classes[i]; + if (!ci->superclass_ref) + continue; + ctx.nesting = decl_nesting[i]; + ci->superclass_qn = ruby_resolve_constant(&ctx, ci->superclass_ref); + } + /* A class reopened without `< Base` inherits the declaration that has one + * (the first, in def order). */ + for (int i = 0; i < sh->class_count; i++) { + RubyClassInfo *ci = &sh->classes[i]; + RubyClassInfo *head = &sh->classes[ci->group_head - 1]; + if (head != ci && !head->superclass_ref && ci->superclass_ref) { + head->superclass_ref = ci->superclass_ref; + head->superclass_qn = ci->superclass_qn; + } + } + return sh; +} + +/* ── cross-file: per-file entry ─────────────────────────────────── */ + +void cbm_run_ruby_lsp_cross_with_registry(CBMArena *arena, const char *source, int source_len, + const char *module_qn, const CBMRubyCrossRegistry *shared, + const char **import_names, const char **import_qns, + int import_count, TSTree *cached_tree, + CBMResolvedCallArray *out) { + if (!arena || !source || !out || !shared) + return; + + RubyLSPContext ctx; + ruby_lsp_init(&ctx, arena, source, source_len, &shared->reg, module_qn, out); + ctx.shared = shared; + ctx.import_names = import_names; + ctx.import_qns = import_qns; + ctx.import_count = import_count; + + /* Parse if the pipeline didn't hand us a cached tree. */ + TSTree *tree = cached_tree; + bool owns_tree = false; + if (!tree) { + TSParser *parser = ts_parser_new(); + if (!parser) + return; + const TSLanguage *lang = tree_sitter_ruby(); + if (!ts_parser_set_language(parser, lang)) { + ts_parser_delete(parser); + return; + } + tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len); + ts_parser_delete(parser); + if (!tree) + return; + owns_tree = true; + } + TSNode root = ts_tree_root_node(tree); + + /* PASS 1 over this file's AST only: its own classes, mixins and + * nesting-packed superclass refs. build_reg stays NULL — methods and every + * other file's classes come from the shared registry, which is never + * written. */ + ruby_pass1_scan(&ctx, root); + ruby_finalize_refs(&ctx); + + /* PASS 1.5 + PASS 2. */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ruby_ivar_scan(&ctx, root); + + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ctx.enclosing_func_qn = NULL; + ctx.in_singleton_method = false; + ruby_resolve_calls_in_node(&ctx, root); + + if (owns_tree) + ts_tree_delete(tree); +} + +void cbm_run_ruby_lsp_cross(CBMArena *arena, const char *source, int source_len, + const char *module_qn, CBMLSPDef *defs, int def_count, + const char **import_names, const char **import_qns, int import_count, + TSTree *cached_tree, CBMResolvedCallArray *out) { + if (!arena || !source || !out) + return; + CBMArena build; + cbm_arena_init(&build); + CBMRubyCrossRegistry *sh = cbm_ruby_build_cross_registry(&build, defs, def_count); + if (sh) { + cbm_run_ruby_lsp_cross_with_registry(arena, source, source_len, module_qn, sh, import_names, + import_qns, import_count, cached_tree, out); + } + cbm_arena_destroy(&build); +} diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h new file mode 100644 index 000000000..697cac7c2 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.h @@ -0,0 +1,229 @@ +#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" +#include "go_lsp.h" /* CBMLSPDef reused across languages */ + +/* 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 constant path used ONLY for constant lookup ("Admin.User" + * for both `module Admin; class User` and `class Admin::User`). `qn` is the + * graph QN built exactly the way the structural extractor builds it: each + * declaration's raw name text appended to the enclosing class QN (or the + * module QN at top level), so a compact `class Admin::User` yields + * ".Admin::User" while the lexically nested form yields + * ".Admin.User". Emitted rows must carry the extractor spelling + * or the pipeline's exact-name join silently drops them. `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` */ + /* Shared (project-wide) table only. A class reopened in another file has + * a different QN there (the QN carries the file's module QN) but the same + * constant path; the entries for one path form a group so a method defined + * in any reopening is found. `group_head` is the first entry of the group, + * `shard_next` the next one; both are table index + 1, 0 = none. Entries + * of a per-file table leave both 0. */ + int group_head; + int shard_next; +} RubyClassInfo; + +struct CBMRubyCrossRegistry; + +/* 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 + * ".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: one frame per enclosing class/module declaration, + * frames joined by '.', each frame keeping its raw source spelling ("" at + * top level, "M.A::B" inside `module M; class A::B`). Ruby's constant + * lookup walks the frames outward — inside `class A::B` the nesting is + * [A::B] only, so A's constants are NOT probed. */ + const char *nesting; + + /* Project-wide read-only class table + method registry (cross-file + * resolution). NULL for per-file extraction. Consulted after this file's + * own table, so a class declared here always wins. */ + const struct CBMRubyCrossRegistry *shared; + + /* Class/module table (pass 1) — this file's own declarations only. */ + 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 */ + /* Inside a block that rebinds `self` (instance_eval/exec, class_eval/ + * exec, module_eval/exec, define_method, Class.new/Module.new): the + * receiver of bare and `self.` calls is NOT the enclosing class, and + * the resolver cannot know what it is — so they emit nothing. */ + bool self_unknown; + /* Number of enclosing blocks (`{ }`, `do end`, `-> { }`) since the + * nearest class/module body. A `def` met here is NOT a method of the + * enclosing class — ActiveSupport's `class_methods do … end`, + * `included do … end`, RSpec groups — so PASS 1 registers nothing for + * it and collects no mixins from it. */ + int block_depth; + 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); + +/* --- Cross-file LSP resolution --- + * + * Ruby constants live in one global namespace and Rails/Zeitwerk autoloads + * them with no require, so a file may name any class in the project. The + * project's definitions are therefore indexed ONCE (cbm_ruby_build_cross_ + * registry) into a sealed, read-only registry: a finalized method registry + * plus a hashed class table. Each file then resolves against it with O(1) + * lookups and builds nothing proportional to the project. */ +typedef struct CBMRubyCrossRegistry { + CBMTypeRegistry reg; /* stdlib + every project method; finalized, sealed */ + RubyClassInfo *classes; + int class_count; + /* Open-addressing hash indexes over `classes` (values are index + 1, 0 = + * empty slot): by QN, and by dotted constant path (group head only). */ + int *by_qn; + int *by_path; + unsigned slot_mask; +} CBMRubyCrossRegistry; + +/* Build the shared registry from every project def. Strings are borrowed from + * `defs` (as the other languages' cross registries do) or copied into `arena`. + * Returns NULL on allocation failure; callers then fall back to the + * per-file path. */ +CBMRubyCrossRegistry *cbm_ruby_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, + int def_count); + +/* Resolve one file against a prebuilt shared registry. Reads `shared` only; + * everything it allocates lives in `arena`. */ +void cbm_run_ruby_lsp_cross_with_registry(CBMArena *arena, const char *source, int source_len, + const char *module_qn, const CBMRubyCrossRegistry *shared, + const char **import_names, const char **import_qns, + int import_count, + TSTree *cached_tree, /* NULL = parse internally */ + CBMResolvedCallArray *out); + +/* Convenience for callers with a def list but no prebuilt registry (the + * per-file fallback path and tests): builds a registry in a scratch arena for + * this one call. Cost is proportional to the def list, so the pipeline uses + * the prebuilt path instead. */ +void cbm_run_ruby_lsp_cross(CBMArena *arena, const char *source, int source_len, + const char *module_qn, CBMLSPDef *defs, int def_count, + const char **import_names, const char **import_qns, int import_count, + TSTree *cached_tree, /* NULL = parse internally */ + CBMResolvedCallArray *out); + +#endif /* CBM_LSP_RUBY_LSP_H */ diff --git a/internal/cbm/lsp_all.c b/internal/cbm/lsp_all.c index 4ab1f5155..7cf05a0c4 100644 --- a/internal/cbm/lsp_all.c +++ b/internal/cbm/lsp_all.c @@ -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" diff --git a/scripts/check-lsp-originality.sh b/scripts/check-lsp-originality.sh index 36633076d..10fa33119 100755 --- a/scripts/check-lsp-originality.sh +++ b/scripts/check-lsp-originality.sh @@ -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" # Infrastructure hybrid LSPs (K8s YAML, Kustomize bundles, Flux/Argo deploy # scopes, the Go operator extension) were written from the Kubernetes, Flux, # Argo, Cluster API, cert-manager and Kubermatic API types. Scanned against diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 5aa955112..49deacd40 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -28,6 +28,7 @@ #include "lsp/php_lsp.h" #include "lsp/java_lsp.h" #include "lsp/kotlin_lsp.h" +#include "lsp/ruby_lsp.h" #include "lsp/rust_lsp.h" #include "lsp/rust_cargo.h" #include "graph_buffer/graph_buffer.h" @@ -1652,6 +1653,7 @@ bool cbm_pxc_has_cross_lsp(CBMLanguage lang) { case CBM_LANG_JAVA: /* fallback cbm_pxc_run_one path */ case CBM_LANG_KOTLIN: /* fallback cbm_pxc_run_one path */ case CBM_LANG_RUST: /* fallback cbm_pxc_run_one path (manifest-aware) */ + case CBM_LANG_RUBY: /* fallback cbm_pxc_run_one path */ return true; default: return false; @@ -2146,6 +2148,10 @@ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const cbm_run_kotlin_lsp_cross(&scratch, source, source_len, module_qn, defs, def_count, imp_names, imp_qns, imp_count, tree, &out); break; + case CBM_LANG_RUBY: + cbm_run_ruby_lsp_cross(&scratch, source, source_len, module_qn, defs, def_count, imp_names, + imp_qns, imp_count, tree, &out); + break; case CBM_LANG_RUST: { /* The Rust resolver wants CBMRustLSPDef (rust_lsp.h), not the * pipeline's CBMLSPDef — the structs share their first 9 fields @@ -2333,6 +2339,15 @@ void cbm_pxc_dispatch_file_with_php_bindings( imp_vals, imp_count, result->cached_tree, &out); used_prebuilt = true; break; + case CBM_LANG_RUBY: + /* The shared registry is sealed and read-only: methods and the + * hashed class table are looked up in it, nothing is built per + * file, so no per-file work scales with the project. */ + cbm_run_ruby_lsp_cross_with_registry(&scratch, source, source_len, def_module, + cross_registries->ruby, imp_keys, imp_vals, + imp_count, result->cached_tree, &out); + used_prebuilt = true; + break; case CBM_LANG_JAVA: /* Java builds its own-module overlay on top of the one it is * handed; both live in scratch now. */ @@ -2390,11 +2405,15 @@ void cbm_pxc_dispatch_file_with_php_bindings( * crate — a module that is in neither own_module nor the import map, so * the filter starves cross-crate resolution (#56 repro red). Rust * therefore always resolves against the FULL def universe: the lazily - * built shared registry when available, else a full per-file build. */ + * built shared registry when available, else a full per-file build. + * RUBY is exempt for the same reason with different mechanics: Ruby + * constants live in one global namespace and Rails/Zeitwerk autoloads + * them WITHOUT require statements, so an own+imports filter starves + * essentially all cross-file resolution in real Ruby apps. */ CBMLSPDef *filtered = NULL; CBMLSPDef *file_defs = all_defs; int file_def_count = all_def_count; - if (module_def_index && lang != CBM_LANG_RUST) { + if (module_def_index && lang != CBM_LANG_RUST && lang != CBM_LANG_RUBY) { int filtered_count = 0; bool filter_succeeded = false; filtered = cbm_pxc_filter_defs_for_file(module_def_index, all_defs, lang, @@ -2592,6 +2611,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * cross_registries.c = cbm_c_build_cross_registry(xa, all_defs, def_count); cross_registries.cs = cbm_cs_build_cross_registry(xa, all_defs, def_count); cross_registries.ts = cbm_ts_build_cross_registry(xa, all_defs, def_count); + cross_registries.ruby = cbm_ruby_build_cross_registry(xa, all_defs, def_count); } int processed = 0; diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h index bcbe30425..538641738 100644 --- a/src/pipeline/pass_lsp_cross.h +++ b/src/pipeline/pass_lsp_cross.h @@ -13,7 +13,7 @@ * file LSP picks them up. * * Languages covered: Go, C/C++/CUDA, Python, TypeScript/JavaScript/JSX/ - * TSX, PHP, C#, and JVM (Java/Kotlin via the shared filter helper). + * TSX, PHP, C#, Ruby, and JVM (Java/Kotlin via the shared filter helper). * Anything else short-circuits via cbm_pxc_has_cross_lsp. * * Previously this work ran as a separate sequential pipeline pass @@ -37,6 +37,7 @@ #include "lsp/ts_lsp.h" /* cbm_ts_build_cross_registry / cbm_run_ts_lsp_cross_with_registry */ #include "lsp/java_lsp.h" /* cbm_java_build_cross_registry / cbm_run_java_lsp_cross_with_registry */ #include "lsp/rust_lsp.h" /* cbm_rust_build_cross_registry / cbm_run_rust_lsp_cross_with_registry */ +#include "lsp/ruby_lsp.h" /* cbm_run_ruby_lsp_cross (fallback cbm_pxc_run_one path) */ #include "pipeline/pipeline_internal.h" #include @@ -143,13 +144,14 @@ CBMLSPDef *cbm_pxc_filter_defs_for_file(const CBMModuleDefIndex *idx, CBMLSPDef * per-file registry build entirely. NULL → fall back to the per-file * cbm_pxc_run_one path. */ typedef struct { - CBMTypeRegistry *go; /* CBM_LANG_GO */ - CBMTypeRegistry *c; /* CBM_LANG_C, CBM_LANG_CPP, CBM_LANG_CUDA */ - CBMTypeRegistry *python; /* CBM_LANG_PYTHON */ - CBMTypeRegistry *ts; /* CBM_LANG_JAVASCRIPT, TYPESCRIPT, TSX */ - CBMTypeRegistry *php; /* CBM_LANG_PHP */ - CBMTypeRegistry *cs; /* CBM_LANG_CSHARP */ - CBMTypeRegistry *java; /* CBM_LANG_JAVA (JVM def universe incl. Kotlin defs) */ + CBMTypeRegistry *go; /* CBM_LANG_GO */ + CBMTypeRegistry *c; /* CBM_LANG_C, CBM_LANG_CPP, CBM_LANG_CUDA */ + CBMTypeRegistry *python; /* CBM_LANG_PYTHON */ + CBMTypeRegistry *ts; /* CBM_LANG_JAVASCRIPT, TYPESCRIPT, TSX */ + CBMTypeRegistry *php; /* CBM_LANG_PHP */ + CBMTypeRegistry *cs; /* CBM_LANG_CSHARP */ + CBMTypeRegistry *java; /* CBM_LANG_JAVA (JVM def universe incl. Kotlin defs) */ + CBMRubyCrossRegistry *ruby; /* CBM_LANG_RUBY: method registry + hashed class table */ /* CBM_LANG_RUST: intentionally absent — the shared rust registry is built * LAZILY inside cbm_parallel_resolve (first NULL-filter rust file), not eagerly. */ } CBMCrossLspRegistries; @@ -188,6 +190,8 @@ static inline CBMTypeRegistry *cbm_pxc_registry_for_lang(const CBMCrossLspRegist return r->cs; case CBM_LANG_JAVA: return r->java; + case CBM_LANG_RUBY: + return r->ruby ? &r->ruby->reg : NULL; default: return NULL; /* incl. CBM_LANG_RUST — its shared registry is built lazily */ } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 92e23de64..08711b972 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -2637,7 +2637,7 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, * ONE language instead of re-diagnosing the whole pass (the cs builder * hid ~140 s behind the pass total, #1669 follow-up). */ struct timespec t_b; - long b_ms[6]; + long b_ms[7]; cbm_clock_gettime(CLOCK_MONOTONIC, &t_b); cross_registries.go = cbm_go_build_cross_registry(&cross_lsp_arena, all_defs, def_count); b_ms[0] = (long)elapsed_ms(t_b); @@ -2658,14 +2658,18 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, cross_registries.java = cbm_java_build_cross_registry(&cross_lsp_arena, all_defs, def_count); b_ms[5] = (long)elapsed_ms(t_b); - char b_buf[6][CBM_SZ_16]; - const char *b_name[6] = {"go", "python", "c", "cs", "ts", "java"}; - for (int bi = 0; bi < 6; bi++) { + cbm_clock_gettime(CLOCK_MONOTONIC, &t_b); + cross_registries.ruby = + cbm_ruby_build_cross_registry(&cross_lsp_arena, all_defs, def_count); + b_ms[6] = (long)elapsed_ms(t_b); + char b_buf[7][CBM_SZ_16]; + const char *b_name[7] = {"go", "python", "c", "cs", "ts", "java", "ruby"}; + for (int bi = 0; bi < 7; bi++) { snprintf(b_buf[bi], sizeof(b_buf[bi]), "%ld", b_ms[bi]); } cbm_log_info("lsp_cross_prepare.builders", b_name[0], b_buf[0], b_name[1], b_buf[1], b_name[2], b_buf[2], b_name[3], b_buf[3], b_name[4], b_buf[4], b_name[5], - b_buf[5]); + b_buf[5], b_name[6], b_buf[6]); /* Rust: NOT built here. The shared all_defs registry is built LAZILY on the * first NULL-filter rust file (the amplifier files) inside cbm_parallel_resolve * — repos whose rust files all filter to subsets never pay the build/RSS. */ diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 6e6740573..ba161b086 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1932,6 +1932,8 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed cross_registries.ts = cbm_ts_build_cross_registry(xa, all_defs, all_def_count); cross_registries.java = cbm_java_build_cross_registry(xa, all_defs, all_def_count); + cross_registries.ruby = + cbm_ruby_build_cross_registry(xa, all_defs, all_def_count); registries_arg = &cross_registries; } } else { diff --git a/tests/fixtures/complexity/Ruby/cx_helper.rb b/tests/fixtures/complexity/Ruby/cx_helper.rb new file mode 100644 index 000000000..aaedf1a35 --- /dev/null +++ b/tests/fixtures/complexity/Ruby/cx_helper.rb @@ -0,0 +1,13 @@ +module CxApp + class CxBase + def label + "base" + end + end + + class CxHelper + def assist(text) + CxWidget.new.name + text + end + end +end diff --git a/tests/fixtures/complexity/Ruby/cx_widget.rb b/tests/fixtures/complexity/Ruby/cx_widget.rb new file mode 100644 index 000000000..f90842a49 --- /dev/null +++ b/tests/fixtures/complexity/Ruby/cx_widget.rb @@ -0,0 +1,14 @@ +# Rails-shaped pair: no require, the constant is found through the global +# namespace, so every call below is a cross-file resolution. +module CxApp + class CxWidget < CxBase + def render + helper = CxHelper.new + helper.assist(name) + end + + def name + "widget" + end + end +end diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 91844f67e..1e760ac7e 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1850,6 +1850,56 @@ TEST(lrp_python_usage_instantiation) { PASS(); } +/* ── Ruby: cross-file through the prebuilt project registry ────────── + * Rails/Zeitwerk code names classes in other files with no require, and a + * class may be reopened in a third file. Both edges must come out of the + * Ruby resolver (ruby_* strategy), not the name-based one. */ +TEST(lrp_ruby_cross_file_zeitwerk) { + const LRP_File files[] = { + {"app/widget.rb", "class Widget\n" + " def run\n" + " h = Helper.new\n" + " h.assist\n" + " h.extra_step\n" + " end\n" + "end\n"}, + {"app/helper.rb", "class Helper\n" + " def assist\n" + " 1\n" + " end\n" + "end\n"}, + {"lib/helper_ext.rb", "class Helper\n" + " def extra_step\n" + " 2\n" + " end\n" + "end\n"}, + }; + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, files, 3); + int assist = lrp_exact_calls_by_name(store, lp.project, "run", "assist"); + int extra = lrp_exact_calls_by_name(store, lp.project, "run", "extra_step"); + int typed = lrp_count_calls_with_strategy(store, lp.project, "ruby_method_typed"); + if (assist < 1 || extra < 1 || typed < 2) { + fprintf(stderr, " [LRP] ruby cross-file FAIL assist=%d extra_step=%d typed_edges=%d\n", + assist, extra, typed); + lrp_diag(store, lp.project, "ruby cross-file"); + cbm_edge_t *edges = NULL; + int n = 0; + if (store && + cbm_store_find_edges_by_type(store, lp.project, "CALLS", &edges, &n) == CBM_STORE_OK) { + for (int i = 0; i < n; i++) + fprintf(stderr, " [LRP] CALLS %s\n", + edges[i].properties_json ? edges[i].properties_json : "(none)"); + cbm_store_free_edges(edges, n); + } + } + lrp_cleanup(&lp, store); + ASSERT_TRUE(assist >= 1); + ASSERT_TRUE(extra >= 1); + ASSERT_TRUE(typed >= 2); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════════ * SUITE registration * ══════════════════════════════════════════════════════════════════════ */ @@ -1967,4 +2017,7 @@ SUITE(lsp_resolution_probe) { RUN_TEST(lrp_ts_usage_import_type_with_decoy); RUN_TEST(lrp_tsx_usage_import_type_with_decoy); RUN_TEST(lrp_python_usage_instantiation); + + /* ── Ruby (project registry built once) ── */ + RUN_TEST(lrp_ruby_cross_file_zeitwerk); } diff --git a/tests/test_main.c b/tests/test_main.c index e0526cf85..3fc6c529c 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1802,6 +1802,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); @@ -2393,6 +2394,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(kotlin_typed_calls); diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c new file mode 100644 index 000000000..5111b2e12 --- /dev/null +++ b/tests/test_ruby_lsp.c @@ -0,0 +1,1682 @@ +/* + * 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) + * 17. Cross-file: defs from another file resolve + * 18. Cross-file: ActiveRecord repro mirroring the Rails-shaped e2e fixture + * 19. Cross-file: a class reopened in a second file + * 20. Cross-file: compact `class A::B` in another file + * 21. Cross-file: one prebuilt registry serves many files unchanged + * Fail-closed ancestry (7c) and block-nested defs (7d) sit with mixins. + * + * Scope: rows 1-16 are SINGLE-FILE, driven through cbm_extract_file so they + * exercise the per-file resolver exactly as the extraction pipeline calls it. + * Rows 17-18 drive cbm_run_ruby_lsp_cross directly with a hand-built + * CBMLSPDef[], which is the shape pass_lsp_cross.c hands it on the + * cbm_pxc_run_one fallback path. + * + * 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 + +/* ── 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 `.` — 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. Cross-file: defs from another file resolve ─────────────── */ + +TEST(rubylsp_cross_file_defs) { + /* models/user.rb (other file) provides the class; this file calls it. + * Simulates the fallback cbm_pxc_run_one path with a combined def + * list. */ + CBMLSPDef defs[3]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "test.models.user.User"; + defs[0].short_name = "User"; + defs[0].label = "Class"; + defs[0].def_module_qn = "test.models.user"; + defs[0].embedded_types = "ApplicationRecord"; + defs[0].lang = CBM_LANG_RUBY; + defs[1].qualified_name = "test.models.user.User.full_name"; + defs[1].short_name = "full_name"; + defs[1].label = "Method"; + defs[1].receiver_type = "test.models.user.User"; + defs[1].def_module_qn = "test.models.user"; + defs[1].lang = CBM_LANG_RUBY; + defs[2].qualified_name = "test.models.user.User.admin?"; + defs[2].short_name = "admin?"; + defs[2].label = "Method"; + defs[2].receiver_type = "test.models.user.User"; + defs[2].def_module_qn = "test.models.user"; + defs[2].lang = CBM_LANG_RUBY; + + const char *src = "class UsersController\n" + " def show\n" + " u = User.new\n" + " u.full_name\n" + " f = User.find(3)\n" + " f.admin?\n" + " end\n" + "end\n"; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out; + memset(&out, 0, sizeof(out)); + cbm_run_ruby_lsp_cross(&arena, src, (int)strlen(src), "test.app.controller", defs, 3, NULL, + NULL, 0, NULL, &out); + + bool ctor = false; + bool full_name = false; + bool admin = false; + for (int i = 0; i < out.count; i++) { + if (!out.items[i].callee_qn || !out.items[i].caller_qn) + continue; + if (!strstr(out.items[i].caller_qn, "UsersController.show")) + continue; + if (strcmp(out.items[i].callee_qn, "test.models.user.User") == 0) + ctor = true; + if (strcmp(out.items[i].callee_qn, "test.models.user.User.full_name") == 0) + full_name = true; + if (strcmp(out.items[i].callee_qn, "test.models.user.User.admin?") == 0) + admin = true; + } + if (!(ctor && full_name && admin)) { + printf(" cross-file rows (%d):\n", out.count); + for (int i = 0; i < out.count; i++) + printf(" %s -> %s [%s]\n", out.items[i].caller_qn, out.items[i].callee_qn, + out.items[i].strategy); + } + cbm_arena_destroy(&arena); + ASSERT(ctor); + ASSERT(full_name); + ASSERT(admin); + PASS(); +} + +/* ── 18. Cross-file: AR repro mirroring the e2e fixture exactly ── */ + +TEST(rubylsp_cross_file_ar_repro) { + CBMLSPDef defs[6]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "e2e.app.models.user.User"; + defs[0].short_name = "User"; + defs[0].label = "Class"; + defs[0].def_module_qn = "e2e.app.models.user"; + defs[0].embedded_types = "ApplicationRecord"; + defs[0].lang = CBM_LANG_RUBY; + defs[1].qualified_name = "e2e.app.models.user.User.full_name"; + defs[1].short_name = "full_name"; + defs[1].label = "Method"; + defs[1].receiver_type = "e2e.app.models.user.User"; + defs[1].def_module_qn = "e2e.app.models.user"; + defs[1].lang = CBM_LANG_RUBY; + defs[2].qualified_name = "e2e.app.models.user.User.deactivate!"; + defs[2].short_name = "deactivate!"; + defs[2].label = "Method"; + defs[2].receiver_type = "e2e.app.models.user.User"; + defs[2].def_module_qn = "e2e.app.models.user"; + defs[2].lang = CBM_LANG_RUBY; + defs[3].qualified_name = "e2e.app.models.application_record.ApplicationRecord"; + defs[3].short_name = "ApplicationRecord"; + defs[3].label = "Class"; + defs[3].def_module_qn = "e2e.app.models.application_record"; + defs[3].embedded_types = "ActiveRecord::Base"; + defs[3].lang = CBM_LANG_RUBY; + defs[4].qualified_name = "e2e.app.services.greeter.Greeter"; + defs[4].short_name = "Greeter"; + defs[4].label = "Class"; + defs[4].def_module_qn = "e2e.app.services.greeter"; + defs[4].lang = CBM_LANG_RUBY; + defs[5].qualified_name = "e2e.app.services.greeter.Greeter.greet"; + defs[5].short_name = "greet"; + defs[5].label = "Method"; + defs[5].receiver_type = "e2e.app.services.greeter.Greeter"; + defs[5].def_module_qn = "e2e.app.services.greeter"; + defs[5].lang = CBM_LANG_RUBY; + + const char *src = "class Report\n" + " def generate(id)\n" + " user = User.find(id)\n" + " name = user.full_name\n" + " greeter = Greeter.new(user)\n" + " greeter.greet\n" + " user.deactivate!\n" + " name\n" + " end\n" + "end\n"; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out; + memset(&out, 0, sizeof(out)); + cbm_run_ruby_lsp_cross(&arena, src, (int)strlen(src), "e2e.app.services.report", defs, 6, NULL, + NULL, 0, NULL, &out); + + bool full_name = false; + bool deact = false; + bool greet = false; + for (int i = 0; i < out.count; i++) { + if (!out.items[i].callee_qn) + continue; + if (strcmp(out.items[i].callee_qn, "e2e.app.models.user.User.full_name") == 0) + full_name = true; + if (strcmp(out.items[i].callee_qn, "e2e.app.models.user.User.deactivate!") == 0) + deact = true; + if (strcmp(out.items[i].callee_qn, "e2e.app.services.greeter.Greeter.greet") == 0) + greet = true; + } + if (!(full_name && deact && greet)) { + printf(" AR-repro rows (%d):\n", out.count); + for (int i = 0; i < out.count; i++) + printf(" %s -> %s [%s]\n", out.items[i].caller_qn, out.items[i].callee_qn, + out.items[i].strategy); + } + cbm_arena_destroy(&arena); + ASSERT(greet); + ASSERT(full_name); + ASSERT(deact); + PASS(); +} + +/* ── 19-21. Cross-file: the shared project registry ─────────────── */ + +static void cross_def(CBMLSPDef *d, const char *qn, const char *short_name, const char *label, + const char *module_qn, const char *receiver, const char *embedded) { + memset(d, 0, sizeof(*d)); + d->qualified_name = qn; + d->short_name = short_name; + d->label = label; + d->def_module_qn = module_qn; + d->receiver_type = receiver; + d->embedded_types = embedded; + d->lang = CBM_LANG_RUBY; +} + +static bool out_has(const CBMResolvedCallArray *out, const char *caller_sub, const char *callee) { + for (int i = 0; i < out->count; i++) { + if (out->items[i].caller_qn && out->items[i].callee_qn && + strstr(out->items[i].caller_qn, caller_sub) && + strcmp(out->items[i].callee_qn, callee) == 0) + return true; + } + return false; +} + +static void out_dump(const CBMResolvedCallArray *out) { + for (int i = 0; i < out->count; i++) + printf(" %s -> %s [%s]\n", out->items[i].caller_qn, out->items[i].callee_qn, + out->items[i].strategy); +} + +TEST(rubylsp_cross_reopened_class_across_files) { + /* `class User` is reopened in a second file. Each file's class has its + * own QN (it carries the file's module QN) but one constant path, so a + * method defined in either reopening must be found, and the constructor + * edge lands on the first declaration. */ + CBMLSPDef defs[4]; + cross_def(&defs[0], "test.a.User", "User", "Class", "test.a", NULL, NULL); + cross_def(&defs[1], "test.a.User.name", "name", "Method", "test.a", "test.a.User", NULL); + cross_def(&defs[2], "test.b.User", "User", "Class", "test.b", NULL, NULL); + cross_def(&defs[3], "test.b.User.email", "email", "Method", "test.b", "test.b.User", NULL); + + const char *src = "class Report\n" + " def run\n" + " u = User.new\n" + " u.name\n" + " u.email\n" + " end\n" + "end\n"; + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out; + memset(&out, 0, sizeof(out)); + cbm_run_ruby_lsp_cross(&arena, src, (int)strlen(src), "test.app.report", defs, 4, NULL, NULL, 0, + NULL, &out); + bool ctor = out_has(&out, "Report.run", "test.a.User"); + bool name = out_has(&out, "Report.run", "test.a.User.name"); + bool email = out_has(&out, "Report.run", "test.b.User.email"); + if (!(ctor && name && email)) + out_dump(&out); + cbm_arena_destroy(&arena); + ASSERT(ctor); + ASSERT(name); + ASSERT(email); + PASS(); +} + +TEST(rubylsp_cross_compact_class_name) { + /* A compact `class Admin::User` in another file has QN + * ".Admin::User" but is found through the dotted path + * "Admin.User". */ + CBMLSPDef defs[2]; + cross_def(&defs[0], "test.admin.Admin::User", "User", "Class", "test.admin", NULL, NULL); + cross_def(&defs[1], "test.admin.Admin::User.greet", "greet", "Method", "test.admin", + "test.admin.Admin::User", NULL); + + const char *src = "class Welcome\n" + " def run\n" + " Admin::User.new.greet\n" + " end\n" + "end\n"; + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out; + memset(&out, 0, sizeof(out)); + cbm_run_ruby_lsp_cross(&arena, src, (int)strlen(src), "test.app.welcome", defs, 2, NULL, NULL, + 0, NULL, &out); + bool ctor = out_has(&out, "Welcome.run", "test.admin.Admin::User"); + bool greet = out_has(&out, "Welcome.run", "test.admin.Admin::User.greet"); + if (!(ctor && greet)) + out_dump(&out); + cbm_arena_destroy(&arena); + ASSERT(ctor); + ASSERT(greet); + PASS(); +} + +TEST(rubylsp_cross_registry_built_once_serves_many_files) { + /* One registry, several files, superclass chain across files: the shared + * registry is read-only, so later files see exactly what earlier ones did + * and nothing a file declares leaks into it. */ + CBMLSPDef defs[4]; + cross_def(&defs[0], "test.base.Base", "Base", "Class", "test.base", NULL, NULL); + cross_def(&defs[1], "test.base.Base.shared_m", "shared_m", "Method", "test.base", + "test.base.Base", NULL); + cross_def(&defs[2], "test.child.Child", "Child", "Class", "test.child", NULL, "Base"); + cross_def(&defs[3], "test.child.Child.own_m", "own_m", "Method", "test.child", + "test.child.Child", NULL); + + CBMArena build; + cbm_arena_init(&build); + CBMRubyCrossRegistry *sh = cbm_ruby_build_cross_registry(&build, defs, 4); + ASSERT(sh != NULL); + int classes_before = sh->class_count; + int funcs_before = sh->reg.func_count; + + const char *src1 = + "class One\n def run\n c = Child.new\n c.shared_m\n c.own_m\n end\nend\n"; + const char *src2 = "class Two\n def run\n Child.new.shared_m\n end\nend\n"; + const char *src3 = "class Three\n def run\n Local.new.shared_m\n end\nend\n"; + CBMArena a1; + CBMArena a2; + CBMArena a3; + cbm_arena_init(&a1); + cbm_arena_init(&a2); + cbm_arena_init(&a3); + CBMResolvedCallArray o1; + CBMResolvedCallArray o2; + CBMResolvedCallArray o3; + memset(&o1, 0, sizeof(o1)); + memset(&o2, 0, sizeof(o2)); + memset(&o3, 0, sizeof(o3)); + cbm_run_ruby_lsp_cross_with_registry(&a1, src1, (int)strlen(src1), "test.app.one", sh, NULL, + NULL, 0, NULL, &o1); + cbm_run_ruby_lsp_cross_with_registry(&a2, src2, (int)strlen(src2), "test.app.two", sh, NULL, + NULL, 0, NULL, &o2); + cbm_run_ruby_lsp_cross_with_registry(&a3, src3, (int)strlen(src3), "test.app.three", sh, NULL, + NULL, 0, NULL, &o3); + bool one_inherited = out_has(&o1, "One.run", "test.base.Base.shared_m"); + bool one_own = out_has(&o1, "One.run", "test.child.Child.own_m"); + bool two_inherited = out_has(&o2, "Two.run", "test.base.Base.shared_m"); + bool three_none = !out_has(&o3, "Three.run", "test.base.Base.shared_m"); + bool untouched = sh->class_count == classes_before && sh->reg.func_count == funcs_before; + if (!(one_inherited && one_own && two_inherited && three_none && untouched)) { + out_dump(&o1); + out_dump(&o2); + out_dump(&o3); + } + cbm_arena_destroy(&a1); + cbm_arena_destroy(&a2); + cbm_arena_destroy(&a3); + cbm_arena_destroy(&build); + ASSERT(one_inherited); + ASSERT(one_own); + ASSERT(two_inherited); + ASSERT(three_none); + ASSERT(untouched); + 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_cross_file_defs); + RUN_TEST(rubylsp_cross_file_ar_repro); + RUN_TEST(rubylsp_cross_reopened_class_across_files); + RUN_TEST(rubylsp_cross_compact_class_name); + RUN_TEST(rubylsp_cross_registry_built_once_serves_many_files); +}