From e9f87efe6abff44a7130fa3db9a9a409d22fd556 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Tue, 18 Aug 2026 16:13:14 +0800 Subject: [PATCH 01/11] feat(lsp): add the Ruby per-file resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-process, type-aware call resolution for single Ruby files, mirroring the perl_lsp.c / php_lsp.c shape and dispatched from cbm_extract_file: - PASS 1 collects classes and modules with lexical nesting, superclasses, and include / prepend / extend mixins, plus method tables with instance and singleton methods classified from the AST. Singleton methods key on ".self", which cannot collide with a constant path. - PASS 1.5 infers instance-variable types from `@x = Const.new`; conflicting assignments latch to no type rather than guessing. - PASS 2 resolves constructors (`Const.new` targets the class node, the way Python's lsp_constructor does), implicit and explicit self dispatch, singleton methods, superclass chains, mixin lookup in Ruby's method-resolution order, super, and ivar / local / chained receivers. The stdlib seed covers Ruby core plus a curated Rails surface, including ActiveRecord query typing: `User.find(1)` is a User, and relation chains keep the model type. Dynamic dispatch (send, method_missing, define_method) and unresolved receivers emit no edge, and only project-defined targets are emitted. Cross-file resolution is a separate tier and is not part of this change; the 18 rows here drive the resolver through cbm_extract_file exactly as the extraction pipeline calls it. PROVENANCE: this resolver was authored clean-room from the tree-sitter-ruby grammar and Ruby language semantics. No Ruby language server source was consulted. scripts/check-lsp-originality.sh gains Shopify's ruby-lsp (MIT) as the Ruby reference so the boundary is auditable alongside the generated data it guards; `check-lsp-originality.sh --lang ruby` reports CLEAN — no verbatim string or comment overlap and no structural clones. Refs #1701 Assisted-by: OpenCode:anthropic/claude-opus-5 Signed-off-by: Ben Fairless --- Makefile.cbm | 3 +- internal/cbm/cbm.c | 4 + internal/cbm/lsp/generated/ruby_stdlib_data.c | 461 ++++++ internal/cbm/lsp/ruby_lsp.c | 1261 +++++++++++++++++ internal/cbm/lsp/ruby_lsp.h | 148 ++ internal/cbm/lsp_all.c | 2 + scripts/check-lsp-originality.sh | 6 +- tests/test_main.c | 2 + tests/test_ruby_lsp.c | 636 +++++++++ 9 files changed, 2521 insertions(+), 2 deletions(-) create mode 100644 internal/cbm/lsp/generated/ruby_stdlib_data.c create mode 100644 internal/cbm/lsp/ruby_lsp.c create mode 100644 internal/cbm/lsp/ruby_lsp.h create mode 100644 tests/test_ruby_lsp.c diff --git a/Makefile.cbm b/Makefile.cbm index bf51a5a8c..9e5564cc6 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -579,6 +579,7 @@ TEST_CS_LSP_SRCS = tests/test_cs_lsp.c TEST_CS_LSP_BENCH_SRCS = tests/test_cs_lsp_bench.c TEST_PERL_LSP_SRCS = tests/test_perl_lsp.c +TEST_RUBY_LSP_SRCS = tests/test_ruby_lsp.c TEST_SCOPE_SRCS = tests/test_scope.c @@ -701,7 +702,7 @@ TEST_REPRO_SRCS = \ tests/repro/repro_lsp_java_cs.c \ tests/repro/repro_lsp_kt_php_rust.c -ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) +ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_RUBY_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) # ── Build directories ──────────────────────────────────────────── diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index d7468af5a..edf1b63d7 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -8,6 +8,7 @@ #include "lsp/php_lsp.h" #include "lsp/perl_lsp.h" #include "lsp/py_lsp.h" +#include "lsp/ruby_lsp.h" #include "lsp/ts_lsp.h" #include "lsp/cs_lsp.h" #include "lsp/java_lsp.h" @@ -1320,6 +1321,9 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua if (language == CBM_LANG_PERL) { cbm_run_perl_lsp(a, result, source, source_len, root); } + if (language == CBM_LANG_RUBY) { + cbm_run_ruby_lsp(a, result, source, source_len, root); + } if (language == CBM_LANG_PYTHON) { cbm_run_py_lsp(a, result, source, source_len, root); } 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..2ca944df2 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.c @@ -0,0 +1,1261 @@ +/* + * 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`). + * PASS 1.5 — infer instance-variable types from `@x = Const.new` + * assignments inside method bodies (conflicting types latch to + * "conflicted" and resolve to nothing). + * PASS 2 — walk method bodies, track local variable types through + * assignments, and resolve call expressions into CBMResolvedCall + * edges via Ruby method lookup (prepends → own → includes → + * superclass chain; extends feed the singleton side). + * + * 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. + * - 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`. + * - 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 `module_qn..` with + * parent_class = `module_qn.`. 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). Dynamic dispatch + * (`send`, `public_send`, `method_missing`, `define_method`) is + * intentionally ignored. + */ + +#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); + +/* ── 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); +} + +/* 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. */ + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 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; +} + +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; + 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) { + if (strcmp(iv->type_qn, type_qn) != 0) + iv->conflicted = true; /* disagreeing assignments — no type */ + return; + } + } + 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; + 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); + iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); +} + +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 && strcmp(iv->class_qn, class_qn) == 0 && + strcmp(iv->ivar_name, ivar_name) == 0) + return iv->type_qn; + } + return NULL; +} + +static 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 NULL; +} + +/* 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) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci) + return ci->superclass_qn; + return ruby_stdlib_superclass(class_qn); +} + +/* ── constant resolution ────────────────────────────────────────── */ + +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path) { + if (!ctx || !path || !path[0]) + return NULL; + /* Lexical nesting probe: for nesting "A.B" try "A.B.path", "A.path", + * then "path" (innermost first — Ruby's constant lookup order). */ + 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 (1) { + if (prefix[0]) { + const char *cand = cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, path); + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, cand) == 0) + return ctx->classes[i].qn; + } + char *last_dot = strrchr(prefix, '.'); + if (last_dot) + *last_dot = '\0'; + else + prefix[0] = '\0'; + continue; + } + break; + } + } + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return ctx->classes[i].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 instance method of one exact class (no ancestry walk). */ +static const CBMRegisteredFunc *ruby_own_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return cbm_registry_lookup_method(ctx->registry, class_qn, method_name); +} + +/* Own singleton method of one exact class (".self" receiver key). */ +static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + const char *key = cbm_arena_sprintf(ctx->arena, "%s.self", class_qn); + return cbm_registry_lookup_method(ctx->registry, key, method_name); +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth); + +/* Search a mixin kind on `owner_qn` (most recent first — later mixins win), + * recursing into each module's own include chain. */ +static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, + RubyMixinKind kind, const char *method_name, + int depth) { + 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 || !mi->module_qn) + continue; + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1); + if (f) + return f; + } + return NULL; +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + /* Prepended modules shadow the class's own methods. */ + const CBMRegisteredFunc *f = + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth); + if (f) + return f; + f = ruby_own_instance_method(ctx, class_qn, method_name); + if (f) + return f; + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth); + if (f) + return f; + 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); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + const CBMRegisteredFunc *f = ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0); + if (f) + 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; +} + +static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth) { + 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); + if (f) + return f; + 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); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0); +} + +/* ── 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; + 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) + 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, "class_eval") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "instance_variable_get") == 0 || + strcmp(m, "instance_variable_set") == 0; +} + +/* 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->enclosing_class_qn) { + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + } + 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) + return cbm_type_unknown(); + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + 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) + return; + const char *fq = ctx->enclosing_func_qn; + const char *mname = strrchr(fq, '.'); + mname = mname ? mname + 1 : fq; + const CBMRegisteredFunc *f = NULL; + /* Search order mirrors Ruby: includes of the class, then superclass + * chain — the class's OWN def is the caller and must be skipped. */ + f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, + ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, + mname, 0); + if (!f) { + 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) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0); + } + } + if (f) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); + /* No known parent or unresolved — zero-edge guarantee. */ +} + +/* ── 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; + if (strcmp(ts_node_type(left), "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); + if (rt && rt->kind == CBM_TYPE_NAMED) + cbm_scope_bind(ctx->current_scope, vname, rt); +} + +/* ── PASS 2: resolution walk ────────────────────────────────────── */ + +/* Process one method/singleton_method definition node. */ +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, ctx->current_scope); + ctx->in_singleton_method = singleton; + + 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 class/module node during PASS 2: update nesting + enclosing + * class, then walk the body. */ +static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + + const char *saved_nesting = ctx->nesting; + const char *saved_class = ctx->enclosing_class_qn; + bool saved_singleton = ctx->in_singleton_method; + + ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); + const char *qn = ruby_resolve_constant(ctx, cpath); + ctx->enclosing_class_qn = qn; + ctx->in_singleton_method = false; + + 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->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + ctx->in_singleton_method = saved_singleton; +} + +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) { + ruby_process_method(ctx, node, ctx->in_singleton_method); + return; + } + if (strcmp(k, "singleton_method") == 0) { + ruby_process_method(ctx, node, true); + return; + } + + if (strcmp(k, "assignment") == 0) + ruby_process_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); + /* 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) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + + const char *saved_nesting = ctx->nesting; + const char *full_path = ruby_path_join(ctx->arena, saved_nesting, cpath); + const char *qn = ctx->module_qn + ? cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, full_path) + : full_path; + RubyClassInfo *ci = ruby_add_class(ctx, full_path, qn, is_module); + + /* Superclass: `class C < Base` — the `superclass` field wraps the + * expression after `<`. */ + 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) { + ci->superclass_ref = ruby_pack_ref(ctx, spath); + break; + } + } + } + } + + ctx->nesting = full_path; + const char *saved_class = ctx->enclosing_class_qn; + bool saved_singleton = ctx->in_singleton_method; + ctx->enclosing_class_qn = qn; + ctx->in_singleton_method = false; + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_pass1_scan(ctx, body); + ctx->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + ctx->in_singleton_method = saved_singleton; +} + +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) { + 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) { + 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; + } + } + } + + 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); + } +} + +/* 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); +} + +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) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + const char *saved_nesting = ctx->nesting; + const char *saved_class = ctx->enclosing_class_qn; + ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); + ctx->enclosing_class_qn = ruby_resolve_constant(ctx, cpath); + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_ivar_scan(ctx, body); + ctx->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + return; + } + + if (strcmp(k, "assignment") == 0 && ctx->enclosing_class_qn) { + 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) && !ts_node_is_null(right) && + strcmp(ts_node_type(left), "instance_variable") == 0) { + const char *tqn = ruby_static_ctor_type(ctx, right); + if (tqn) { + char *ivar = ruby_node_text(ctx, left); + ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + } + } + } + + 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_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); +} diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h new file mode 100644 index 000000000..f6de32943 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.h @@ -0,0 +1,148 @@ +#ifndef CBM_LSP_RUBY_LSP_H +#define CBM_LSP_RUBY_LSP_H + +#include "type_rep.h" +#include "scope.h" +#include "type_registry.h" +#include "../cbm.h" + +/* Ruby mixin kinds. Lookup order for an instance method on class C: + * prepended modules (last prepended first) → C's own methods → + * included modules (last included first) → superclass chain. + * `extend` adds a module's instance methods to C's singleton (class-side). */ +typedef enum { + RUBY_MIXIN_INCLUDE = 0, + RUBY_MIXIN_PREPEND, + RUBY_MIXIN_EXTEND, +} RubyMixinKind; + +/* One class/module discovered in the file (or, cross-file, in the project). + * `path` is the dotted source-level constant path (e.g. "Admin.User" for + * `Admin::User`); `qn` is the full graph QN (module_qn.path for file-local + * classes; the extractor QN for cross-file defs). `superclass_qn` is resolved + * against the class table + stdlib after collection; NULL when unknown. */ +typedef struct { + const char *path; + const char *qn; + const char *superclass_ref; /* raw dotted superclass spelling, or NULL */ + const char *superclass_qn; /* resolved QN (table/stdlib), or NULL */ + bool is_module; /* `module` (mixin source) vs `class` */ +} RubyClassInfo; + +/* One `include`/`prepend`/`extend` record: owner class QN → module ref. */ +typedef struct { + const char *owner_qn; /* class/module whose body held the call */ + const char *module_ref; /* raw dotted module spelling */ + const char *module_qn; /* resolved QN, or NULL */ + RubyMixinKind kind; +} RubyMixinInfo; + +/* One inferred instance-variable type: (class QN, @name) → type QN. + * `conflicted` latches when two assignments disagree — lookup then returns + * nothing (zero-edge guarantee beats a coin-flip type). */ +typedef struct { + const char *class_qn; + const char *ivar_name; /* including the leading '@' */ + const char *type_qn; + bool conflicted; +} RubyIvarInfo; + +/* RubyLSPContext — per-file state for Ruby type-aware call resolution. + * Mirrors PerlLSPContext / PHPLSPContext structure. + * + * Ruby specifics that shape this context: + * - Classes/modules nest lexically (`module A; class B`), and constants + * resolve through the lexical nesting outward (A::B sees A's constants + * before top-level ones). `nesting` tracks the current dotted path. + * - Inheritance is `class C < Base`; mixins arrive via include/prepend/ + * extend and participate in method lookup (see RubyMixinKind). + * - `Foo.new` constructs a Foo and dispatches to Foo#initialize. + * - `def m` defines an instance method; `def self.m` a singleton (class) + * method. Singleton methods are registered under the receiver key + * ".self" — constants cannot be lowercase, so the suffix can + * never collide with a real nested-constant path. */ +typedef struct { + CBMArena *arena; + const char *source; + int source_len; + const CBMTypeRegistry *registry; + /* Mutable alias of `registry`, set only while PASS 1 populates method + * tables (mirrors perl_lsp's mutable-reg build phase). NULL afterwards. */ + CBMTypeRegistry *build_reg; + CBMScope *current_scope; + + /* Lexical nesting: dotted constant path of the enclosing class/module + * chain ("" at top level, "A.B" inside `module A; class B`). */ + const char *nesting; + + /* Class/module table (pass 1). */ + RubyClassInfo *classes; + int class_count; + int class_cap; + + /* Mixin table (pass 1). */ + RubyMixinInfo *mixins; + int mixin_count; + int mixin_cap; + + /* Instance-variable type table (pass 1). */ + RubyIvarInfo *ivars; + int ivar_count; + int ivar_cap; + + /* Cross-file import map (require/require_relative resolved by the + * pipeline). Unused per-file: Ruby constants are file-global. */ + const char **import_names; + const char **import_qns; + int import_count; + + /* Current enclosing context during the resolution walk. */ + const char *enclosing_class_qn; /* class QN, or NULL at top level */ + const char *enclosing_func_qn; /* enclosing method QN, or NULL */ + bool in_singleton_method; /* `def self.m` / class << self scope */ + const char *module_qn; + + /* Output: resolved calls accumulate here. */ + CBMResolvedCallArray *resolved_calls; + + /* Recursion guards. */ + int eval_depth; + int walk_depth; + + /* Debug mode (CBM_LSP_DEBUG env). */ + bool debug; +} RubyLSPContext; + +/* Initialize a RubyLSPContext for processing one file. */ +void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len, + const CBMTypeRegistry *registry, const char *module_qn, + CBMResolvedCallArray *out); + +/* Resolve a dotted constant path (e.g. "Foo", "A.B") against the class + * table using the current lexical nesting, then the stdlib. Returns the + * resolved QN or NULL. */ +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path); + +/* Look up an instance method on a class, walking prepends → own → includes → + * superclass chain. Returns the resolved CBMRegisteredFunc or NULL. */ +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name); + +/* Look up a singleton (class-side) method: own singleton methods → + * `extend`ed modules' instance methods → superclass singleton chain. */ +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name); + +/* Evaluate a Ruby expression's type. May return CBM_TYPE_UNKNOWN. */ +const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node); + +/* Entry point: build registry from file defs + stdlib, then run resolution. + * Called from cbm_extract_file() via the language dispatch in cbm.c. */ +void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root); + +/* Register Ruby core stdlib types/methods + a curated Rails surface + * (ActiveRecord/ActiveSupport/ActionController) into a registry. */ +void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena); + +#endif /* CBM_LSP_RUBY_LSP_H */ 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 a50af9de4..f02b9cc8b 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" ) ONLY_LANG=""; REFRESH=0; LIST_ONLY=0 diff --git a/tests/test_main.c b/tests/test_main.c index ba6f26d45..883ba4ed6 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -733,6 +733,7 @@ extern void suite_php_lsp(void); extern void suite_cs_lsp(void); extern void suite_cs_lsp_bench(void); extern void suite_perl_lsp(void); +extern void suite_ruby_lsp(void); extern void suite_scope(void); extern void suite_type_rep(void); extern void suite_py_lsp(void); @@ -1026,6 +1027,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(cs_lsp); RUN_SELECTED_SUITE_PERF(cs_lsp_bench); RUN_SELECTED_SUITE(perl_lsp); + RUN_SELECTED_SUITE(ruby_lsp); RUN_SELECTED_SUITE(py_lsp); RUN_SELECTED_SUITE(kotlin_lsp); RUN_SELECTED_SUITE(rust_lsp); diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c new file mode 100644 index 000000000..23c37314e --- /dev/null +++ b/tests/test_ruby_lsp.c @@ -0,0 +1,636 @@ +/* + * test_ruby_lsp.c — Tests for the Ruby Light Semantic Pass. + * + * Coverage mirrors tests/test_perl_lsp.c / tests/test_php_lsp.c, exercising + * the foundational Ruby resolution scenarios: + * 1. QN contract (defs carry module_qn.ClassPath.method — the join key + * the resolver's emitted rows depend on) + * 2. Constructor typing + edge (a = Animal.new; a.speak) + * 3. Constructor edge targets CLASS (textual `Widget.new` -> "Widget") + * 4. Implicit/explicit self dispatch + * 5. Singleton (class) methods (def self.m; Animal.m) + * 6. Superclass chain lookup (class B < A) + * 7. include + prepend mixin lookup (prepend shadows the class's own) + * 8. extend mixin (class-side) lookup + * 9. super dispatch + * 10. Instance-variable typing (@thing = Foo.new; @thing.bar) + * 11. Chained call typing (Foo.new.bar) + * 12. Nested modules (lexical nesting + `A::B` references) + * 13. ActiveRecord model typing (User.find(1).full_name) + * 14. Top-level function calls + * 15. Unresolvable receiver emits NO spurious edge (negative) + * 16. send() / the whole dynamic-dispatch family emits NO edge (negative) + * + * Scope: every row here is SINGLE-FILE, driven through cbm_extract_file so it + * exercises the per-file resolver exactly as the extraction pipeline calls it. + * Cross-file resolution (cbm_run_ruby_lsp_cross, wired through + * pass_lsp_cross.c) is a separate tier and is covered by its own rows. + * + * The resolver populates result->resolved_calls with CBMResolvedCall rows. + * Ruby defs weave the class path into the QN (module_qn.Animal.speak), so + * for these single-file fixtures ("test"/"main.rb" -> module QN test.main) + * callee fragments like "main.Animal.speak" are unique join keys. + */ +#include "test_framework.h" +#include "cbm.h" +#include "../src/pipeline/lsp_resolve.h" +#include "lsp/ruby_lsp.h" +#include + +/* ── Helpers (mirror test_perl_lsp.c) ──────────────────────────── */ + +static CBMFileResult *extract_ruby(const char *source) { + return cbm_extract_file(source, (int)strlen(source), CBM_LANG_RUBY, "test", "main.rb", 0, NULL, + NULL); +} + +static int find_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && strstr(rc->caller_qn, callerSub) && rc->callee_qn && + strstr(rc->callee_qn, calleeSub)) + return i; + } + return -1; +} + +static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { + int idx = find_resolved(r, callerSub, calleeSub); + if (idx < 0) { + printf(" MISSING resolved call: caller~%s -> callee~%s (have %d)\n", callerSub, calleeSub, + r->resolved_calls.count); + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)", + rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)", + rc->confidence); + } + } + return idx; +} + +static const CBMResolvedCall *find_resolved_with_strategy(const CBMFileResult *r, + const char *callerSub, + const char *calleeSub, + const char *strategy) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (!rc->caller_qn || !rc->callee_qn) + continue; + if (!strstr(rc->caller_qn, callerSub)) + continue; + if (!strstr(rc->callee_qn, calleeSub)) + continue; + if (strategy && (!rc->strategy || strcmp(rc->strategy, strategy) != 0)) + continue; + return rc; + } + return NULL; +} + +static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && d->name && strcmp(d->label, label) == 0 && strcmp(d->name, name) == 0) + return d; + } + return NULL; +} + +/* ── 1. QN contract: defs weave the class path into method QNs ─── */ + +TEST(rubylsp_qn_contract) { + const char *src = "class Animal\n" + " def speak\n" + " 'woof'\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMDefinition *m = find_def(r, "Method", "speak"); + if (!m) { + printf(" no Method speak def; defs:\n"); + for (int i = 0; i < r->defs.count; i++) + printf(" [%s] %s qn=%s parent=%s\n", r->defs.items[i].label, r->defs.items[i].name, + r->defs.items[i].qualified_name, + r->defs.items[i].parent_class ? r->defs.items[i].parent_class : "(null)"); + } + ASSERT(m); + if (strcmp(m->qualified_name, "test.main.Animal.speak") != 0) { + printf(" QN scheme mismatch: got %s (resolver assumes module_qn.ClassPath.method)\n", + m->qualified_name); + } + ASSERT(strcmp(m->qualified_name, "test.main.Animal.speak") == 0); + cbm_free_result(r); + PASS(); +} + +/* ── 2. Constructor typing: a = Animal.new; a.speak ────────────── */ + +TEST(rubylsp_method_via_constructor_assignment) { + const char *src = "class Animal\n" + " def initialize(name)\n" + " @name = name\n" + " end\n" + " def speak\n" + " @name\n" + " end\n" + "end\n" + "class Runner\n" + " def run\n" + " a = Animal.new('rex')\n" + " a.speak\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Runner.run", "Animal.speak") >= 0); + ASSERT(find_resolved_with_strategy(r, "Runner.run", "Animal.speak", "ruby_method_typed")); + cbm_free_result(r); + PASS(); +} + +/* ── 3. Constructor edge targets the CLASS node ─────────────────── */ + +TEST(rubylsp_constructor_edge_targets_class) { + const char *src = "class Widget\n" + " def initialize\n" + " end\n" + "end\n" + "class Maker\n" + " def build\n" + " Widget.new\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "Maker.build", "Widget", "ruby_constructor"); + ASSERT(rc); + /* Callee is the class QN (leaf "Widget"), matching the textual + * extractor's Widget.new -> "Widget" rewrite so the row joins. */ + ASSERT(strcmp(rc->callee_qn, "test.main.Widget") == 0); + ASSERT(rc->confidence >= CBM_LSP_CONFIDENCE_FLOOR); + cbm_free_result(r); + PASS(); +} + +/* ── 4. Implicit + explicit self dispatch ───────────────────────── */ + +TEST(rubylsp_self_dispatch) { + const char *src = "class Greeter\n" + " def greet\n" + " build_greeting('hi')\n" + " self.build_greeting('yo')\n" + " end\n" + " def build_greeting(word)\n" + " word\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Greeter.greet", "Greeter.build_greeting") >= 0); + ASSERT(find_resolved_with_strategy(r, "Greeter.greet", "Greeter.build_greeting", + "ruby_self_dispatch")); + cbm_free_result(r); + PASS(); +} + +/* ── 5. Singleton (class) methods ───────────────────────────────── */ + +TEST(rubylsp_singleton_method_dispatch) { + const char *src = "class Registry\n" + " def self.register(key)\n" + " key\n" + " end\n" + "end\n" + "class App\n" + " def boot\n" + " Registry.register(:db)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.boot", "Registry.register") >= 0); + ASSERT(find_resolved_with_strategy(r, "App.boot", "Registry.register", "ruby_class_method")); + cbm_free_result(r); + PASS(); +} + +/* Singleton methods must NOT satisfy instance dispatch (and vice versa). */ +TEST(rubylsp_singleton_instance_split) { + const char *src = "class Config\n" + " def self.load\n" + " 1\n" + " end\n" + "end\n" + "class App\n" + " def boot\n" + " c = Config.new\n" + " c.load\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* c.load is an INSTANCE call; Config only defines a singleton `load`. + * Zero-edge guarantee: no resolved row may bind them. */ + ASSERT(find_resolved(r, "App.boot", "Config.load") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 6. Superclass chain ────────────────────────────────────────── */ + +TEST(rubylsp_inheritance) { + const char *src = "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " def work\n" + " helper\n" + " self.helper\n" + " end\n" + "end\n" + "class Driver\n" + " def drive\n" + " c = Child.new\n" + " c.helper\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Inherited method resolves to Base.helper from both dispatch shapes. */ + ASSERT(require_resolved(r, "Child.work", "Base.helper") >= 0); + ASSERT(require_resolved(r, "Driver.drive", "Base.helper") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7. include mixin ───────────────────────────────────────────── */ + +TEST(rubylsp_include_mixin) { + const char *src = "module Greetable\n" + " def greet\n" + " 'hello'\n" + " end\n" + "end\n" + "class Person\n" + " include Greetable\n" + " def hail\n" + " greet()\n" + " end\n" + "end\n" + "class Caller\n" + " def run\n" + " p = Person.new\n" + " p.greet\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Person.hail", "Greetable.greet") >= 0); + ASSERT(require_resolved(r, "Caller.run", "Greetable.greet") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7b. prepend mixin shadows the class's own method ───────────── */ + +/* Ruby's lookup order puts prepended modules AHEAD of the class itself, which + * is the whole point of `prepend` over `include`. Both an own method and a + * prepended one exist here, so a resolver that walked the class first would + * still emit an edge — just the wrong one. Pin the winner, not merely that + * something resolved. */ +TEST(rubylsp_prepend_mixin) { + const char *src = "module Audited\n" + " def save\n" + " 'audited'\n" + " end\n" + "end\n" + "class Record\n" + " prepend Audited\n" + " def save\n" + " 'plain'\n" + " end\n" + "end\n" + "class App\n" + " def run\n" + " r = Record.new\n" + " r.save\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* The prepended module wins. */ + ASSERT(require_resolved(r, "App.run", "Audited.save") >= 0); + /* And the shadowed own method must NOT also be emitted. */ + ASSERT(find_resolved(r, "App.run", "Record.save") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 8. extend mixin (class-side) ───────────────────────────────── */ + +TEST(rubylsp_extend_mixin) { + const char *src = "module Findable\n" + " def locate(id)\n" + " id\n" + " end\n" + "end\n" + "class Record\n" + " extend Findable\n" + "end\n" + "class App\n" + " def run\n" + " Record.locate(7)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.run", "Findable.locate") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 9. super dispatch ──────────────────────────────────────────── */ + +TEST(rubylsp_super_dispatch) { + const char *src = "class Base\n" + " def setup\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " def setup\n" + " super\n" + " 2\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "Child.setup", "Base.setup", "ruby_method_super"); + if (!rc) + (void)require_resolved(r, "Child.setup", "Base.setup"); + ASSERT(rc); + cbm_free_result(r); + PASS(); +} + +/* ── 10. Instance-variable typing ───────────────────────────────── */ + +TEST(rubylsp_ivar_typing) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class Car\n" + " def initialize\n" + " @engine = Engine.new\n" + " end\n" + " def drive\n" + " @engine.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Car.drive", "Engine.start") >= 0); + ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method")); + cbm_free_result(r); + PASS(); +} + +/* Conflicting ivar assignments must suppress the type (zero-edge). */ +TEST(rubylsp_ivar_conflict_no_edge) { + const char *src = "class A\n" + " def go\n" + " 1\n" + " end\n" + "end\n" + "class B\n" + " def go\n" + " 2\n" + " end\n" + "end\n" + "class Holder\n" + " def initialize(flag)\n" + " @x = A.new\n" + " @x = B.new\n" + " end\n" + " def run\n" + " @x.go\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Holder.run", ".go") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 11. Chained calls ──────────────────────────────────────────── */ + +TEST(rubylsp_chained_constructor_call) { + const char *src = "class Builder\n" + " def finish\n" + " 1\n" + " end\n" + "end\n" + "class App\n" + " def run\n" + " Builder.new.finish\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.run", "Builder.finish") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 12. Nested modules + lexical nesting ───────────────────────── */ + +TEST(rubylsp_nested_modules) { + const char *src = "module Admin\n" + " class User\n" + " def self.lookup(id)\n" + " id\n" + " end\n" + " def name\n" + " 'n'\n" + " end\n" + " end\n" + " class Panel\n" + " def show\n" + " User.lookup(1)\n" + " u = User.new\n" + " u.name\n" + " end\n" + " end\n" + "end\n" + "class Outside\n" + " def probe\n" + " Admin::User.lookup(2)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Lexical reference from sibling class inside the module. */ + ASSERT(require_resolved(r, "Panel.show", "User.lookup") >= 0); + ASSERT(require_resolved(r, "Panel.show", "User.name") >= 0); + /* Fully-qualified reference from outside. */ + ASSERT(require_resolved(r, "Outside.probe", "User.lookup") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 13. ActiveRecord model typing ──────────────────────────────── */ + +TEST(rubylsp_activerecord_model_typing) { + const char *src = "class User < ApplicationRecord\n" + " def full_name\n" + " 'x'\n" + " end\n" + "end\n" + "class UsersController < ApplicationController\n" + " def show\n" + " u = User.find(1)\n" + " u.full_name\n" + " end\n" + " def index\n" + " User.where(active: true).first.full_name\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* User.find returns User (AR query typing); u.full_name resolves. */ + ASSERT(require_resolved(r, "UsersController.show", "User.full_name") >= 0); + /* Relation approximation keeps the model type through where/first. */ + ASSERT(require_resolved(r, "UsersController.index", "User.full_name") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 14. Top-level functions ────────────────────────────────────── */ + +TEST(rubylsp_top_level_function) { + const char *src = "def helper(x)\n" + " x\n" + "end\n" + "def run\n" + " helper(1)\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "main.run", "main.helper", "ruby_function_local"); + if (!rc) + (void)require_resolved(r, "main.run", "main.helper"); + ASSERT(rc); + cbm_free_result(r); + PASS(); +} + +/* ── 15. Negative: unresolvable receiver emits no edge ──────────── */ + +TEST(rubylsp_unknown_receiver_no_edge) { + const char *src = "class Safe\n" + " def process(payload)\n" + " payload.transform\n" + " mystery = fetch_thing\n" + " mystery.explode\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Neither the untyped parameter nor the unresolved local may produce + * a resolved row. */ + ASSERT(find_resolved(r, "Safe.process", "transform") < 0); + ASSERT(find_resolved(r, "Safe.process", "explode") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16. Negative: dynamic dispatch emits no edge ───────────────── */ + +TEST(rubylsp_send_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def invoke\n" + " t = Target.new\n" + " t.send(:hidden)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.invoke", "hidden") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16b. Negative: the whole dynamic-dispatch family emits no edge ── */ + +/* `send` has its own row above. The zero-edge guarantee is advertised for the + * whole reflective family, so pin the rest of it too: a resolver that treated + * any of these as an ordinary call would invent an edge to `hidden`. */ +TEST(rubylsp_dynamic_dispatch_family_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def via_public_send\n" + " Target.new.public_send(:hidden)\n" + " end\n" + " def via_underscore_send\n" + " Target.new.__send__(:hidden)\n" + " end\n" + " def via_define_method\n" + " Target.define_method(:hidden) { 2 }\n" + " end\n" + " def via_instance_eval\n" + " Target.new.instance_eval { hidden }\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.via_public_send", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_underscore_send", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_define_method", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_instance_eval", "hidden") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── suite ──────────────────────────────────────────────────────── */ + +void suite_ruby_lsp(void) { + RUN_TEST(rubylsp_qn_contract); + RUN_TEST(rubylsp_method_via_constructor_assignment); + RUN_TEST(rubylsp_constructor_edge_targets_class); + RUN_TEST(rubylsp_self_dispatch); + RUN_TEST(rubylsp_singleton_method_dispatch); + RUN_TEST(rubylsp_singleton_instance_split); + RUN_TEST(rubylsp_inheritance); + RUN_TEST(rubylsp_include_mixin); + RUN_TEST(rubylsp_prepend_mixin); + RUN_TEST(rubylsp_extend_mixin); + RUN_TEST(rubylsp_super_dispatch); + RUN_TEST(rubylsp_ivar_typing); + RUN_TEST(rubylsp_ivar_conflict_no_edge); + RUN_TEST(rubylsp_chained_constructor_call); + RUN_TEST(rubylsp_nested_modules); + RUN_TEST(rubylsp_activerecord_model_typing); + RUN_TEST(rubylsp_top_level_function); + RUN_TEST(rubylsp_unknown_receiver_no_edge); + RUN_TEST(rubylsp_send_no_edge); + RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); +} From 9f204925dd93aecf8ca8010d65c3b808864b1d50 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:30:30 +0800 Subject: [PATCH 02/11] fix(ruby-lsp): build compact class QNs the way the extractor does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural extractor names a class by appending the declaration's raw name text to the enclosing class QN, so `class Admin::User` yields `.Admin::User` while `module Admin; class User` yields `.Admin.User`. The resolver normalised both to the dotted form, and because the pipeline joins resolved rows to defs by exact caller QN, every row from or to a compact-declared class was silently dropped — the most common Rails style. All three passes now enter a class through one shared frame helper that computes the extractor-shaped QN from the raw name and keeps the dotted path only for constant lookup. The class table dedupes on QN. Nesting is now one raw frame per declaration joined by '.', and the constant probe strips one frame at a time: inside `class A::B` only `A.B.x` and `x` are tried, never `A.x`, matching Ruby's lexical scoping (`class A::B` does not open A). Tests pin exact QN equality on both ends of the row for compact, nested-compact, and the outer-scope-not-opened case. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 201 +++++++++++++++++++++++------------- internal/cbm/lsp/ruby_lsp.h | 21 ++-- tests/test_ruby_lsp.c | 149 ++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 77 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 2ca944df2..56f74e8cb 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -38,11 +38,16 @@ * 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 `module_qn..` with - * parent_class = `module_qn.`. 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. + * (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). Dynamic dispatch @@ -121,6 +126,76 @@ static const char *ruby_path_join(CBMArena *a, const char *left, const char *rig 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; +} 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->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; + 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; +} + /* 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 @@ -139,9 +214,12 @@ static RubyClassInfo *ruby_add_class(RubyLSPContext *ctx, const char *path, cons bool is_module) { if (!ctx || !path || !qn) return NULL; - /* Reopened class/module: reuse the existing record. */ + /* 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].path, path) == 0) + if (strcmp(ctx->classes[i].qn, qn) == 0) return &ctx->classes[i]; } if (ctx->class_count >= ctx->class_cap) { @@ -266,37 +344,45 @@ static const char *ruby_superclass_qn(RubyLSPContext *ctx, const char *class_qn) /* ── constant resolution ────────────────────────────────────────── */ +/* Class-table probe by dotted path. */ +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; + } + return NULL; +} + const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path) { if (!ctx || !path || !path[0]) return NULL; - /* Lexical nesting probe: for nesting "A.B" try "A.B.path", "A.path", - * then "path" (innermost first — Ruby's constant lookup order). */ + /* 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 (1) { - if (prefix[0]) { - const char *cand = cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, path); - for (int i = 0; i < ctx->class_count; i++) { - if (strcmp(ctx->classes[i].path, cand) == 0) - return ctx->classes[i].qn; - } - char *last_dot = strrchr(prefix, '.'); - if (last_dot) - *last_dot = '\0'; - else - prefix[0] = '\0'; - continue; - } - break; + 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'; } } - for (int i = 0; i < ctx->class_count; i++) { - if (strcmp(ctx->classes[i].path, path) == 0) - return ctx->classes[i].qn; - } + 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) @@ -808,27 +894,15 @@ static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton /* Process a class/module node during PASS 2: update nesting + enclosing * class, then walk the body. */ static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - const char *saved_nesting = ctx->nesting; - const char *saved_class = ctx->enclosing_class_qn; - bool saved_singleton = ctx->in_singleton_method; - - ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); - const char *qn = ruby_resolve_constant(ctx, cpath); - ctx->enclosing_class_qn = qn; - ctx->in_singleton_method = false; - 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->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; - ctx->in_singleton_method = saved_singleton; + ruby_class_leave(ctx, &fr); } static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node) { @@ -992,20 +1066,15 @@ static void ruby_collect_mixin_call(RubyLSPContext *ctx, TSNode call) { } static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - - const char *saved_nesting = ctx->nesting; - const char *full_path = ruby_path_join(ctx->arena, saved_nesting, cpath); - const char *qn = ctx->module_qn - ? cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, full_path) - : full_path; - RubyClassInfo *ci = ruby_add_class(ctx, full_path, qn, is_module); + RubyClassInfo *ci = ruby_add_class(ctx, fr.path, fr.qn, is_module); /* Superclass: `class C < Base` — the `superclass` field wraps the - * expression after `<`. */ + * 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)) { @@ -1013,24 +1082,20 @@ static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { 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; } } } } - ctx->nesting = full_path; - const char *saved_class = ctx->enclosing_class_qn; - bool saved_singleton = ctx->in_singleton_method; - ctx->enclosing_class_qn = qn; - ctx->in_singleton_method = false; TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_pass1_scan(ctx, body); - ctx->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; - ctx->in_singleton_method = saved_singleton; + ruby_class_leave(ctx, &fr); } static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node) { @@ -1145,19 +1210,13 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { const char *k = ts_node_type(node); if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - const char *saved_nesting = ctx->nesting; - const char *saved_class = ctx->enclosing_class_qn; - ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); - ctx->enclosing_class_qn = ruby_resolve_constant(ctx, cpath); TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_ivar_scan(ctx, body); - ctx->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; + ruby_class_leave(ctx, &fr); return; } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index f6de32943..e52d148c5 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -17,10 +17,16 @@ typedef enum { } RubyMixinKind; /* One class/module discovered in the file (or, cross-file, in the project). - * `path` is the dotted source-level constant path (e.g. "Admin.User" for - * `Admin::User`); `qn` is the full graph QN (module_qn.path for file-local - * classes; the extractor QN for cross-file defs). `superclass_qn` is resolved - * against the class table + stdlib after collection; NULL when unknown. */ + * `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; @@ -71,8 +77,11 @@ typedef struct { CBMTypeRegistry *build_reg; CBMScope *current_scope; - /* Lexical nesting: dotted constant path of the enclosing class/module - * chain ("" at top level, "A.B" inside `module A; class B`). */ + /* 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; /* Class/module table (pass 1). */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 23c37314e..a2b455f31 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -88,6 +88,41 @@ static const CBMResolvedCall *find_resolved_with_strategy(const CBMFileResult *r 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; +} + 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]; @@ -487,6 +522,117 @@ TEST(rubylsp_nested_modules) { 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) { @@ -628,6 +774,9 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_ivar_conflict_no_edge); 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); From c2d8644777066a0670c58cc0d579e5a3b334b42b Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:38:59 +0800 Subject: [PATCH 03/11] fix(ruby-lsp): make def a scope gate and fail closed on untyped reassignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `def` body inherited the enclosing scope, so a typed local from the surrounding body (or a preceding sibling method) typed a same-named parameter or local inside it. Parameters and block parameters were never bound, so they could not shadow an outer local either. And an assignment whose right-hand side had no proven type was ignored, so `a = Foo.new; a = x; a.bar` still resolved to Foo#bar. Ruby scoping is now modeled explicitly: `def`, `class` and `module` bodies start from a fresh, unparented scope; blocks and lambdas get a chained child scope; method, block and lambda parameters (including destructured groups and block-local names) bind to UNKNOWN; and any write the resolver cannot type — plain reassignment, multiple assignment, `||=` on an already-typed name, other compound operators, or an assignment inside a block — rebinds the nearest existing binding to UNKNOWN. `a ||= Foo.new` on a fresh name still types as Foo. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 161 ++++++++++++++++++++++++++++++++++-- tests/test_ruby_lsp.c | 159 +++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 6 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 56f74e8cb..06b5b5e2f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -844,6 +844,87 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode 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) { @@ -851,26 +932,66 @@ static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { 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) + 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); - if (rt && rt->kind == CBM_TYPE_NAMED) - cbm_scope_bind(ctx->current_scope, vname, rt); + /* 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. */ +/* 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, ctx->current_scope); + 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); @@ -891,17 +1012,39 @@ static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton 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. */ + * 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); } @@ -940,9 +1083,15 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { ruby_process_method(ctx, node, true); return; } + if (strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0) { + ruby_process_block(ctx, node); + 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); diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index a2b455f31..c4f17e71f 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -123,6 +123,19 @@ static int require_resolved_exact(const CBMFileResult *r, const char *caller_qn, 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]; @@ -700,6 +713,149 @@ TEST(rubylsp_unknown_receiver_no_edge) { 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) { @@ -780,6 +936,9 @@ void suite_ruby_lsp(void) { 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); } From ffeb06f595cf5fe00dc41cd7e0ff6f2cbceba1e2 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:43:38 +0800 Subject: [PATCH 04/11] fix(ruby-lsp): latch ivar type on any non-constructor write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PASS 1.5 only recorded `@x = Const.new` and ignored every other write, so `@x = compute` (or a parameter, a compound operator, a multiple assignment, or an unresolved constant's .new) left the ivar typed by whichever constructor it saw elsewhere. Any such write now latches the conflict, exactly as two different constructors already did; `nil` is neutral. `@x ||= Const.new` — the memoised-constructor idiom — counts as a constructor assignment. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 122 +++++++++++++++++++++++++++++------- tests/test_ruby_lsp.c | 110 ++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 21 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 06b5b5e2f..6d6e7e6db 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -263,24 +263,21 @@ static void ruby_add_mixin(RubyLSPContext *ctx, const char *owner_qn, const char mi->kind = kind; } -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; +/* 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) { - if (strcmp(iv->type_qn, type_qn) != 0) - iv->conflicted = true; /* disagreeing assignments — no type */ - return; - } + 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; + return NULL; for (int i = 0; i < ctx->ivar_count; i++) ni[i] = ctx->ivars[i]; ctx->ivars = ni; @@ -290,7 +287,34 @@ static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char 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); - iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); + 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, @@ -299,7 +323,7 @@ static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, return NULL; for (int i = 0; i < ctx->ivar_count; i++) { RubyIvarInfo *iv = &ctx->ivars[i]; - if (!iv->conflicted && strcmp(iv->class_qn, class_qn) == 0 && + if (!iv->conflicted && iv->type_qn && strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) return iv->type_qn; } @@ -1345,6 +1369,44 @@ static const char *ruby_static_ctor_type(RubyLSPContext *ctx, TSNode expr) { 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; @@ -1369,15 +1431,33 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { return; } - if (strcmp(k, "assignment") == 0 && ctx->enclosing_class_qn) { - 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) && !ts_node_is_null(right) && - strcmp(ts_node_type(left), "instance_variable") == 0) { - const char *tqn = ruby_static_ctor_type(ctx, right); - if (tqn) { - char *ivar = ruby_node_text(ctx, left); - ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + 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); + } } } } diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index c4f17e71f..ba578d204 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -479,6 +479,114 @@ TEST(rubylsp_ivar_conflict_no_edge) { 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) { @@ -928,6 +1036,8 @@ void suite_ruby_lsp(void) { 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); From 50f28fbae520cfead28ff6c331954ed414564c21 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:55:33 +0800 Subject: [PATCH 05/11] fix(ruby-lsp): suppress self dispatch inside blocks that rebind self instance_eval/instance_exec, class_eval/class_exec, module_eval/ module_exec, define_method and Class.new/Module.new run their block with a different `self`, but bare and `self.` calls inside them were dispatched on the enclosing class. The eval call itself was already typed unknown; the walk into its block was not. The block of such a call is now walked with self_unknown set: bare calls, `self.` calls and `super` emit nothing there, `self` has no type, and PASS 1.5 does not attribute ivar writes inside it to the enclosing class. Receiver and argument expressions of the same call still resolve, and the suppression ends with the block. The *_exec forms join the dynamic-dispatch list. The existing negative test passed only because Meta had no `hidden`; it now has one, and the calls are written with `()` because tree-sitter-ruby parses a lone identifier as a variable reference, which the extractor never emits as a call. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 78 ++++++++++++++++++++++++++++--- internal/cbm/lsp/ruby_lsp.h | 5 ++ tests/test_ruby_lsp.c | 92 ++++++++++++++++++++++++++++++++++++- 3 files changed, 166 insertions(+), 9 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 6d6e7e6db..f7f41e10f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -83,6 +83,7 @@ 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 ────────────────────────────────────────────────────── */ @@ -159,6 +160,7 @@ typedef struct { const char *saved_nesting; const char *saved_class; bool saved_singleton; + bool saved_self_unknown; } RubyClassFrame; /* Enter a class/module node: compute names, push nesting + enclosing class. @@ -175,6 +177,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f 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->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); if (fr->saved_class) @@ -187,6 +190,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f 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 */ return true; } @@ -194,6 +198,7 @@ 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; } /* True when a QN belongs to the indexed project (module_qn's first segment @@ -630,7 +635,7 @@ const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { if (tqn) result = cbm_type_named(ctx->arena, tqn); } else if (strcmp(k, "self") == 0) { - if (ctx->enclosing_class_qn && !ctx->in_singleton_method) + 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); @@ -679,9 +684,40 @@ const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { 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, "class_eval") == 0 || - strcmp(m, "module_eval") == 0 || strcmp(m, "instance_variable_get") == 0 || - strcmp(m, "instance_variable_set") == 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 @@ -770,6 +806,8 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* 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) { const CBMRegisteredFunc *f = ctx->in_singleton_method @@ -799,7 +837,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* self.m — explicit self dispatch. */ if (strcmp(rk, "self") == 0) { - if (!ctx->enclosing_class_qn) + if (!ctx->enclosing_class_qn || ctx->self_unknown) return cbm_type_unknown(); const CBMRegisteredFunc *f = ctx->in_singleton_method @@ -845,7 +883,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* `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) + 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, '.'); @@ -1123,6 +1161,26 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { 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. */ @@ -1462,10 +1520,16 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { } } + /* 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)) + if (!ts_node_is_null(c) && !(!ts_node_is_null(skip_blk) && ts_node_eq(c, skip_blk))) ruby_ivar_scan(ctx, c); } } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index e52d148c5..dc9271d5b 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -109,6 +109,11 @@ typedef struct { 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; const char *module_qn; /* Output: resolved calls accumulate here. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index ba578d204..a3d4aca6e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -989,7 +989,14 @@ TEST(rubylsp_send_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`. */ + * 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" @@ -997,6 +1004,9 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { " 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" @@ -1007,7 +1017,7 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { " Target.define_method(:hidden) { 2 }\n" " end\n" " def via_instance_eval\n" - " Target.new.instance_eval { hidden }\n" + " Target.new.instance_eval { hidden() }\n" " end\n" "end\n"; CBMFileResult *r = extract_ruby(src); @@ -1020,6 +1030,83 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { 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(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1051,4 +1138,5 @@ void suite_ruby_lsp(void) { 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); } From 60801bc53fd19959c7b14083f18ad66167c86566 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:05:01 +0800 Subject: [PATCH 06/11] fix(ruby-lsp): fail closed on unresolved ancestry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup walks skipped a mixin whose module did not resolve and carried on to the class's own methods and superclass chain, and a bare call whose class ancestry did not resolve fell through to a same-named top-level def. An unseen ancestor may define or shadow the method, so none of those answers is trustworthy. Every lookup now carries a `blocked` flag set the moment the walk meets an unresolved prepend, include, extend or superclass reference, in Ruby's lookup order: an unresolved prepend blocks even an own-method hit; an unresolved include (or superclass) stops the walk before the superclass chain; an unresolved extend blocks the singleton walk. When blocked, nothing is emitted — no Object typing fallback, no top-level fallthrough, no `super` edge. Hits found before the unseen ancestor are still returned (an own method ahead of an unresolved include; a resolved include listed after an unresolved one). Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 123 ++++++++++++++++++++++++++---------- tests/test_ruby_lsp.c | 121 +++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 34 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index f7f41e10f..1411c3649 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -437,23 +437,44 @@ static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, c return cbm_registry_lookup_method(ctx->registry, key, method_name); } +/* 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); + 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) { + 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. */ + * 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) { + 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 || !mi->module_qn) + 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); - if (f) + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, blocked); + if (f || *blocked) return f; } return NULL; @@ -462,30 +483,41 @@ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth) { + 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. */ + /* 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); - if (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; - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth); - if (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); + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, blocked); return NULL; } -const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, - const char *method_name) { - const CBMRegisteredFunc *f = ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0); - if (f) +/* 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. */ @@ -494,28 +526,39 @@ const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const 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) { + 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); - if (f) + 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); + 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) { - return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0); + bool blocked = false; + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &blocked); } /* ── ActiveRecord model detection (typing special case) ─────────── */ @@ -809,16 +852,23 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool 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(ctx, ctx->enclosing_class_qn, mname) - : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + ? 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); @@ -839,10 +889,12 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (strcmp(rk, "self") == 0) { if (!ctx->enclosing_class_qn || ctx->self_unknown) return cbm_type_unknown(); - const CBMRegisteredFunc *f = - ctx->in_singleton_method - ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) - : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + 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, @@ -889,19 +941,22 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { 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. */ + * 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); - if (!f) { + 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) - : ruby_lookup_instance_method_depth(ctx, sup, mname, 0); + 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) + 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. */ } diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index a3d4aca6e..1ad45e314 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -379,6 +379,125 @@ TEST(rubylsp_prepend_mixin) { 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(); +} + /* ── 8. extend mixin (class-side) ───────────────────────────────── */ TEST(rubylsp_extend_mixin) { @@ -1119,6 +1238,8 @@ void suite_ruby_lsp(void) { 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_extend_mixin); RUN_TEST(rubylsp_super_dispatch); RUN_TEST(rubylsp_ivar_typing); From 8bc99a40587b1d12604738417af4ba3b392788e7 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:12:59 +0800 Subject: [PATCH 07/11] fix(ruby-lsp): do not register block-nested defs as class members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PASS 1 recursed through blocks and registered any `def` it met on the enclosing class or module, so ActiveSupport's `class_methods do … end` and `included do … end` bodies became instance methods of the concern and were then found through `include Concern` — edges to defs the structural extractor does not even emit. `include X` written inside such a block was likewise recorded on the concern instead of the includer. PASS 1 now tracks the block depth since the nearest class/module body and, inside a block, registers no methods and collects no mixins. In PASS 2 a class-body block def is skipped outright (no def exists for it to join), and a top-level block def (an RSpec group) is walked with self unknown: typed-receiver calls in its body may still resolve, bare and self. calls emit nothing, and other top-level code cannot reach it. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 44 ++++++++++++++++---- internal/cbm/lsp/ruby_lsp.h | 6 +++ tests/test_ruby_lsp.c | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 1411c3649..c0063212f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -161,6 +161,7 @@ typedef struct { 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. @@ -178,6 +179,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f 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) @@ -191,6 +193,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f 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; } @@ -199,6 +202,7 @@ static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { 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 @@ -1192,16 +1196,29 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { ctx->in_singleton_method = saved; return; } - if (strcmp(k, "method") == 0) { - ruby_process_method(ctx, node, ctx->in_singleton_method); - return; - } - if (strcmp(k, "singleton_method") == 0) { - ruby_process_method(ctx, node, true); + 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; } @@ -1418,6 +1435,11 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { 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); @@ -1425,7 +1447,9 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { 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) { + 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); @@ -1437,12 +1461,18 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { } } + 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 diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index dc9271d5b..ad2d81de3 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -114,6 +114,12 @@ typedef struct { * 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. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 1ad45e314..63f764f34 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -498,6 +498,85 @@ TEST(rubylsp_resolved_ancestry_still_resolves) { 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) { @@ -1240,6 +1319,8 @@ void suite_ruby_lsp(void) { 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); From 0f8b7194d81f6e3c4ba5c2da87141cbdebd9f87a Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:17:46 +0800 Subject: [PATCH 08/11] test(ruby-lsp): exact-name assertions for nested-class rows The nested-modules row matched caller and callee by substring, which would have let a wrongly-spelled class path pass; it now pins the full extractor QNs on both ends with strcmp, as the compact-class rows already do. Refresh the file headers to describe the scope gates, fail-closed ancestry and self-rebinding rules the resolver now follows. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 44 +++++++++++++++++++++++++++---------- tests/test_ruby_lsp.c | 24 +++++++++++++++----- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index c0063212f..ed97c78ec 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -12,27 +12,41 @@ * 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`). + * `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` - * assignments inside method bodies (conflicting types latch to - * "conflicted" and resolve to nothing). + * (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, and resolve call expressions into CBMResolvedCall - * edges via Ruby method lookup (prepends → own → includes → - * superclass chain; extends feed the singleton side). + * 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. + * `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`. + * - 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. @@ -50,9 +64,17 @@ * 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). Dynamic dispatch - * (`send`, `public_send`, `method_missing`, `define_method`) is - * intentionally ignored. + * 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" diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 63f764f34..d1999357f 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -16,10 +16,14 @@ * 10. Instance-variable typing (@thing = Foo.new; @thing.bar) * 11. Chained call typing (Foo.new.bar) * 12. Nested modules (lexical nesting + `A::B` references) + * 12b. Compact `class A::B` names (exact extractor spelling, scope) * 13. ActiveRecord model typing (User.find(1).full_name) * 14. Top-level function calls * 15. Unresolvable receiver emits NO spurious edge (negative) + * 15b. Scope gates (def, params, blocks, reassignment) * 16. send() / the whole dynamic-dispatch family emits NO edge (negative) + * 16c. Blocks that rebind self emit NO self edge (negative) + * Fail-closed ancestry (7c) and block-nested defs (7d) sit with mixins. * * Scope: every row here is SINGLE-FILE, driven through cbm_extract_file so it * exercises the per-file resolver exactly as the extraction pipeline calls it. @@ -27,9 +31,10 @@ * pass_lsp_cross.c) is a separate tier and is covered by its own rows. * * The resolver populates result->resolved_calls with CBMResolvedCall rows. - * Ruby defs weave the class path into the QN (module_qn.Animal.speak), so - * for these single-file fixtures ("test"/"main.rb" -> module QN test.main) - * callee fragments like "main.Animal.speak" are unique join keys. + * 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" @@ -832,11 +837,18 @@ TEST(rubylsp_nested_modules) { "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(r, "Panel.show", "User.lookup") >= 0); - ASSERT(require_resolved(r, "Panel.show", "User.name") >= 0); + 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(r, "Outside.probe", "User.lookup") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin.User.lookup") >= + 0); cbm_free_result(r); PASS(); } From 0210ea97940ed9c3af670fb11758b23a768cd69e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:21 +0100 Subject: [PATCH 09/11] fix(ruby-lsp): search each ancestor once per method lookup The mixin walk recursed into prepend and include at every level with only the depth cap of 16 as a bound, so a module that both prepends and includes the next one doubled the paths per level: a miss on a chain of n such modules cost 2^n visits (measured 4 -> 20,744 for n = 2..14). Each lookup is now one walk that carries the fail-closed blocked flag and a visit stamp. Ruby linearizes ancestors, so a module reached a second time in one walk was already searched and missed (a hit or a block would have ended the walk); skipping the repeat keeps every lookup linear in the ancestors the file declares. Lookup order, hits and the fail-closed rules are unchanged. The stamps live on the per-file class table, so the walk allocates nothing. The walk entries feed CBM_LSP_WORK (seam builds only; product builds compile it to nothing). rubylsp_mixin_walk_visits_each_module_once pins linear work for the doubled chain on the instance and the class side (before: 66 -> 4,098 visits for n = 6 -> 12; after: 14 -> 26) and keeps a diamond and a prepend-plus-include pair resolving exactly once. Signed-off-by: Martin Vogel --- internal/cbm/lsp/ruby_lsp.c | 135 ++++++++++++++++++++++++------------ internal/cbm/lsp/ruby_lsp.h | 8 +++ tests/test_ruby_lsp.c | 114 ++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 43 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index ed97c78ec..6c89ac01f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -78,6 +78,7 @@ */ #include "ruby_lsp.h" +#include "lsp_work.h" #include "../helpers.h" #include "../arena.h" #include @@ -470,12 +471,53 @@ static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, c * 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. */ + * lookup order are still returned. + * + * Each lookup is one walk: the `blocked` flag plus a visit stamp. Ruby + * linearizes ancestors, so a module reached a second time in one walk + * (`prepend M; include M`, two includes sharing a module) was already + * searched and missed: a hit or a block would have ended the walk. Skipping + * the repeat keeps a lookup linear in the ancestors it can see; without it + * every module that both prepends and includes the next one doubles the + * paths per level. */ +typedef struct { + bool blocked; + unsigned int stamp; +} RubyLookupWalk; + +static RubyLookupWalk ruby_walk_begin(RubyLSPContext *ctx) { + if (++ctx->lookup_stamp == 0) { + /* Wrapped: clear every mark so no class looks visited. */ + for (int i = 0; i < ctx->class_count; i++) { + ctx->classes[i].seen_instance = 0; + ctx->classes[i].seen_singleton = 0; + } + ctx->lookup_stamp = 1; + } + RubyLookupWalk w = {false, ctx->lookup_stamp}; + return w; +} + +/* Marks `class_qn` searched on one side (instance or singleton) in this walk; + * true when it already was. Only the file's class table is marked: mixins + * and superclass refs are recorded for those classes alone, so a type outside + * the table (a stdlib type) has nothing to expand and is never a repeat. */ +static bool ruby_walk_seen(RubyLSPContext *ctx, RubyLookupWalk *w, const char *class_qn, + bool singleton) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (!ci) + return false; + unsigned int *mark = singleton ? &ci->seen_singleton : &ci->seen_instance; + if (*mark == w->stamp) + return true; + *mark = w->stamp; + return false; +} static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked); + int depth, RubyLookupWalk *w); /* True when the class declares a superclass the finalize step could not * resolve (stdlib-seeded conventions like ApplicationRecord DO resolve). */ @@ -489,18 +531,18 @@ static bool ruby_superclass_unresolved(RubyLSPContext *ctx, const char *class_qn * 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) { + int depth, RubyLookupWalk *w) { 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 */ + w->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) + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, w); + if (f || w->blocked) return f; } return NULL; @@ -509,29 +551,32 @@ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked) { + int depth, RubyLookupWalk *w) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; + CBM_LSP_WORK(1); + if (ruby_walk_seen(ctx, w, class_qn, false)) + return NULL; /* searched earlier in this walk: a repeat can only miss */ /* 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) + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, w); + if (f || w->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) + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, w); + if (f || w->blocked) return f; if (ruby_superclass_unresolved(ctx, class_qn)) { - *blocked = true; + w->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 ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, w); return NULL; } @@ -540,10 +585,10 @@ static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - bool *blocked) { + RubyLookupWalk *w) { const CBMRegisteredFunc *f = - ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, blocked); - if (f || *blocked) + ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, w); + if (f || w->blocked) return f; /* Universal receiver fallback (Object / ActiveSupport predicates) — * typing only; Object methods are stdlib entries, never project defs. */ @@ -554,37 +599,44 @@ static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ct 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); + if (!ctx) + return NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &w); } static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked) { + int depth, RubyLookupWalk *w) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; + CBM_LSP_WORK(1); + if (ruby_walk_seen(ctx, w, class_qn, true)) + return NULL; /* searched earlier in this walk: a repeat can only miss */ 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) + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, w); + if (f || w->blocked) return f; if (ruby_superclass_unresolved(ctx, class_qn)) { - *blocked = true; + w->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 ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, w); 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); + if (!ctx) + return NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &w); } /* ── ActiveRecord model detection (typing special case) ─────────── */ @@ -878,13 +930,11 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (ctx->self_unknown) return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ if (ctx->enclosing_class_qn) { - bool blocked = false; + RubyLookupWalk w = ruby_walk_begin(ctx); 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); + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, @@ -893,7 +943,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool } /* An unseen ancestor may define this method: do NOT fall through * to a same-named top-level def. */ - if (blocked) + if (w.blocked) return cbm_type_unknown(); } const CBMRegisteredFunc *f = @@ -915,12 +965,11 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool 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); + RubyLookupWalk w = ruby_walk_begin(ctx); + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, @@ -967,22 +1016,22 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { const char *mname = strrchr(fq, '.'); mname = mname ? mname + 1 : fq; const CBMRegisteredFunc *f = NULL; - bool blocked = false; + RubyLookupWalk w = ruby_walk_begin(ctx); /* 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)) { + mname, 0, &w); + if (!f && !w.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); + ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &w); } } - if (f && !blocked) + if (f && !w.blocked) ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); /* No known parent or unresolved — zero-edge guarantee. */ } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index ad2d81de3..317702cb6 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -33,6 +33,10 @@ typedef struct { 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` */ + /* Method-lookup visit marks: the stamp of the last walk that searched + * this class on the instance / singleton side (ruby_walk_seen). */ + unsigned int seen_instance; + unsigned int seen_singleton; } RubyClassInfo; /* One `include`/`prepend`/`extend` record: owner class QN → module ref. */ @@ -129,6 +133,10 @@ typedef struct { int eval_depth; int walk_depth; + /* Stamp of the current method-lookup walk; each walk takes a fresh one + * so marks left by earlier walks never match. */ + unsigned int lookup_stamp; + /* Debug mode (CBM_LSP_DEBUG env). */ bool debug; } RubyLSPContext; diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index d1999357f..1e2b2c85e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -40,6 +40,9 @@ #include "cbm.h" #include "../src/pipeline/lsp_resolve.h" #include "lsp/ruby_lsp.h" +#include "lsp/lsp_work.h" +#include +#include #include /* ── Helpers (mirror test_perl_lsp.c) ──────────────────────────── */ @@ -1317,6 +1320,116 @@ TEST(rubylsp_self_rebinding_blocks_no_edge) { PASS(); } +/* ── 17. Bounded resolver work ──────────────────────────────────── + * + * Measured with counters, never with a clock (O9): CBM_LSP_WORK + * (lsp_work.h, seam builds) for lookup work, and the result arena's + * cumulative allocation for bytes built. Each fixture is measured at n and + * 2n: linear work doubles (ratio about 2); anything worse lands above the + * gate of 3. */ + +static uint64_t rubylsp_counted_work(const char *src, size_t *arena_bytes) { + (void)cbm_lsp_work_take(); + CBMFileResult *r = extract_ruby(src); + uint64_t work = cbm_lsp_work_take(); + if (arena_bytes) + *arena_bytes = r ? cbm_arena_total(&r->arena) : 0; + if (r) + cbm_free_result(r); + return work; +} + +/* n modules where every module both prepends and includes the next one; + * the last defines one method. `Probe` reaches M0 through `include` + * (instance side) or `extend` (class side) and calls a method nobody + * defines, so the lookup must miss after searching every ancestor. */ +static char *rubylsp_mixin_chain_src(int n, bool class_side) { + size_t cap = (size_t)n * 96 + 256; + char *src = (char *)malloc(cap); + if (!src) + return NULL; + size_t off = 0; + for (int i = 0; i < n; i++) { + if (i + 1 < n) + off += (size_t)snprintf(src + off, cap - off, + "module M%d\n prepend M%d\n include M%d\nend\n", i, i + 1, + i + 1); + else + off += (size_t)snprintf(src + off, cap - off, + "module M%d\n def leaf\n 1\n end\nend\n", i); + } + if (class_side) + (void)snprintf(src + off, cap - off, + "class Probe\n extend M0\n def self.run\n nobody_defines_this()\n" + " end\nend\n"); + else + (void)snprintf(src + off, cap - off, + "class Probe\n include M0\n def run\n nobody_defines_this()\n" + " end\nend\n"); + return src; +} + +static uint64_t rubylsp_mixin_chain_work(int n, bool class_side) { + char *src = rubylsp_mixin_chain_src(n, class_side); + if (!src) + return 0; + uint64_t work = rubylsp_counted_work(src, NULL); + free(src); + return work; +} + +/* Review ask (a): Ruby linearizes ancestors, so a module reached through + * two paths (`prepend M; include M`, a diamond) is searched once. Without a + * visited set every level of the chain doubles the paths: 2^n visits + * (measured 4 -> 20,744 for n = 2..14). With it a miss costs one visit per + * module. Positive controls: the doubled pair still resolves through the + * prepend, and a diamond resolves to the shared module exactly once. */ +TEST(rubylsp_mixin_walk_visits_each_module_once) { + uint64_t inst_n = rubylsp_mixin_chain_work(6, false); + uint64_t inst_2n = rubylsp_mixin_chain_work(12, false); + uint64_t cls_n = rubylsp_mixin_chain_work(6, true); + uint64_t cls_2n = rubylsp_mixin_chain_work(12, true); + printf(" mixin chain work: instance n=6 %llu, n=12 %llu; class side n=6 %llu, n=12 %llu\n", + (unsigned long long)inst_n, (unsigned long long)inst_2n, (unsigned long long)cls_n, + (unsigned long long)cls_2n); + ASSERT(inst_n > 0 && cls_n > 0); + ASSERT(inst_2n <= 3 * inst_n); + ASSERT(cls_2n <= 3 * cls_n); + + const char *src = "module Shared\n" + " def hello\n" + " 1\n" + " end\n" + "end\n" + "module Left\n" + " include Shared\n" + "end\n" + "module Right\n" + " include Shared\n" + "end\n" + "module Twice\n" + " def twice\n" + " 2\n" + " end\n" + "end\n" + "class Diamond\n" + " include Left\n" + " include Right\n" + " prepend Twice\n" + " include Twice\n" + " def run\n" + " hello()\n" + " twice()\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Shared.hello") == 1); + ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Twice.twice") == 1); + cbm_free_result(r); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1353,4 +1466,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_send_no_edge); RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); + RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); } From 214a9ac8436ba8e2277bce533726a8ffdc1ed187 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:28 +0100 Subject: [PATCH 10/11] fix(ruby-lsp): build constant paths in one pass ruby_const_path recursed once per :: segment and re-formatted the growing prefix at every level, so an A::B::C path cost one stack frame per segment and quadratic bytes in the per-file arena. It now follows the scope fields in a loop: one pass measures the segments, a second copies them right to left into a single buffer. The result is the same string as before, including a leading ::Foo and a scope that is not a constant path. rubylsp_const_path_is_linear pins the arena bytes for an 800 vs a 1,600 segment path (before: 494,520 -> 1,660,520 bytes already at 400 -> 800 segments; after: 136,248 -> 150,848 at 800 -> 1,600) and the exact class QN for a five-level path and for a rooted ::Top::Inner. Signed-off-by: Martin Vogel --- internal/cbm/lsp/ruby_lsp.c | 77 +++++++++++++++++++++++++++++++------ tests/test_ruby_lsp.c | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 6c89ac01f..2464a6891 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -114,10 +114,52 @@ static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) { return cbm_node_text(ctx->arena, node, ctx->source); } +/* Byte length of a node's source text (0 for an empty node). */ +static size_t ruby_node_len(TSNode node) { + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + return end > start ? (size_t)(end - start) : 0; +} + +/* One step left along a constant path. `*cur` is a scope_resolution whose + * own segment has been taken; on success `*seg` is the next segment to its + * left and `*cur` the node to continue from. The path ends at a missing + * scope (`::Foo`), at a scope that is not a constant path (`expr::Foo`), + * and at an empty segment. A plain `constant` has no scope field, so it is + * always the last segment. */ +static bool ruby_const_step(TSNode *cur, TSNode *seg) { + TSNode scope = ts_node_child_by_field_name(*cur, "scope", 5); + if (ts_node_is_null(scope)) + return false; + const char *k = ts_node_type(scope); + if (strcmp(k, "constant") == 0) { + if (ruby_node_len(scope) == 0) + return false; + *seg = scope; + *cur = scope; + return true; + } + if (strcmp(k, "scope_resolution") != 0) + return false; + TSNode name = ts_node_child_by_field_name(scope, "name", 4); + if (ts_node_is_null(name) || ruby_node_len(name) == 0) + return false; + *seg = name; + *cur = scope; + return true; +} + /* Convert a constant expression node (constant | scope_resolution) into a * dotted path ("Foo", "A.B.C"). Returns NULL for anything else. A leading * "::Foo" (scope_resolution without a scope) yields "Foo" — the resolver's - * table probe covers the top-level meaning. */ + * table probe covers the top-level meaning. A scope that is not itself a + * constant path (`expr::Foo`) contributes nothing. + * + * `A::B::C` nests to the LEFT (scope_resolution(scope_resolution(A, B), C)), + * so the path is read from the outermost node along the `scope` fields: one + * pass measures the segments, a second copies them right to left into one + * buffer. No recursion and no re-formatting of a growing prefix, so neither + * stack depth nor bytes grow faster than the path itself. */ static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { if (ts_node_is_null(node)) return NULL; @@ -126,19 +168,32 @@ static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { return ruby_node_text(ctx, node); if (strcmp(k, "scope_resolution") != 0) return NULL; - TSNode scope = ts_node_child_by_field_name(node, "scope", 5); TSNode name = ts_node_child_by_field_name(node, "name", 4); - if (ts_node_is_null(name)) + if (ts_node_is_null(name) || ruby_node_len(name) == 0) return NULL; - char *nm = ruby_node_text(ctx, name); - if (!nm || !nm[0]) + + size_t total = 0; + TSNode cur = node; + TSNode seg = name; + do { + total += ruby_node_len(seg) + 1; /* segment + its '.' (or the NUL) */ + } while (ruby_const_step(&cur, &seg)); + + char *out = (char *)cbm_arena_alloc(ctx->arena, total); + if (!out) return NULL; - if (ts_node_is_null(scope)) - return nm; /* ::Foo */ - const char *sp = ruby_const_path(ctx, scope); - if (!sp || !sp[0]) - return nm; - return cbm_arena_sprintf(ctx->arena, "%s.%s", sp, nm); + size_t pos = total - 1; + out[pos] = '\0'; + cur = node; + seg = name; + do { + size_t len = ruby_node_len(seg); + pos -= len; + memcpy(out + pos, ctx->source + ts_node_start_byte(seg), len); + if (pos > 0) + out[--pos] = '.'; + } while (ruby_const_step(&cur, &seg)); + return out; } /* Join two dotted path fragments; either side may be empty/NULL. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 1e2b2c85e..4aa00b9f8 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -1430,6 +1430,74 @@ TEST(rubylsp_mixin_walk_visits_each_module_once) { PASS(); } +/* `class Probe; def run; S0::S1::...::S.new; end; end` */ +static char *rubylsp_deep_const_src(int n) { + size_t cap = (size_t)n * 12 + 128; + char *src = (char *)malloc(cap); + if (!src) + return NULL; + size_t off = (size_t)snprintf(src, cap, "class Probe\n def run\n "); + for (int i = 0; i < n; i++) + off += (size_t)snprintf(src + off, cap - off, i ? "::S%d" : "S%d", i); + (void)snprintf(src + off, cap - off, ".new\n end\nend\n"); + return src; +} + +static size_t rubylsp_deep_const_bytes(int n) { + char *src = rubylsp_deep_const_src(n); + if (!src) + return 0; + size_t bytes = 0; + (void)rubylsp_counted_work(src, &bytes); + free(src); + return bytes; +} + +/* Review ask (b): a constant path is built in one pass. Recursing once per + * `::` and re-formatting the growing prefix at every level costs one stack + * frame per segment and quadratic bytes; the loop has no recursion and + * writes the path once. The positive control pins the exact class QN for a + * deep path and for a leading `::`. */ +TEST(rubylsp_const_path_is_linear) { + size_t b_n = rubylsp_deep_const_bytes(800); + size_t b_2n = rubylsp_deep_const_bytes(1600); + printf(" const path arena bytes: n=800 %zu, n=1600 %zu\n", b_n, b_2n); + ASSERT(b_n > 0); + ASSERT(b_2n <= 3 * b_n); + + const char *src = "module L0\n" + " module L1\n" + " module L2\n" + " module L3\n" + " module L4\n" + " class Leaf\n" + " end\n" + " end\n" + " end\n" + " end\n" + " end\n" + "end\n" + "class Top\n" + " class Inner\n" + " end\n" + "end\n" + "class Caller\n" + " def deep\n" + " L0::L1::L2::L3::L4::Leaf.new\n" + " end\n" + " def rooted\n" + " ::Top::Inner.new\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Caller.deep", "test.main.L0.L1.L2.L3.L4.Leaf") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Caller.rooted", "test.main.Top.Inner") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1467,4 +1535,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); + RUN_TEST(rubylsp_const_path_is_linear); } From 435baf6bd87bbe250ba45b2c4424d11a1e2e057e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:28 +0100 Subject: [PATCH 11/11] fix(ruby-lsp): survive allocation failure in the stdlib seed The REG_* macros wrote through the return-type vector without checking the allocation, and registered entries whose formatted QN came back NULL. A shared ruby_seed_signature helper now leaves a failed vector out, so the signature types as unknown (the fallback ruby_register_method already uses), and REG_METHOD / REG_SMETHOD skip an entry whose formatted name is NULL, since a NULL QN would reach strcmp in finalize and in every lookup. rubylsp_stdlib_seed_survives_alloc_failure seeds a registry from an arena that cannot allocate. Before: UBSan reported a store to a null pointer and the run crashed with SEGV at ruby_stdlib_data.c:163. After: every registered entry carries a name, the registry finalizes, and the Kernel built-ins with literal names still resolve. Signed-off-by: Martin Vogel --- internal/cbm/lsp/generated/ruby_stdlib_data.c | 90 ++++++++++--------- tests/test_ruby_lsp.c | 27 ++++++ 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/internal/cbm/lsp/generated/ruby_stdlib_data.c b/internal/cbm/lsp/generated/ruby_stdlib_data.c index 83e853e65..f94ccd764 100644 --- a/internal/cbm/lsp/generated/ruby_stdlib_data.c +++ b/internal/cbm/lsp/generated/ruby_stdlib_data.c @@ -40,55 +40,59 @@ 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); \ +/* Signature `() -> ret`. Every seed allocation can fail (arena exhausted): + * a NULL return vector is left out, so the signature types as unknown (the + * same fallback ruby_register_method uses) instead of being written through. */ +static const CBMType *ruby_seed_signature(CBMArena *arena, const CBMType *ret) { + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); + if (rets) { + rets[0] = ret; + rets[1] = NULL; + } + return cbm_type_func(arena, NULL, NULL, rets); +} + +/* Register an instance method on receiver type `recv_` returning `ret_`. + * An entry whose formatted QN came back NULL is not registered: a NULL QN + * would reach strcmp in finalize and in every later lookup. */ +#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_)); \ + if (!rf.qualified_name) \ + break; \ + rf.short_name = (name_); \ + rf.receiver_type = (recv_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + 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); \ + * matches ruby_lookup_singleton_method's keying convention. Skipped when + * either formatted name came back NULL (see REG_METHOD). */ +#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.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \ + if (!rf.qualified_name || !rf.receiver_type) \ + break; \ + rf.short_name = (name_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + 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); \ +#define REG_BUILTIN(name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = (name_); \ + rf.short_name = (name_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ } while (0) void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena) { diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 4aa00b9f8..c5499f78e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -1498,6 +1498,32 @@ TEST(rubylsp_const_path_is_linear) { PASS(); } +/* Review ask (c): the stdlib seed must survive allocation failure. A + * zero-initialised arena has no blocks and no grow size, so every + * allocation from it returns NULL. The seed must not write through a NULL + * return-type vector, and must not register an entry whose formatted name + * came back NULL (a NULL QN would reach strcmp in finalize and in every later + * lookup). */ +TEST(rubylsp_stdlib_seed_survives_alloc_failure) { + CBMArena reg_arena; + cbm_arena_init(®_arena); + CBMTypeRegistry reg; + cbm_registry_init(®, ®_arena); + CBMArena dead; + memset(&dead, 0, sizeof(dead)); + cbm_ruby_stdlib_register(®, &dead); + ASSERT(reg.type_count > 0); + ASSERT(reg.func_count > 0); /* Kernel built-ins carry literal names */ + for (int i = 0; i < reg.func_count; i++) { + ASSERT(reg.funcs[i].qualified_name != NULL); + ASSERT(reg.funcs[i].short_name != NULL); + } + cbm_registry_finalize(®); + ASSERT(cbm_registry_lookup_func(®, "puts") != NULL); + cbm_arena_destroy(®_arena); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1536,4 +1562,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); RUN_TEST(rubylsp_const_path_is_linear); + RUN_TEST(rubylsp_stdlib_seed_survives_alloc_failure); }