mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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>
1825 lines
79 KiB
C
1825 lines
79 KiB
C
/*
|
|
* 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 "<class_qn>.self" — Ruby constants cannot be lowercase, so
|
|
* the suffix never collides with a real nested-constant path.
|
|
* 2. ruby_lsp_process_file does a multi-pass walk:
|
|
* PASS 1 — collect class/module declarations (with lexical nesting),
|
|
* superclasses, include/prepend/extend mixins, and method tables
|
|
* (instance vs singleton classified from the AST — `def m` vs
|
|
* `def self.m` / `class << self`). Defs and mixin calls inside a
|
|
* BLOCK (`class_methods do … end`, `included do … end`) belong to
|
|
* some other module and are not recorded.
|
|
* PASS 1.5 — infer instance-variable types from `@x = Const.new`
|
|
* (and `@x ||= Const.new`) inside method bodies. Any other write
|
|
* to the ivar latches it as untypable.
|
|
* PASS 2 — walk method bodies, track local variable types through
|
|
* assignments with Ruby's scope gates (fresh scope per def/class,
|
|
* chained scope per block, parameters unknown, untyped writes
|
|
* rebind to unknown), and resolve call expressions into
|
|
* CBMResolvedCall edges via Ruby method lookup (prepends → own →
|
|
* includes → superclass chain; extends feed the singleton side).
|
|
* The walk fails closed the moment an ancestor is unresolved.
|
|
*
|
|
* Verified tree-sitter-ruby node/field names (vendored compiled grammar at
|
|
* internal/cbm/vendored/grammars/ruby/parser.c — ts_symbol_names and
|
|
* ts_field_names tables):
|
|
* - call : fields `receiver`, `method`, `arguments`, `block`. Both
|
|
* `foo(x)` and command calls (`foo x`) surface as "call" nodes. A lone
|
|
* `foo` is an `identifier`, never a call — and the extractor emits no
|
|
* call for it either, so the resolver does not treat it as one.
|
|
* - class : fields `name` (constant | scope_resolution), `superclass`
|
|
* (a `superclass` wrapper node), `body` (body_statement).
|
|
* - module : fields `name`, `body`.
|
|
* - method : fields `name`, `parameters`, `body`.
|
|
* - singleton_method : fields `object` (self | constant), `name`, `body`.
|
|
* - singleton_class : `class << self` — fields `value`, body children.
|
|
* - assignment : fields `left`, `right` (`left` may be a
|
|
* left_assignment_list for `a, b = …`).
|
|
* - operator_assignment : fields `left`, `operator`, `right` (`||=` etc.).
|
|
* - block / do_block / lambda : field `parameters` (block_parameters /
|
|
* lambda_parameters), body.
|
|
* - method_parameters children: identifier | optional_parameter |
|
|
* keyword_parameter | splat_parameter | hash_splat_parameter |
|
|
* block_parameter (each with a `name` field) | destructured_parameter.
|
|
* - scope_resolution : fields `scope` (may be absent for ::Foo), `name`.
|
|
* - leaves: constant, identifier, self, instance_variable, super,
|
|
* string, array, hash, integer, float, simple_symbol, regex.
|
|
*
|
|
* QN scheme (matches the structural extractor): Ruby HAS class node types
|
|
* (class/module), so defs are named `<class_qn>.<method>` with
|
|
* parent_class = `<class_qn>`, where a class QN is the enclosing class QN
|
|
* (or the module QN at top level) joined with the declaration's RAW name
|
|
* text. A compact `class Admin::User` therefore yields `module_qn.Admin::User`
|
|
* while `module Admin; class User` yields `module_qn.Admin.User` — both are
|
|
* reproduced here byte-for-byte because the pipeline joins resolved rows to
|
|
* defs by exact caller name. The dotted form is used only for constant
|
|
* lookup. Constructor calls: the textual extractor rewrites `Widget.new` to
|
|
* callee "Widget" (extract_calls.c), so constructor rows emit the CLASS QN —
|
|
* the edge lands on the Class node, exactly like Python's lsp_constructor.
|
|
*
|
|
* Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge
|
|
* is emitted (false edges are worse than missing edges). Concretely:
|
|
* - dynamic dispatch (`send`, `public_send`, `__send__`, `method_missing`,
|
|
* `define_method`, the eval/exec family) is never guessed through;
|
|
* - a block that rebinds `self` (instance_eval/exec, class_eval/exec,
|
|
* module_eval/exec, define_method, Class.new/Module.new) suppresses bare,
|
|
* `self.` and `super` dispatch inside it;
|
|
* - an unresolved prepend blocks even an own-method hit, an unresolved
|
|
* include/extend/superclass stops the walk, and a blocked walk never
|
|
* falls through to a same-named top-level def or the Object fallback;
|
|
* - a local or ivar written with anything the resolver cannot type loses
|
|
* its type rather than keeping a stale one.
|
|
*/
|
|
|
|
#include "ruby_lsp.h"
|
|
#include "lsp_work.h"
|
|
#include "../helpers.h"
|
|
#include "../arena.h"
|
|
#include <ctype.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* Recursion cap for ruby_eval_expr_type — mirrors perl/php (unknown past 8). */
|
|
#define RUBY_EVAL_MAX_DEPTH 8
|
|
|
|
/* Confidence levels. */
|
|
#define RUBY_CONF_HIGH 0.95f /* constructor / static constant dispatch */
|
|
#define RUBY_CONF_TYPED 0.90f /* inferred receiver type (locals, ivars, chains) */
|
|
|
|
/* Maximum AST-walk recursion depth (see scope.h rationale — graceful
|
|
* degradation on pathologically nested sources, never a crash). */
|
|
#define CBM_LSP_RUBY_MAX_WALK_DEPTH 512
|
|
|
|
/* ── forward declarations ───────────────────────────────────────── */
|
|
|
|
static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node);
|
|
static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node);
|
|
static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node);
|
|
static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node);
|
|
static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node);
|
|
static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node);
|
|
static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit);
|
|
static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call);
|
|
|
|
/* ── helpers ────────────────────────────────────────────────────── */
|
|
|
|
static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) {
|
|
return cbm_node_text(ctx->arena, node, ctx->source);
|
|
}
|
|
|
|
/* 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. 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;
|
|
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 name = ts_node_child_by_field_name(node, "name", 4);
|
|
if (ts_node_is_null(name) || ruby_node_len(name) == 0)
|
|
return NULL;
|
|
|
|
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;
|
|
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. */
|
|
static const char *ruby_path_join(CBMArena *a, const char *left, const char *right) {
|
|
if (!left || !left[0])
|
|
return right;
|
|
if (!right || !right[0])
|
|
return left;
|
|
return cbm_arena_sprintf(a, "%s.%s", left, right);
|
|
}
|
|
|
|
/* Dotted form of a raw constant spelling: every "::" becomes ".". Used to
|
|
* turn nesting frames (raw) into class-table paths (dotted). */
|
|
static const char *ruby_dotted(CBMArena *a, const char *raw) {
|
|
if (!raw)
|
|
return NULL;
|
|
size_t n = strlen(raw);
|
|
char *out = (char *)cbm_arena_alloc(a, n + 1);
|
|
if (!out)
|
|
return raw;
|
|
size_t j = 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (raw[i] == ':' && i + 1 < n && raw[i + 1] == ':') {
|
|
out[j++] = '.';
|
|
i++;
|
|
continue;
|
|
}
|
|
out[j++] = raw[i];
|
|
}
|
|
out[j] = '\0';
|
|
return out;
|
|
}
|
|
|
|
/* Class-declaration frame shared by every pass, so PASS 1, PASS 1.5 and
|
|
* PASS 2 compute identical nesting + class QNs. `raw_name` is the name
|
|
* field's source text (the extractor's spelling, "Admin::User"); `qn` is
|
|
* the extractor-shaped class QN; `path` is the dotted constant-table path. */
|
|
typedef struct {
|
|
const char *raw_name;
|
|
const char *path;
|
|
const char *qn;
|
|
const char *saved_nesting;
|
|
const char *saved_class;
|
|
bool saved_singleton;
|
|
bool saved_self_unknown;
|
|
int saved_block_depth;
|
|
} RubyClassFrame;
|
|
|
|
/* Enter a class/module node: compute names, push nesting + enclosing class.
|
|
* Returns false (nothing pushed) when the name is not a constant path. */
|
|
static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *fr) {
|
|
TSNode name = ts_node_child_by_field_name(node, "name", 4);
|
|
const char *cpath = ruby_const_path(ctx, name);
|
|
if (!cpath)
|
|
return false;
|
|
memset(fr, 0, sizeof(*fr));
|
|
fr->raw_name = ruby_node_text(ctx, name);
|
|
if (!fr->raw_name || !fr->raw_name[0])
|
|
return false;
|
|
fr->saved_nesting = ctx->nesting;
|
|
fr->saved_class = ctx->enclosing_class_qn;
|
|
fr->saved_singleton = ctx->in_singleton_method;
|
|
fr->saved_self_unknown = ctx->self_unknown;
|
|
fr->saved_block_depth = ctx->block_depth;
|
|
|
|
fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath);
|
|
if (fr->saved_class)
|
|
fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", fr->saved_class, fr->raw_name);
|
|
else if (ctx->module_qn)
|
|
fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fr->raw_name);
|
|
else
|
|
fr->qn = fr->raw_name;
|
|
|
|
ctx->nesting = ruby_path_join(ctx->arena, fr->saved_nesting, fr->raw_name);
|
|
ctx->enclosing_class_qn = fr->qn;
|
|
ctx->in_singleton_method = false;
|
|
ctx->self_unknown = false; /* a class body names its self again */
|
|
ctx->block_depth = 0;
|
|
return true;
|
|
}
|
|
|
|
static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) {
|
|
ctx->nesting = fr->saved_nesting;
|
|
ctx->enclosing_class_qn = fr->saved_class;
|
|
ctx->in_singleton_method = fr->saved_singleton;
|
|
ctx->self_unknown = fr->saved_self_unknown;
|
|
ctx->block_depth = fr->saved_block_depth;
|
|
}
|
|
|
|
/* True when a QN belongs to the indexed project (module_qn's first segment
|
|
* is its prefix). Stdlib seed QNs ("String.upcase", "ActiveRecord.Base")
|
|
* never carry the project prefix, so edges are only ever emitted at
|
|
* project-defined targets. */
|
|
static bool ruby_is_project_qn(RubyLSPContext *ctx, const char *qn) {
|
|
if (!qn || !ctx->module_qn || !ctx->module_qn[0])
|
|
return false;
|
|
const char *dot = strchr(ctx->module_qn, '.');
|
|
size_t plen = dot ? (size_t)(dot - ctx->module_qn) : strlen(ctx->module_qn);
|
|
return strncmp(qn, ctx->module_qn, plen) == 0 && qn[plen] == '.';
|
|
}
|
|
|
|
/* ── table growth helpers ───────────────────────────────────────── */
|
|
|
|
static RubyClassInfo *ruby_add_class(RubyLSPContext *ctx, const char *path, const char *qn,
|
|
bool is_module) {
|
|
if (!ctx || !path || !qn)
|
|
return NULL;
|
|
/* Reopened class/module: reuse the existing record. Keyed on the
|
|
* extractor QN, so `module A; class B` and `class A::B` in one file each
|
|
* keep their own spelling (constant lookup returns whichever was
|
|
* declared first; a miss on the other fails closed at the join). */
|
|
for (int i = 0; i < ctx->class_count; i++) {
|
|
if (strcmp(ctx->classes[i].qn, qn) == 0)
|
|
return &ctx->classes[i];
|
|
}
|
|
if (ctx->class_count >= ctx->class_cap) {
|
|
int newcap = ctx->class_cap ? ctx->class_cap * 2 : 8;
|
|
RubyClassInfo *nc =
|
|
(RubyClassInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyClassInfo));
|
|
if (!nc)
|
|
return NULL;
|
|
for (int i = 0; i < ctx->class_count; i++)
|
|
nc[i] = ctx->classes[i];
|
|
ctx->classes = nc;
|
|
ctx->class_cap = newcap;
|
|
}
|
|
RubyClassInfo *ci = &ctx->classes[ctx->class_count++];
|
|
memset(ci, 0, sizeof(*ci));
|
|
ci->path = cbm_arena_strdup(ctx->arena, path);
|
|
ci->qn = cbm_arena_strdup(ctx->arena, qn);
|
|
ci->is_module = is_module;
|
|
return ci;
|
|
}
|
|
|
|
static void ruby_add_mixin(RubyLSPContext *ctx, const char *owner_qn, const char *module_ref,
|
|
RubyMixinKind kind) {
|
|
if (!ctx || !owner_qn || !module_ref)
|
|
return;
|
|
if (ctx->mixin_count >= ctx->mixin_cap) {
|
|
int newcap = ctx->mixin_cap ? ctx->mixin_cap * 2 : 8;
|
|
RubyMixinInfo *nm =
|
|
(RubyMixinInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyMixinInfo));
|
|
if (!nm)
|
|
return;
|
|
for (int i = 0; i < ctx->mixin_count; i++)
|
|
nm[i] = ctx->mixins[i];
|
|
ctx->mixins = nm;
|
|
ctx->mixin_cap = newcap;
|
|
}
|
|
RubyMixinInfo *mi = &ctx->mixins[ctx->mixin_count++];
|
|
memset(mi, 0, sizeof(*mi));
|
|
mi->owner_qn = cbm_arena_strdup(ctx->arena, owner_qn);
|
|
mi->module_ref = cbm_arena_strdup(ctx->arena, module_ref);
|
|
mi->kind = kind;
|
|
}
|
|
|
|
/* Find-or-create the (class, @ivar) record. `type_qn` may be NULL when the
|
|
* caller only wants to latch a conflict on a name never typed. */
|
|
static RubyIvarInfo *ruby_ivar_slot(RubyLSPContext *ctx, const char *class_qn,
|
|
const char *ivar_name) {
|
|
for (int i = 0; i < ctx->ivar_count; i++) {
|
|
RubyIvarInfo *iv = &ctx->ivars[i];
|
|
if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0)
|
|
return iv;
|
|
}
|
|
if (ctx->ivar_count >= ctx->ivar_cap) {
|
|
int newcap = ctx->ivar_cap ? ctx->ivar_cap * 2 : 8;
|
|
RubyIvarInfo *ni =
|
|
(RubyIvarInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyIvarInfo));
|
|
if (!ni)
|
|
return NULL;
|
|
for (int i = 0; i < ctx->ivar_count; i++)
|
|
ni[i] = ctx->ivars[i];
|
|
ctx->ivars = ni;
|
|
ctx->ivar_cap = newcap;
|
|
}
|
|
RubyIvarInfo *iv = &ctx->ivars[ctx->ivar_count++];
|
|
memset(iv, 0, sizeof(*iv));
|
|
iv->class_qn = cbm_arena_strdup(ctx->arena, class_qn);
|
|
iv->ivar_name = cbm_arena_strdup(ctx->arena, ivar_name);
|
|
return iv;
|
|
}
|
|
|
|
/* Record `@x = <type_qn>` (a resolved constructor). A second, different
|
|
* type latches the conflict. */
|
|
static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name,
|
|
const char *type_qn) {
|
|
if (!ctx || !class_qn || !ivar_name || !type_qn)
|
|
return;
|
|
RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name);
|
|
if (!iv)
|
|
return;
|
|
if (!iv->type_qn)
|
|
iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn);
|
|
else if (strcmp(iv->type_qn, type_qn) != 0)
|
|
iv->conflicted = true; /* disagreeing assignments — no type */
|
|
}
|
|
|
|
/* Latch `@x` as untypable: some write to it was not a resolved constructor
|
|
* (or `nil`). Lookup then returns nothing regardless of other assignments —
|
|
* the zero-edge guarantee beats a coin-flip type. */
|
|
static void ruby_ivar_mark_conflict(RubyLSPContext *ctx, const char *class_qn,
|
|
const char *ivar_name) {
|
|
if (!ctx || !class_qn || !ivar_name)
|
|
return;
|
|
RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name);
|
|
if (iv)
|
|
iv->conflicted = true;
|
|
}
|
|
|
|
static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn,
|
|
const char *ivar_name) {
|
|
if (!ctx || !class_qn || !ivar_name)
|
|
return NULL;
|
|
for (int i = 0; i < ctx->ivar_count; i++) {
|
|
RubyIvarInfo *iv = &ctx->ivars[i];
|
|
if (!iv->conflicted && iv->type_qn && strcmp(iv->class_qn, class_qn) == 0 &&
|
|
strcmp(iv->ivar_name, ivar_name) == 0)
|
|
return iv->type_qn;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
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 ────────────────────────────────────────── */
|
|
|
|
/* 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, innermost frame first — Ruby's constant lookup
|
|
* order. The nesting string holds one RAW frame per declaration joined
|
|
* by '.', so stripping at the last '.' drops exactly one frame: inside
|
|
* `module M; class A::B` (nesting "M.A::B") the probes are "M.A.B.path",
|
|
* "M.path", "path" — never "M.A.path", because `class A::B` does not
|
|
* open A's scope. */
|
|
const char *nest = ctx->nesting ? ctx->nesting : "";
|
|
char prefix[512];
|
|
size_t nlen = strlen(nest);
|
|
if (nlen < sizeof(prefix)) {
|
|
memcpy(prefix, nest, nlen + 1);
|
|
while (prefix[0]) {
|
|
const char *cand =
|
|
cbm_arena_sprintf(ctx->arena, "%s.%s", ruby_dotted(ctx->arena, prefix), path);
|
|
const char *qn = ruby_class_qn_by_path(ctx, cand);
|
|
if (qn)
|
|
return qn;
|
|
char *last_dot = strrchr(prefix, '.');
|
|
if (last_dot)
|
|
*last_dot = '\0';
|
|
else
|
|
prefix[0] = '\0';
|
|
}
|
|
}
|
|
const char *qn = ruby_class_qn_by_path(ctx, path);
|
|
if (qn)
|
|
return qn;
|
|
/* Stdlib types are keyed by their dotted bare path. */
|
|
const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, path);
|
|
if (t)
|
|
return t->qualified_name;
|
|
return NULL;
|
|
}
|
|
|
|
/* ── method lookup (Ruby MRO approximation) ─────────────────────── */
|
|
|
|
/* Depth cap shared by the instance/singleton lookup walks. */
|
|
#define RUBY_LOOKUP_MAX_DEPTH CBM_LSP_MAX_LOOKUP_DEPTH
|
|
|
|
/* Own 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 ("<qn>.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);
|
|
}
|
|
|
|
/* 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.
|
|
*
|
|
* 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, RubyLookupWalk *w);
|
|
|
|
/* 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. 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, 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) {
|
|
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, w);
|
|
if (f || w->blocked)
|
|
return f;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx,
|
|
const char *class_qn,
|
|
const char *method_name,
|
|
int depth, 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, 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, w);
|
|
if (f || w->blocked)
|
|
return f;
|
|
if (ruby_superclass_unresolved(ctx, class_qn)) {
|
|
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, w);
|
|
return NULL;
|
|
}
|
|
|
|
/* Instance lookup with the universal-receiver typing fallback. Skipped when
|
|
* the ancestry is blocked: nothing below the unseen ancestor is trustworthy. */
|
|
static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ctx,
|
|
const char *class_qn,
|
|
const char *method_name,
|
|
RubyLookupWalk *w) {
|
|
const CBMRegisteredFunc *f =
|
|
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. */
|
|
if (class_qn && strcmp(class_qn, "Object") != 0)
|
|
return cbm_registry_lookup_method(ctx->registry, "Object", method_name);
|
|
return NULL;
|
|
}
|
|
|
|
const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn,
|
|
const char *method_name) {
|
|
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, 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, w);
|
|
if (f || w->blocked)
|
|
return f;
|
|
if (ruby_superclass_unresolved(ctx, class_qn)) {
|
|
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, w);
|
|
return NULL;
|
|
}
|
|
|
|
const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn,
|
|
const char *method_name) {
|
|
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) ─────────── */
|
|
|
|
/* 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 && !ctx->self_unknown)
|
|
result = cbm_type_named(ctx->arena, ctx->enclosing_class_qn);
|
|
} else if (strcmp(k, "call") == 0) {
|
|
result = ruby_eval_call_type(ctx, node, false);
|
|
} else if (strcmp(k, "assignment") == 0) {
|
|
TSNode right = ts_node_child_by_field_name(node, "right", 5);
|
|
if (!ts_node_is_null(right))
|
|
result = ruby_eval_expr_type(ctx, right);
|
|
} else if (strcmp(k, "string") == 0 || strcmp(k, "string_content") == 0 ||
|
|
strcmp(k, "heredoc_body") == 0 || strcmp(k, "chained_string") == 0) {
|
|
result = cbm_type_named(ctx->arena, "String");
|
|
} else if (strcmp(k, "array") == 0 || strcmp(k, "string_array") == 0 ||
|
|
strcmp(k, "symbol_array") == 0) {
|
|
result = cbm_type_named(ctx->arena, "Array");
|
|
} else if (strcmp(k, "hash") == 0) {
|
|
result = cbm_type_named(ctx->arena, "Hash");
|
|
} else if (strcmp(k, "integer") == 0) {
|
|
result = cbm_type_named(ctx->arena, "Integer");
|
|
} else if (strcmp(k, "float") == 0) {
|
|
result = cbm_type_named(ctx->arena, "Float");
|
|
} else if (strcmp(k, "simple_symbol") == 0 || strcmp(k, "delimited_symbol") == 0) {
|
|
result = cbm_type_named(ctx->arena, "Symbol");
|
|
} else if (strcmp(k, "regex") == 0) {
|
|
result = cbm_type_named(ctx->arena, "Regexp");
|
|
} else if (strcmp(k, "parenthesized_statements") == 0 || strcmp(k, "begin") == 0) {
|
|
/* Type of the last meaningful child. */
|
|
uint32_t nc = ts_node_named_child_count(node);
|
|
for (uint32_t i = nc; i > 0; i--) {
|
|
TSNode c = ts_node_named_child(node, i - 1);
|
|
if (ts_node_is_null(c))
|
|
continue;
|
|
const char *ck = ts_node_type(c);
|
|
if (strcmp(ck, "comment") == 0)
|
|
continue;
|
|
result = ruby_eval_expr_type(ctx, c);
|
|
break;
|
|
}
|
|
}
|
|
|
|
ctx->eval_depth--;
|
|
return result;
|
|
}
|
|
|
|
/* ── call resolution core ───────────────────────────────────────── */
|
|
|
|
/* Dynamic-dispatch method names the resolver must never guess through. */
|
|
static bool ruby_is_dynamic_dispatch(const char *m) {
|
|
return strcmp(m, "send") == 0 || strcmp(m, "public_send") == 0 || strcmp(m, "__send__") == 0 ||
|
|
strcmp(m, "method_missing") == 0 || strcmp(m, "define_method") == 0 ||
|
|
strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 ||
|
|
strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 ||
|
|
strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 ||
|
|
strcmp(m, "instance_variable_get") == 0 || strcmp(m, "instance_variable_set") == 0;
|
|
}
|
|
|
|
/* True when `call` carries a block whose body runs with a DIFFERENT `self`
|
|
* than the enclosing lexical scope: the eval/exec family, define_method
|
|
* (self is the future receiver), and `Class.new { }` / `Module.new { }`
|
|
* (self is the anonymous class). Bare and `self.` calls in such a block
|
|
* must not dispatch on the enclosing class. */
|
|
static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call) {
|
|
if (ts_node_is_null(ts_node_child_by_field_name(call, "block", 5)))
|
|
return false;
|
|
TSNode meth = ts_node_child_by_field_name(call, "method", 6);
|
|
if (ts_node_is_null(meth))
|
|
return false;
|
|
char *m = ruby_node_text(ctx, meth);
|
|
if (!m)
|
|
return false;
|
|
if (strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 ||
|
|
strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 ||
|
|
strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 ||
|
|
strcmp(m, "define_method") == 0)
|
|
return true;
|
|
if (strcmp(m, "new") == 0) {
|
|
TSNode recv = ts_node_child_by_field_name(call, "receiver", 8);
|
|
if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "constant") == 0) {
|
|
char *r = ruby_node_text(ctx, recv);
|
|
if (r && (strcmp(r, "Class") == 0 || strcmp(r, "Module") == 0))
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* Class-body macro-ish names PASS 1 already interpreted (or that never
|
|
* denote resolvable user calls). */
|
|
static bool ruby_is_body_macro(const char *m) {
|
|
return strcmp(m, "include") == 0 || strcmp(m, "extend") == 0 || strcmp(m, "prepend") == 0 ||
|
|
strcmp(m, "require") == 0 || strcmp(m, "require_relative") == 0 ||
|
|
strcmp(m, "attr_accessor") == 0 || strcmp(m, "attr_reader") == 0 ||
|
|
strcmp(m, "attr_writer") == 0 || strcmp(m, "private") == 0 || strcmp(m, "public") == 0 ||
|
|
strcmp(m, "protected") == 0 || strcmp(m, "module_function") == 0 ||
|
|
strcmp(m, "alias_method") == 0 || strcmp(m, "loop") == 0 || strcmp(m, "lambda") == 0 ||
|
|
strcmp(m, "proc") == 0;
|
|
}
|
|
|
|
/* Resolve a singleton-side dispatch on a resolved class QN. Emits (when
|
|
* `emit`) and returns the call's type. Also owns the ActiveRecord typing
|
|
* special case (User.find -> User). */
|
|
static const CBMType *ruby_dispatch_singleton(RubyLSPContext *ctx, const char *class_qn,
|
|
const char *mname, bool emit, TSNode site) {
|
|
const CBMRegisteredFunc *f = ruby_lookup_singleton_method(ctx, class_qn, mname);
|
|
if (f) {
|
|
if (emit)
|
|
ruby_emit_resolved(ctx, f->qualified_name, "ruby_class_method", RUBY_CONF_HIGH, site);
|
|
const CBMType *rt = ruby_func_return_type(f);
|
|
if (!cbm_type_is_unknown(rt))
|
|
return rt;
|
|
/* Stdlib AR query entries return unknown; substitute the model. */
|
|
if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname))
|
|
return cbm_type_named(ctx->arena, class_qn);
|
|
return rt;
|
|
}
|
|
if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname))
|
|
return cbm_type_named(ctx->arena, class_qn);
|
|
return cbm_type_unknown();
|
|
}
|
|
|
|
/* Resolve an instance-side dispatch on a receiver type QN. */
|
|
static const CBMType *ruby_dispatch_instance(RubyLSPContext *ctx, const char *class_qn,
|
|
const char *mname, const char *strategy, bool emit,
|
|
TSNode site) {
|
|
const CBMRegisteredFunc *f = ruby_lookup_instance_method(ctx, class_qn, mname);
|
|
if (!f)
|
|
return cbm_type_unknown();
|
|
if (emit)
|
|
ruby_emit_resolved(ctx, f->qualified_name, strategy, RUBY_CONF_TYPED, site);
|
|
return ruby_func_return_type(f);
|
|
}
|
|
|
|
/* Resolve one `call` node. When `emit` is set, resolved project targets are
|
|
* appended as CBMResolvedCall edges; the call's result type is returned
|
|
* either way (so chains and assignments can type through it). */
|
|
static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit) {
|
|
TSNode meth = ts_node_child_by_field_name(call, "method", 6);
|
|
TSNode recv = ts_node_child_by_field_name(call, "receiver", 8);
|
|
if (ts_node_is_null(meth))
|
|
return cbm_type_unknown();
|
|
|
|
/* `super(...)` is dispatched by the walker (ruby_resolve_super); as an
|
|
* expression its type is not modeled. */
|
|
if (strcmp(ts_node_type(meth), "super") == 0)
|
|
return cbm_type_unknown();
|
|
|
|
char *mname = ruby_node_text(ctx, meth);
|
|
if (!mname || !mname[0])
|
|
return cbm_type_unknown();
|
|
if (ruby_is_dynamic_dispatch(mname))
|
|
return cbm_type_unknown(); /* zero-edge guarantee */
|
|
|
|
/* Constructor: Const.new / A::B.new — the textual extractor rewrites
|
|
* the callee to the constant, so the row targets the CLASS QN. */
|
|
if (strcmp(mname, "new") == 0 && !ts_node_is_null(recv)) {
|
|
const char *cpath = ruby_const_path(ctx, recv);
|
|
if (cpath) {
|
|
const char *cqn = ruby_resolve_constant(ctx, cpath);
|
|
if (cqn) {
|
|
if (emit)
|
|
ruby_emit_resolved(ctx, cqn, "ruby_constructor", RUBY_CONF_HIGH, call);
|
|
return cbm_type_named(ctx->arena, cqn);
|
|
}
|
|
return cbm_type_unknown();
|
|
}
|
|
/* Fall through: `expr.new` on a non-constant receiver. */
|
|
}
|
|
|
|
if (ts_node_is_null(recv)) {
|
|
/* Bare call: self-dispatch inside methods, then file-level funcs. */
|
|
if (ruby_is_body_macro(mname))
|
|
return cbm_type_unknown();
|
|
if (ctx->self_unknown)
|
|
return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */
|
|
if (ctx->enclosing_class_qn) {
|
|
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,
|
|
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 (w.blocked)
|
|
return cbm_type_unknown();
|
|
}
|
|
const CBMRegisteredFunc *f =
|
|
cbm_registry_lookup_symbol(ctx->registry, ctx->module_qn, mname);
|
|
if (!f)
|
|
f = cbm_registry_lookup_func(ctx->registry, mname); /* Kernel builtins */
|
|
if (f) {
|
|
if (emit)
|
|
ruby_emit_resolved(ctx, f->qualified_name, "ruby_function_local", RUBY_CONF_HIGH,
|
|
call);
|
|
return ruby_func_return_type(f);
|
|
}
|
|
return cbm_type_unknown();
|
|
}
|
|
|
|
const char *rk = ts_node_type(recv);
|
|
|
|
/* self.m — explicit self dispatch. */
|
|
if (strcmp(rk, "self") == 0) {
|
|
if (!ctx->enclosing_class_qn || ctx->self_unknown)
|
|
return cbm_type_unknown();
|
|
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,
|
|
call);
|
|
return ruby_func_return_type(f);
|
|
}
|
|
return cbm_type_unknown();
|
|
}
|
|
|
|
/* Const.m / A::B.m — singleton dispatch on the resolved class. */
|
|
if (strcmp(rk, "constant") == 0 || strcmp(rk, "scope_resolution") == 0) {
|
|
const char *cpath = ruby_const_path(ctx, recv);
|
|
const char *cqn = cpath ? ruby_resolve_constant(ctx, cpath) : NULL;
|
|
if (!cqn)
|
|
return cbm_type_unknown(); /* unknown constant — zero-edge */
|
|
return ruby_dispatch_singleton(ctx, cqn, mname, emit, call);
|
|
}
|
|
|
|
/* Typed receiver: locals, ivars, chained calls, literals. */
|
|
const CBMType *rt = ruby_eval_expr_type(ctx, recv);
|
|
if (!rt || rt->kind != CBM_TYPE_NAMED)
|
|
return cbm_type_unknown(); /* unknown receiver — zero-edge */
|
|
const char *strategy = "ruby_method_typed";
|
|
if (strcmp(rk, "instance_variable") == 0)
|
|
strategy = "ruby_ivar_method";
|
|
else if (strcmp(rk, "call") == 0)
|
|
strategy = "ruby_method_chained";
|
|
const CBMType *out =
|
|
ruby_dispatch_instance(ctx, rt->data.named.qualified_name, mname, strategy, emit, call);
|
|
if (!cbm_type_is_unknown(out))
|
|
return out;
|
|
/* AR relation approximation: chains on a model type keep the model. */
|
|
if (ruby_is_ar_model(ctx, rt->data.named.qualified_name, 0) && ruby_ar_returns_model(mname))
|
|
return rt;
|
|
return out;
|
|
}
|
|
|
|
/* `super` / `super(...)`: resolve the enclosing method's name starting at
|
|
* the enclosing class's ancestry (own def skipped — that IS the caller). */
|
|
static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) {
|
|
if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn || ctx->self_unknown)
|
|
return;
|
|
const char *fq = ctx->enclosing_func_qn;
|
|
const char *mname = strrchr(fq, '.');
|
|
mname = mname ? mname + 1 : fq;
|
|
const CBMRegisteredFunc *f = NULL;
|
|
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, &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, &w)
|
|
: ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &w);
|
|
}
|
|
}
|
|
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. */
|
|
}
|
|
|
|
/* ── local scope (Ruby scope gates) ─────────────────────────────── */
|
|
|
|
/* Ruby scoping, as modeled here:
|
|
* - `def`, `class` and `module` bodies are SCOPE GATES: no outer local is
|
|
* visible inside, so each gets a fresh, unparented scope.
|
|
* - Blocks (`{ }`, `do end`, `-> { }`) close over the enclosing locals, so
|
|
* they get a CHAINED child scope. Block parameters shadow outer names
|
|
* for the block's extent only.
|
|
* - Assigning to a name that is already bound in an enclosing frame
|
|
* rebinds THAT frame (closure assignment), otherwise binds locally.
|
|
* - Parameters and any reassignment whose right-hand side has no proven
|
|
* type bind the name to UNKNOWN. A stale type must never survive a
|
|
* write the resolver could not read (zero-edge guarantee). */
|
|
|
|
/* Assign `type` to `name`: rebind the nearest existing binding in place, or
|
|
* bind in the current frame when the name is new. */
|
|
static void ruby_scope_assign(RubyLSPContext *ctx, const char *name, const CBMType *type) {
|
|
if (!ctx->current_scope || !name || !name[0])
|
|
return;
|
|
for (CBMScope *s = ctx->current_scope; s != NULL; s = s->parent) {
|
|
for (CBMScopeChunk *c = s->chunks; c != NULL; c = c->next) {
|
|
for (int i = 0; i < c->used; i++) {
|
|
if (c->bindings[i].name && strcmp(c->bindings[i].name, name) == 0) {
|
|
c->bindings[i].type = type;
|
|
c->bindings[i].callable_qn = NULL;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
cbm_scope_bind(ctx->current_scope, name, type);
|
|
}
|
|
|
|
/* Bind every name introduced by a parameter list (method, block or lambda
|
|
* parameters, including destructured groups and block-local `; x` names) to
|
|
* UNKNOWN in the current frame. Parameters arrive untyped, and a parameter
|
|
* that shares its name with an outer local must shadow it. */
|
|
static void ruby_bind_params_unknown(RubyLSPContext *ctx, TSNode params) {
|
|
if (ts_node_is_null(params))
|
|
return;
|
|
const char *k = ts_node_type(params);
|
|
if (strcmp(k, "identifier") == 0) {
|
|
char *nm = ruby_node_text(ctx, params);
|
|
if (nm && nm[0])
|
|
cbm_scope_bind(ctx->current_scope, nm, cbm_type_unknown());
|
|
return;
|
|
}
|
|
if (strcmp(k, "optional_parameter") == 0 || strcmp(k, "keyword_parameter") == 0 ||
|
|
strcmp(k, "splat_parameter") == 0 || strcmp(k, "hash_splat_parameter") == 0 ||
|
|
strcmp(k, "block_parameter") == 0) {
|
|
ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(params, "name", 4));
|
|
return;
|
|
}
|
|
if (strcmp(k, "method_parameters") == 0 || strcmp(k, "block_parameters") == 0 ||
|
|
strcmp(k, "lambda_parameters") == 0 || strcmp(k, "destructured_parameter") == 0) {
|
|
uint32_t nc = ts_node_named_child_count(params);
|
|
for (uint32_t i = 0; i < nc; i++)
|
|
ruby_bind_params_unknown(ctx, ts_node_named_child(params, i));
|
|
}
|
|
}
|
|
|
|
/* Rebind every local named on the left of a multiple assignment
|
|
* (`a, b = ...`, `a, (b, c) = ...`, `a, *rest = ...`) to UNKNOWN. */
|
|
static void ruby_unbind_assignment_list(RubyLSPContext *ctx, TSNode lhs) {
|
|
if (ts_node_is_null(lhs))
|
|
return;
|
|
const char *k = ts_node_type(lhs);
|
|
if (strcmp(k, "identifier") == 0) {
|
|
char *nm = ruby_node_text(ctx, lhs);
|
|
if (nm && nm[0])
|
|
ruby_scope_assign(ctx, nm, cbm_type_unknown());
|
|
return;
|
|
}
|
|
if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 ||
|
|
strcmp(k, "rest_assignment") == 0) {
|
|
uint32_t nc = ts_node_named_child_count(lhs);
|
|
for (uint32_t i = 0; i < nc; i++)
|
|
ruby_unbind_assignment_list(ctx, ts_node_named_child(lhs, i));
|
|
}
|
|
}
|
|
|
|
/* ── assignment observer (scope binding) ────────────────────────── */
|
|
|
|
static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) {
|
|
TSNode left = ts_node_child_by_field_name(assign, "left", 4);
|
|
TSNode right = ts_node_child_by_field_name(assign, "right", 5);
|
|
if (ts_node_is_null(left) || ts_node_is_null(right))
|
|
return;
|
|
const char *lk = ts_node_type(left);
|
|
if (strcmp(lk, "left_assignment_list") == 0) {
|
|
ruby_unbind_assignment_list(ctx, left); /* no per-element typing */
|
|
return;
|
|
}
|
|
if (strcmp(lk, "identifier") != 0)
|
|
return; /* only simple local targets are tracked */
|
|
char *vname = ruby_node_text(ctx, left);
|
|
if (!vname || !vname[0])
|
|
return;
|
|
const CBMType *rt = ruby_eval_expr_type(ctx, right);
|
|
/* An untyped right-hand side REBINDS to unknown — `a = Foo.new; a = x`
|
|
* must not leave `a` typed Foo. */
|
|
ruby_scope_assign(ctx, vname, (rt && rt->kind == CBM_TYPE_NAMED) ? rt : cbm_type_unknown());
|
|
}
|
|
|
|
/* `a ||= expr` and the other compound assignments. `||=` on a name with no
|
|
* binding yet takes the right-hand type; on a name already bound to the same
|
|
* type it keeps it; every other case (untyped right-hand side, different
|
|
* type, or any non-`||=` operator) fails closed to UNKNOWN. */
|
|
static void ruby_process_operator_assignment(RubyLSPContext *ctx, TSNode assign) {
|
|
TSNode left = ts_node_child_by_field_name(assign, "left", 4);
|
|
TSNode op = ts_node_child_by_field_name(assign, "operator", 8);
|
|
TSNode right = ts_node_child_by_field_name(assign, "right", 5);
|
|
if (ts_node_is_null(left) || ts_node_is_null(right))
|
|
return;
|
|
if (strcmp(ts_node_type(left), "identifier") != 0)
|
|
return;
|
|
char *vname = ruby_node_text(ctx, left);
|
|
if (!vname || !vname[0])
|
|
return;
|
|
char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op);
|
|
const CBMType *result = cbm_type_unknown();
|
|
if (optxt && strcmp(optxt, "||=") == 0) {
|
|
const CBMType *rt = ruby_eval_expr_type(ctx, right);
|
|
if (rt && rt->kind == CBM_TYPE_NAMED) {
|
|
const CBMVarBinding *cur = cbm_scope_lookup_binding(ctx->current_scope, vname);
|
|
if (!cur)
|
|
result = rt; /* nil || Foo.new → Foo */
|
|
else if (cur->type && cur->type->kind == CBM_TYPE_NAMED &&
|
|
strcmp(cur->type->data.named.qualified_name, rt->data.named.qualified_name) ==
|
|
0)
|
|
result = rt;
|
|
}
|
|
}
|
|
ruby_scope_assign(ctx, vname, result);
|
|
}
|
|
|
|
/* ── PASS 2: resolution walk ────────────────────────────────────── */
|
|
|
|
/* Process one method/singleton_method definition node. `def` is a scope
|
|
* gate: the body starts from a FRESH scope holding only its parameters. */
|
|
static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton) {
|
|
CBMScope *saved_scope = ctx->current_scope;
|
|
const char *saved_func = ctx->enclosing_func_qn;
|
|
bool saved_singleton = ctx->in_singleton_method;
|
|
|
|
ctx->current_scope = cbm_scope_push(ctx->arena, NULL);
|
|
ctx->in_singleton_method = singleton;
|
|
ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10));
|
|
|
|
TSNode name = ts_node_child_by_field_name(node, "name", 4);
|
|
char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name);
|
|
if (mname && mname[0]) {
|
|
const char *owner = ctx->enclosing_class_qn ? ctx->enclosing_class_qn : ctx->module_qn;
|
|
if (owner)
|
|
ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname);
|
|
else
|
|
ctx->enclosing_func_qn = cbm_arena_strdup(ctx->arena, mname);
|
|
}
|
|
|
|
TSNode body = ts_node_child_by_field_name(node, "body", 4);
|
|
if (!ts_node_is_null(body))
|
|
ruby_resolve_calls_in_node(ctx, body);
|
|
|
|
ctx->current_scope = saved_scope;
|
|
ctx->enclosing_func_qn = saved_func;
|
|
ctx->in_singleton_method = saved_singleton;
|
|
}
|
|
|
|
/* Process a block / do-block / lambda: a CHAINED scope (closures see the
|
|
* enclosing locals) in which the block parameters shadow as UNKNOWN. */
|
|
static void ruby_process_block(RubyLSPContext *ctx, TSNode node) {
|
|
CBMScope *saved_scope = ctx->current_scope;
|
|
ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope);
|
|
ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10));
|
|
uint32_t nc = ts_node_child_count(node);
|
|
for (uint32_t i = 0; i < nc; i++) {
|
|
TSNode c = ts_node_child(node, i);
|
|
if (ts_node_is_null(c))
|
|
continue;
|
|
/* Parameters were consumed above; walking them would be harmless
|
|
* (identifiers are not calls) but default values may hold calls. */
|
|
ruby_resolve_calls_in_node(ctx, c);
|
|
}
|
|
ctx->current_scope = saved_scope;
|
|
}
|
|
|
|
/* Process a class/module node during PASS 2: update nesting + enclosing
|
|
* class, then walk the body from a fresh scope (class bodies are scope
|
|
* gates too). */
|
|
static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) {
|
|
RubyClassFrame fr;
|
|
if (!ruby_class_enter(ctx, node, &fr))
|
|
return;
|
|
CBMScope *saved_scope = ctx->current_scope;
|
|
ctx->current_scope = cbm_scope_push(ctx->arena, NULL);
|
|
|
|
TSNode body = ts_node_child_by_field_name(node, "body", 4);
|
|
if (!ts_node_is_null(body))
|
|
ruby_resolve_calls_in_node(ctx, body);
|
|
|
|
ctx->current_scope = saved_scope;
|
|
ruby_class_leave(ctx, &fr);
|
|
}
|
|
|
|
static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node) {
|
|
if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH)
|
|
return;
|
|
ctx->walk_depth++;
|
|
ruby_resolve_calls_in_node_inner(ctx, node);
|
|
ctx->walk_depth--;
|
|
}
|
|
|
|
static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) {
|
|
if (ts_node_is_null(node))
|
|
return;
|
|
const char *k = ts_node_type(node);
|
|
|
|
if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) {
|
|
ruby_process_class_body(ctx, node);
|
|
return;
|
|
}
|
|
if (strcmp(k, "singleton_class") == 0) {
|
|
/* `class << self` — methods inside are singleton methods. */
|
|
bool saved = ctx->in_singleton_method;
|
|
ctx->in_singleton_method = true;
|
|
uint32_t nc = ts_node_named_child_count(node);
|
|
for (uint32_t i = 0; i < nc; i++)
|
|
ruby_resolve_calls_in_node(ctx, ts_node_named_child(node, i));
|
|
ctx->in_singleton_method = saved;
|
|
return;
|
|
}
|
|
if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) {
|
|
bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0;
|
|
if (ctx->block_depth > 0) {
|
|
/* A def inside a block. Inside a class body the extractor emits
|
|
* no def for it, so no row could ever join: skip it. At top
|
|
* level the extractor emits a plain Function, so typed-receiver
|
|
* calls in its body may resolve — but its `self` is whatever
|
|
* the block's DSL made it, so bare and self. calls emit nothing. */
|
|
if (ctx->enclosing_class_qn)
|
|
return;
|
|
bool saved = ctx->self_unknown;
|
|
ctx->self_unknown = true;
|
|
ruby_process_method(ctx, node, singleton);
|
|
ctx->self_unknown = saved;
|
|
return;
|
|
}
|
|
ruby_process_method(ctx, node, singleton);
|
|
return;
|
|
}
|
|
if (strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0) {
|
|
ctx->block_depth++;
|
|
ruby_process_block(ctx, node);
|
|
ctx->block_depth--;
|
|
return;
|
|
}
|
|
|
|
if (strcmp(k, "assignment") == 0)
|
|
ruby_process_assignment(ctx, node);
|
|
else if (strcmp(k, "operator_assignment") == 0)
|
|
ruby_process_operator_assignment(ctx, node);
|
|
|
|
if (strcmp(k, "call") == 0) {
|
|
TSNode meth = ts_node_child_by_field_name(node, "method", 6);
|
|
if (!ts_node_is_null(meth) && strcmp(ts_node_type(meth), "super") == 0)
|
|
ruby_resolve_super(ctx, node);
|
|
else
|
|
ruby_eval_call_type(ctx, node, true);
|
|
if (ruby_call_rebinds_self(ctx, node)) {
|
|
/* Receiver and arguments are ordinary expressions; the block
|
|
* body runs with a `self` this resolver cannot name. */
|
|
TSNode blk = ts_node_child_by_field_name(node, "block", 5);
|
|
uint32_t cc = ts_node_child_count(node);
|
|
for (uint32_t i = 0; i < cc; i++) {
|
|
TSNode c = ts_node_child(node, i);
|
|
if (ts_node_is_null(c))
|
|
continue;
|
|
if (ts_node_eq(c, blk)) {
|
|
bool saved = ctx->self_unknown;
|
|
ctx->self_unknown = true;
|
|
ruby_resolve_calls_in_node(ctx, c);
|
|
ctx->self_unknown = saved;
|
|
} else {
|
|
ruby_resolve_calls_in_node(ctx, c);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
/* Recurse into receiver/arguments/blocks for nested calls. */
|
|
} else if (strcmp(k, "super") == 0) {
|
|
/* Bare `super` (no argument list) appears as a standalone node. */
|
|
ruby_resolve_super(ctx, node);
|
|
return;
|
|
}
|
|
|
|
uint32_t nc = ts_node_child_count(node);
|
|
for (uint32_t i = 0; i < nc; i++) {
|
|
TSNode c = ts_node_child(node, i);
|
|
if (!ts_node_is_null(c))
|
|
ruby_resolve_calls_in_node(ctx, c);
|
|
}
|
|
}
|
|
|
|
/* ── PASS 1: class/mixin/method collection ──────────────────────── */
|
|
|
|
/* Register one method into the registry with the proper receiver key. */
|
|
static void ruby_register_method(RubyLSPContext *ctx, CBMTypeRegistry *reg, const char *owner_qn,
|
|
const char *mname, bool singleton) {
|
|
if (!mname || !mname[0])
|
|
return;
|
|
CBMRegisteredFunc rf;
|
|
memset(&rf, 0, sizeof(rf));
|
|
rf.min_params = -1;
|
|
const char *owner = owner_qn ? owner_qn : ctx->module_qn;
|
|
rf.qualified_name = owner ? cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname)
|
|
: cbm_arena_strdup(ctx->arena, mname);
|
|
rf.short_name = cbm_arena_strdup(ctx->arena, mname);
|
|
if (owner_qn) {
|
|
rf.receiver_type = singleton ? cbm_arena_sprintf(ctx->arena, "%s.self", owner_qn)
|
|
: cbm_arena_strdup(ctx->arena, owner_qn);
|
|
}
|
|
const CBMType **rets = (const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets));
|
|
if (rets) {
|
|
rets[0] = cbm_type_unknown();
|
|
rets[1] = NULL;
|
|
}
|
|
rf.signature = cbm_type_func(ctx->arena, NULL, NULL, rets);
|
|
cbm_registry_add_func(reg, rf);
|
|
}
|
|
|
|
/* PASS-1 refs are nesting-scoped: the finalize step resolves superclass and
|
|
* mixin references once the class table is complete, so PASS 1 records raw
|
|
* dotted refs plus the nesting string active at the declaration site
|
|
* (packed as "<nesting>|<ref>" 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 "<nesting>|<ref>" (or bare "<ref>") 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 "<nesting>|<ref>" string. */
|
|
static const char *ruby_packed_bare_ref(const char *packed) {
|
|
if (!packed)
|
|
return NULL;
|
|
const char *bar = strchr(packed, '|');
|
|
return bar ? bar + 1 : packed;
|
|
}
|
|
|
|
/* Interpret a class-body `include X` / `prepend X` / `extend X` call,
|
|
* recording the mixin with the nesting packed for finalize-time resolve. */
|
|
static void ruby_collect_mixin_call(RubyLSPContext *ctx, TSNode call) {
|
|
if (!ctx->enclosing_class_qn)
|
|
return;
|
|
TSNode meth = ts_node_child_by_field_name(call, "method", 6);
|
|
TSNode recv = ts_node_child_by_field_name(call, "receiver", 8);
|
|
if (ts_node_is_null(meth))
|
|
return;
|
|
if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "self") != 0)
|
|
return; /* not a bare/self class-body macro */
|
|
char *mname = ruby_node_text(ctx, meth);
|
|
if (!mname)
|
|
return;
|
|
RubyMixinKind kind;
|
|
if (strcmp(mname, "include") == 0)
|
|
kind = RUBY_MIXIN_INCLUDE;
|
|
else if (strcmp(mname, "prepend") == 0)
|
|
kind = RUBY_MIXIN_PREPEND;
|
|
else if (strcmp(mname, "extend") == 0)
|
|
kind = RUBY_MIXIN_EXTEND;
|
|
else
|
|
return;
|
|
TSNode args = ts_node_child_by_field_name(call, "arguments", 9);
|
|
if (ts_node_is_null(args))
|
|
return;
|
|
uint32_t nc = ts_node_named_child_count(args);
|
|
for (uint32_t i = 0; i < nc; i++) {
|
|
const char *mpath = ruby_const_path(ctx, ts_node_named_child(args, i));
|
|
if (!mpath)
|
|
continue; /* `extend self` etc. — not a constant */
|
|
ruby_add_mixin(ctx, ctx->enclosing_class_qn, ruby_pack_ref(ctx, mpath), kind);
|
|
}
|
|
}
|
|
|
|
static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) {
|
|
RubyClassFrame fr;
|
|
if (!ruby_class_enter(ctx, node, &fr))
|
|
return;
|
|
RubyClassInfo *ci = ruby_add_class(ctx, fr.path, fr.qn, is_module);
|
|
|
|
/* Superclass: `class C < Base` — the `superclass` field wraps the
|
|
* expression after `<`. The ref is resolved from the DECLARING scope
|
|
* (the frame just pushed does not apply to its own superclass), so pack
|
|
* it with the outer nesting. */
|
|
if (ci && !is_module && !ci->superclass_ref) {
|
|
TSNode sup = ts_node_child_by_field_name(node, "superclass", 10);
|
|
if (!ts_node_is_null(sup)) {
|
|
uint32_t sc = ts_node_named_child_count(sup);
|
|
for (uint32_t i = 0; i < sc; i++) {
|
|
const char *spath = ruby_const_path(ctx, ts_node_named_child(sup, i));
|
|
if (spath) {
|
|
const char *inner = ctx->nesting;
|
|
ctx->nesting = fr.saved_nesting;
|
|
ci->superclass_ref = ruby_pack_ref(ctx, spath);
|
|
ctx->nesting = inner;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TSNode body = ts_node_child_by_field_name(node, "body", 4);
|
|
if (!ts_node_is_null(body))
|
|
ruby_pass1_scan(ctx, body);
|
|
ruby_class_leave(ctx, &fr);
|
|
}
|
|
|
|
static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node) {
|
|
if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH)
|
|
return;
|
|
ctx->walk_depth++;
|
|
ruby_pass1_scan_inner(ctx, node);
|
|
ctx->walk_depth--;
|
|
}
|
|
|
|
/* Registry under construction during PASS 1 (methods are registered as
|
|
* they are discovered). Held in a file-scope slot only for the duration of
|
|
* cbm_run_ruby_lsp's pass-1 invocation — single-threaded per file. */
|
|
static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) {
|
|
if (ts_node_is_null(node))
|
|
return;
|
|
const char *k = ts_node_type(node);
|
|
|
|
if (strcmp(k, "class") == 0) {
|
|
ruby_pass1_class(ctx, node, false);
|
|
return;
|
|
}
|
|
if (strcmp(k, "module") == 0) {
|
|
ruby_pass1_class(ctx, node, true);
|
|
return;
|
|
}
|
|
if (strcmp(k, "singleton_class") == 0) {
|
|
bool saved = ctx->in_singleton_method;
|
|
ctx->in_singleton_method = true;
|
|
uint32_t nc = ts_node_named_child_count(node);
|
|
for (uint32_t i = 0; i < nc; i++)
|
|
ruby_pass1_scan(ctx, ts_node_named_child(node, i));
|
|
ctx->in_singleton_method = saved;
|
|
return;
|
|
}
|
|
if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) {
|
|
/* A def inside a block (`class_methods do … end`, `included do …
|
|
* end`, a describe group) is not a method of the enclosing
|
|
* class/module — and not a file-level function either. */
|
|
if (ctx->block_depth > 0)
|
|
return;
|
|
bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0;
|
|
TSNode name = ts_node_child_by_field_name(node, "name", 4);
|
|
char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name);
|
|
if (ctx->build_reg)
|
|
ruby_register_method(ctx, ctx->build_reg, ctx->enclosing_class_qn, mname, singleton);
|
|
return; /* method bodies are PASS-2 territory */
|
|
}
|
|
if (strcmp(k, "call") == 0 && ctx->block_depth == 0) {
|
|
/* `include X` inside a block (`included do include X end`,
|
|
* `Foo.class_eval { include X }`) applies to some other module. */
|
|
TSNode meth = ts_node_child_by_field_name(node, "method", 6);
|
|
if (!ts_node_is_null(meth)) {
|
|
char *mn = ruby_node_text(ctx, meth);
|
|
if (mn && (strcmp(mn, "include") == 0 || strcmp(mn, "prepend") == 0 ||
|
|
strcmp(mn, "extend") == 0)) {
|
|
ruby_collect_mixin_call(ctx, node);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool is_block =
|
|
strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0;
|
|
if (is_block)
|
|
ctx->block_depth++;
|
|
uint32_t nc = ts_node_child_count(node);
|
|
for (uint32_t i = 0; i < nc; i++) {
|
|
TSNode c = ts_node_child(node, i);
|
|
if (!ts_node_is_null(c))
|
|
ruby_pass1_scan(ctx, c);
|
|
}
|
|
if (is_block)
|
|
ctx->block_depth--;
|
|
}
|
|
|
|
/* Finalize PASS 1: resolve superclass + mixin references now that the class
|
|
* table is complete. */
|
|
static void ruby_finalize_refs(RubyLSPContext *ctx) {
|
|
for (int i = 0; i < ctx->class_count; i++) {
|
|
RubyClassInfo *ci = &ctx->classes[i];
|
|
if (ci->superclass_ref) {
|
|
ci->superclass_qn = ruby_resolve_packed_ref(ctx, ci->superclass_ref);
|
|
/* Keep the bare ref spelling for AR-model heuristics. */
|
|
ci->superclass_ref = ruby_packed_bare_ref(ci->superclass_ref);
|
|
}
|
|
}
|
|
for (int i = 0; i < ctx->mixin_count; i++) {
|
|
RubyMixinInfo *mi = &ctx->mixins[i];
|
|
mi->module_qn = ruby_resolve_packed_ref(ctx, mi->module_ref);
|
|
mi->module_ref = ruby_packed_bare_ref(mi->module_ref);
|
|
}
|
|
}
|
|
|
|
/* ── PASS 1.5: instance-variable type inference ─────────────────── */
|
|
|
|
/* Statically evaluate `Const.new` / `A::B.new` without scope context. */
|
|
static const char *ruby_static_ctor_type(RubyLSPContext *ctx, TSNode expr) {
|
|
if (ts_node_is_null(expr) || strcmp(ts_node_type(expr), "call") != 0)
|
|
return NULL;
|
|
TSNode meth = ts_node_child_by_field_name(expr, "method", 6);
|
|
TSNode recv = ts_node_child_by_field_name(expr, "receiver", 8);
|
|
if (ts_node_is_null(meth) || ts_node_is_null(recv))
|
|
return NULL;
|
|
char *mname = ruby_node_text(ctx, meth);
|
|
if (!mname || strcmp(mname, "new") != 0)
|
|
return NULL;
|
|
const char *cpath = ruby_const_path(ctx, recv);
|
|
if (!cpath)
|
|
return NULL;
|
|
return ruby_resolve_constant(ctx, cpath);
|
|
}
|
|
|
|
/* Observe one write to `@x` (the `left` node) with right-hand side `right`.
|
|
* Only a RESOLVED `Const.new` types the ivar; `nil` is neutral (a common
|
|
* reset that says nothing about the type); every other right-hand side —
|
|
* an unresolved constant's `.new`, a method result, a parameter, a literal
|
|
* — latches the conflict, exactly like two different constructors do. */
|
|
static void ruby_ivar_observe_write(RubyLSPContext *ctx, TSNode left, TSNode right) {
|
|
char *ivar = ruby_node_text(ctx, left);
|
|
if (!ivar || !ivar[0])
|
|
return;
|
|
if (!ts_node_is_null(right) && strcmp(ts_node_type(right), "nil") == 0)
|
|
return;
|
|
const char *tqn = ruby_static_ctor_type(ctx, right);
|
|
if (tqn)
|
|
ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn);
|
|
else
|
|
ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar);
|
|
}
|
|
|
|
/* Latch every instance variable named on the left of a multiple assignment
|
|
* (`@a, @b = ...`): per-element typing is not attempted. */
|
|
static void ruby_ivar_conflict_assignment_list(RubyLSPContext *ctx, TSNode lhs) {
|
|
if (ts_node_is_null(lhs))
|
|
return;
|
|
const char *k = ts_node_type(lhs);
|
|
if (strcmp(k, "instance_variable") == 0) {
|
|
char *ivar = ruby_node_text(ctx, lhs);
|
|
if (ivar && ivar[0])
|
|
ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar);
|
|
return;
|
|
}
|
|
if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 ||
|
|
strcmp(k, "rest_assignment") == 0) {
|
|
uint32_t nc = ts_node_named_child_count(lhs);
|
|
for (uint32_t i = 0; i < nc; i++)
|
|
ruby_ivar_conflict_assignment_list(ctx, ts_node_named_child(lhs, i));
|
|
}
|
|
}
|
|
|
|
static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node) {
|
|
if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH)
|
|
return;
|
|
ctx->walk_depth++;
|
|
ruby_ivar_scan_inner(ctx, node);
|
|
ctx->walk_depth--;
|
|
}
|
|
|
|
static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) {
|
|
if (ts_node_is_null(node))
|
|
return;
|
|
const char *k = ts_node_type(node);
|
|
|
|
if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) {
|
|
RubyClassFrame fr;
|
|
if (!ruby_class_enter(ctx, node, &fr))
|
|
return;
|
|
TSNode body = ts_node_child_by_field_name(node, "body", 4);
|
|
if (!ts_node_is_null(body))
|
|
ruby_ivar_scan(ctx, body);
|
|
ruby_class_leave(ctx, &fr);
|
|
return;
|
|
}
|
|
|
|
if (ctx->enclosing_class_qn) {
|
|
if (strcmp(k, "assignment") == 0) {
|
|
TSNode left = ts_node_child_by_field_name(node, "left", 4);
|
|
TSNode right = ts_node_child_by_field_name(node, "right", 5);
|
|
if (!ts_node_is_null(left)) {
|
|
const char *lk = ts_node_type(left);
|
|
if (strcmp(lk, "instance_variable") == 0)
|
|
ruby_ivar_observe_write(ctx, left, right);
|
|
else if (strcmp(lk, "left_assignment_list") == 0)
|
|
ruby_ivar_conflict_assignment_list(ctx, left);
|
|
}
|
|
} else if (strcmp(k, "operator_assignment") == 0) {
|
|
/* `@x ||= Const.new` is the memoised-constructor idiom and counts
|
|
* as a constructor assignment; any other operator is an untyped
|
|
* write. */
|
|
TSNode left = ts_node_child_by_field_name(node, "left", 4);
|
|
TSNode op = ts_node_child_by_field_name(node, "operator", 8);
|
|
TSNode right = ts_node_child_by_field_name(node, "right", 5);
|
|
if (!ts_node_is_null(left) && strcmp(ts_node_type(left), "instance_variable") == 0) {
|
|
char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op);
|
|
if (optxt && strcmp(optxt, "||=") == 0) {
|
|
ruby_ivar_observe_write(ctx, left, right);
|
|
} else {
|
|
char *ivar = ruby_node_text(ctx, left);
|
|
if (ivar && ivar[0])
|
|
ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* A block that rebinds `self` writes ivars on some OTHER object; do not
|
|
* attribute them to the enclosing class. */
|
|
TSNode skip_blk = {0};
|
|
if (strcmp(k, "call") == 0 && ruby_call_rebinds_self(ctx, node))
|
|
skip_blk = ts_node_child_by_field_name(node, "block", 5);
|
|
|
|
uint32_t nc = ts_node_child_count(node);
|
|
for (uint32_t i = 0; i < nc; i++) {
|
|
TSNode c = ts_node_child(node, i);
|
|
if (!ts_node_is_null(c) && !(!ts_node_is_null(skip_blk) && ts_node_eq(c, skip_blk)))
|
|
ruby_ivar_scan(ctx, c);
|
|
}
|
|
}
|
|
|
|
/* ── public API ─────────────────────────────────────────────────── */
|
|
|
|
void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len,
|
|
const CBMTypeRegistry *registry, const char *module_qn,
|
|
CBMResolvedCallArray *out) {
|
|
memset(ctx, 0, sizeof(*ctx));
|
|
ctx->arena = arena;
|
|
ctx->source = source;
|
|
ctx->source_len = source_len;
|
|
ctx->registry = registry;
|
|
ctx->module_qn = module_qn;
|
|
ctx->nesting = "";
|
|
ctx->resolved_calls = out;
|
|
ctx->current_scope = cbm_scope_push(arena, NULL);
|
|
|
|
const char *dbg = getenv("CBM_LSP_DEBUG");
|
|
ctx->debug = (dbg && dbg[0]);
|
|
}
|
|
|
|
/* ── entry: cbm_run_ruby_lsp ────────────────────────────────────── */
|
|
|
|
void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len,
|
|
TSNode root) {
|
|
if (!result || !arena || ts_node_is_null(root))
|
|
return;
|
|
|
|
CBMTypeRegistry reg;
|
|
cbm_registry_init(®, arena);
|
|
|
|
/* Phase A: stdlib (Ruby core + curated Rails surface). */
|
|
cbm_ruby_stdlib_register(®, arena);
|
|
|
|
const char *module_qn = result->module_qn;
|
|
|
|
RubyLSPContext ctx;
|
|
ruby_lsp_init(&ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls);
|
|
|
|
/* Phase B: PASS 1 — collect classes/modules, mixins, and methods from
|
|
* the AST (methods register directly into `reg`; the AST is ground
|
|
* truth for the instance-vs-singleton split the defs don't carry). */
|
|
ctx.build_reg = ®
|
|
ruby_pass1_scan(&ctx, root);
|
|
ctx.build_reg = NULL;
|
|
ruby_finalize_refs(&ctx);
|
|
|
|
/* Finalize the registry for O(1) lookups — index allocations go to a
|
|
* per-call scratch arena that dies with this call (see perl_lsp.c). */
|
|
CBMArena idx_arena;
|
|
cbm_arena_init(&idx_arena);
|
|
cbm_registry_finalize_into(®, &idx_arena);
|
|
|
|
/* Phase B.5: PASS 1.5 — instance-variable types (needs the finished
|
|
* class table + registry for constant resolution). */
|
|
ctx.nesting = "";
|
|
ctx.enclosing_class_qn = NULL;
|
|
ruby_ivar_scan(&ctx, root);
|
|
|
|
/* Phase C: PASS 2 — resolution walk. */
|
|
ctx.nesting = "";
|
|
ctx.enclosing_class_qn = NULL;
|
|
ctx.enclosing_func_qn = NULL;
|
|
ctx.in_singleton_method = false;
|
|
ruby_resolve_calls_in_node(&ctx, root);
|
|
|
|
if (ctx.debug) {
|
|
fprintf(stderr, "[ruby_lsp] module_qn=%s defs=%d resolved=%d classes=%d mixins=%d\n",
|
|
module_qn ? module_qn : "(null)", result->defs.count, result->resolved_calls.count,
|
|
ctx.class_count, ctx.mixin_count);
|
|
for (int i = 0; i < result->resolved_calls.count; i++) {
|
|
CBMResolvedCall *r = &result->resolved_calls.items[i];
|
|
fprintf(stderr, "[ruby_lsp] %s -> %s [%s %.2f]\n", r->caller_qn, r->callee_qn,
|
|
r->strategy, r->confidence);
|
|
}
|
|
}
|
|
|
|
cbm_arena_destroy(&idx_arena);
|
|
}
|