Files
codebase-memory-mcp/internal/cbm/lsp/ruby_lsp.h
T
Martin Vogel 0210ea9794 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 <martin.vogel.tech@gmail.com>
2026-11-01 21:17:21 +01:00

177 lines
7.7 KiB
C

#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 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
* "<module_qn>.Admin::User" while the lexically nested form yields
* "<module_qn>.Admin.User". Emitted rows must carry the extractor spelling
* or the pipeline's exact-name join silently drops them. `superclass_qn` is
* resolved against the class table + stdlib after collection; NULL when
* unknown. */
typedef struct {
const char *path;
const char *qn;
const char *superclass_ref; /* raw dotted superclass spelling, or NULL */
const char *superclass_qn; /* resolved QN (table/stdlib), or NULL */
bool is_module; /* `module` (mixin source) vs `class` */
/* 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. */
typedef struct {
const char *owner_qn; /* class/module whose body held the call */
const char *module_ref; /* raw dotted module spelling */
const char *module_qn; /* resolved QN, or NULL */
RubyMixinKind kind;
} RubyMixinInfo;
/* One inferred instance-variable type: (class QN, @name) → type QN.
* `conflicted` latches when two assignments disagree — lookup then returns
* nothing (zero-edge guarantee beats a coin-flip type). */
typedef struct {
const char *class_qn;
const char *ivar_name; /* including the leading '@' */
const char *type_qn;
bool conflicted;
} RubyIvarInfo;
/* RubyLSPContext — per-file state for Ruby type-aware call resolution.
* Mirrors PerlLSPContext / PHPLSPContext structure.
*
* Ruby specifics that shape this context:
* - Classes/modules nest lexically (`module A; class B`), and constants
* resolve through the lexical nesting outward (A::B sees A's constants
* before top-level ones). `nesting` tracks the current dotted path.
* - Inheritance is `class C < Base`; mixins arrive via include/prepend/
* extend and participate in method lookup (see RubyMixinKind).
* - `Foo.new` constructs a Foo and dispatches to Foo#initialize.
* - `def m` defines an instance method; `def self.m` a singleton (class)
* method. Singleton methods are registered under the receiver key
* "<class_qn>.self" — constants cannot be lowercase, so the suffix can
* never collide with a real nested-constant path. */
typedef struct {
CBMArena *arena;
const char *source;
int source_len;
const CBMTypeRegistry *registry;
/* Mutable alias of `registry`, set only while PASS 1 populates method
* tables (mirrors perl_lsp's mutable-reg build phase). NULL afterwards. */
CBMTypeRegistry *build_reg;
CBMScope *current_scope;
/* Lexical nesting: 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). */
RubyClassInfo *classes;
int class_count;
int class_cap;
/* Mixin table (pass 1). */
RubyMixinInfo *mixins;
int mixin_count;
int mixin_cap;
/* Instance-variable type table (pass 1). */
RubyIvarInfo *ivars;
int ivar_count;
int ivar_cap;
/* Cross-file import map (require/require_relative resolved by the
* pipeline). Unused per-file: Ruby constants are file-global. */
const char **import_names;
const char **import_qns;
int import_count;
/* Current enclosing context during the resolution walk. */
const char *enclosing_class_qn; /* class QN, or NULL at top level */
const char *enclosing_func_qn; /* enclosing method QN, or NULL */
bool in_singleton_method; /* `def self.m` / class << self scope */
/* Inside a block that rebinds `self` (instance_eval/exec, class_eval/
* exec, module_eval/exec, define_method, Class.new/Module.new): the
* receiver of bare and `self.` calls is NOT the enclosing class, and
* the resolver cannot know what it is — so they emit nothing. */
bool self_unknown;
/* Number of enclosing blocks (`{ }`, `do end`, `-> { }`) since the
* nearest class/module body. A `def` met here is NOT a method of the
* enclosing class — ActiveSupport's `class_methods do … end`,
* `included do … end`, RSpec groups — so PASS 1 registers nothing for
* it and collects no mixins from it. */
int block_depth;
const char *module_qn;
/* Output: resolved calls accumulate here. */
CBMResolvedCallArray *resolved_calls;
/* Recursion guards. */
int eval_depth;
int walk_depth;
/* 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;
/* 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 */