11 Commits
Author SHA1 Message Date
Martin Vogel 435baf6bd8 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 <martin.vogel.tech@gmail.com>
2026-11-01 21:17:28 +01:00
Martin Vogel 214a9ac843 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 <martin.vogel.tech@gmail.com>
2026-11-01 21:17:28 +01:00
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
Ben Fairless 0f8b7194d8 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 <ben@oaf.org.au>
2026-09-30 10:29:10 +08:00
Ben Fairless 8bc99a4058 fix(ruby-lsp): do not register block-nested defs as class members
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 <ben@oaf.org.au>
2026-09-30 10:29:10 +08:00
Ben Fairless 60801bc53f fix(ruby-lsp): fail closed on unresolved ancestry
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 <ben@oaf.org.au>
2026-09-30 10:29:10 +08:00
Ben Fairless 50f28fbae5 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 <ben@oaf.org.au>
2026-09-30 10:29:09 +08:00
Ben Fairless ffeb06f595 fix(ruby-lsp): latch ivar type on any non-constructor write
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 <ben@oaf.org.au>
2026-09-30 10:29:09 +08:00
Ben Fairless c2d8644777 fix(ruby-lsp): make def a scope gate and fail closed on untyped reassignment
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 <ben@oaf.org.au>
2026-09-30 10:29:09 +08:00
Ben Fairless 9f204925dd fix(ruby-lsp): build compact class QNs the way the extractor does
The structural extractor names a class by appending the declaration's
raw name text to the enclosing class QN, so `class Admin::User` yields
`<module_qn>.Admin::User` while `module Admin; class User` yields
`<module_qn>.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 <ben@oaf.org.au>
2026-09-30 10:29:09 +08:00
Ben Fairless e9f87efe6a feat(lsp): add the Ruby per-file resolver
In-process, type-aware call resolution for single Ruby files, mirroring
the perl_lsp.c / php_lsp.c shape and dispatched from cbm_extract_file:

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

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

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

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

Refs #1701

Assisted-by: OpenCode:anthropic/claude-opus-5
Signed-off-by: Ben Fairless <ben@oaf.org.au>
2026-08-18 16:22:09 +08:00