feat: add RubyExtractor for tree-sitter Ruby structural analysis

Handles methods, classes, modules, attr_* properties, require imports,
and call graph including bare identifier calls (no-arg method invocations).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-04-15 19:02:52 +08:00
Unverified
parent 6e500cbfdf
commit dc8dd7dd77
2 changed files with 1108 additions and 0 deletions
@@ -0,0 +1,688 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { RubyExtractor } from "../ruby-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Ruby grammar once
let Parser: any;
let Language: any;
let rubyLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-ruby/tree-sitter-ruby.wasm",
);
rubyLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(rubyLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("RubyExtractor", () => {
const extractor = new RubyExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["ruby"]);
});
// ---- Functions / Methods ----
describe("extractStructure - functions", () => {
it("extracts simple methods", () => {
const { tree, parser, root } = parse(`
def hello(name)
puts name
end
def add(a, b)
a + b
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("hello");
expect(result.functions[0].params).toEqual(["name"]);
expect(result.functions[0].lineRange[0]).toBeGreaterThan(0);
expect(result.functions[1].name).toBe("add");
expect(result.functions[1].params).toEqual(["a", "b"]);
tree.delete();
parser.delete();
});
it("extracts methods with optional parameters", () => {
const { tree, parser, root } = parse(`
def connect(host, port = 8080, timeout = 30.0)
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("connect");
expect(result.functions[0].params).toEqual(["host", "port", "timeout"]);
tree.delete();
parser.delete();
});
it("extracts methods with splat, hash splat, and block parameters", () => {
const { tree, parser, root } = parse(`
def flexible(*args, **kwargs, &block)
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].params).toEqual(["*args", "**kwargs", "&block"]);
tree.delete();
parser.delete();
});
it("extracts methods with no parameters", () => {
const { tree, parser, root } = parse(`
def noop
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
tree.delete();
parser.delete();
});
it("does not assign return types (Ruby has none)", () => {
const { tree, parser, root } = parse(`
def compute(x)
x * 2
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("reports correct line ranges", () => {
const { tree, parser, root } = parse(`
def multiline(
a,
b
)
result = a + b
result
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts classes with methods", () => {
const { tree, parser, root } = parse(`
class UserService
def initialize(name)
@name = name
end
def find_user(id)
db_query(id)
end
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("UserService");
expect(result.classes[0].methods).toContain("initialize");
expect(result.classes[0].methods).toContain("find_user");
tree.delete();
parser.delete();
});
it("extracts attr_accessor, attr_reader, attr_writer as properties", () => {
const { tree, parser, root } = parse(`
class Model
attr_accessor :name, :email
attr_reader :id
attr_writer :status
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toEqual(["name", "email", "id", "status"]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts singleton methods (def self.foo) within classes", () => {
const { tree, parser, root } = parse(`
class Factory
def self.create(attrs)
new(attrs)
end
def instance_method
end
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].methods).toContain("self.create");
expect(result.classes[0].methods).toContain("instance_method");
tree.delete();
parser.delete();
});
it("also adds class methods to the functions array", () => {
const { tree, parser, root } = parse(`
class Svc
def run(x)
x
end
end
`);
const result = extractor.extractStructure(root);
// Class methods appear in the top-level functions array
expect(result.functions.some((f) => f.name === "run")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts namespaced class names", () => {
const { tree, parser, root } = parse(`
class Foo::Bar
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Foo::Bar");
tree.delete();
parser.delete();
});
it("reports correct class line ranges", () => {
const { tree, parser, root } = parse(`
class MyClass
def method_a
end
def method_b
end
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].lineRange[0]).toBe(2);
expect(result.classes[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Modules ----
describe("extractStructure - modules", () => {
it("treats modules as classes", () => {
const { tree, parser, root } = parse(`
module Helpers
def format_date(date)
date.strftime("%Y-%m-%d")
end
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Helpers");
expect(result.classes[0].methods).toContain("format_date");
tree.delete();
parser.delete();
});
it("extracts module properties from attr_* calls", () => {
const { tree, parser, root } = parse(`
module Config
attr_accessor :debug
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toContain("debug");
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts require statements", () => {
const { tree, parser, root } = parse(`
require "json"
require "net/http"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("json");
expect(result.imports[0].specifiers).toEqual(["json"]);
expect(result.imports[1].source).toBe("net/http");
expect(result.imports[1].specifiers).toEqual(["net/http"]);
tree.delete();
parser.delete();
});
it("extracts require_relative statements", () => {
const { tree, parser, root } = parse(`
require_relative "./helper"
require_relative "../lib/utils"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("./helper");
expect(result.imports[1].source).toBe("../lib/utils");
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`
require "json"
require_relative "./helper"
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
it("handles mixed require and require_relative", () => {
const { tree, parser, root } = parse(`
require "json"
require_relative "./helper"
require "yaml"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(3);
expect(result.imports[0].source).toBe("json");
expect(result.imports[1].source).toBe("./helper");
expect(result.imports[2].source).toBe("yaml");
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("treats top-level methods as exports", () => {
const { tree, parser, root } = parse(`
def public_func
end
def another_func(x)
end
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("public_func");
expect(exportNames).toContain("another_func");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats top-level classes as exports", () => {
const { tree, parser, root } = parse(`
class MyService
end
class MyModel
end
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("MyService");
expect(exportNames).toContain("MyModel");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats top-level modules as exports", () => {
const { tree, parser, root } = parse(`
module Helpers
end
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Helpers");
tree.delete();
parser.delete();
});
it("does not treat imports as exports", () => {
const { tree, parser, root } = parse(`
require "json"
require_relative "./helper"
def my_func
end
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(1);
expect(result.exports[0].name).toBe("my_func");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple method calls", () => {
const { tree, parser, root } = parse(`
def process(data)
result = transform(data)
format_output(result)
end
def main
process([1, 2, 3])
end
`);
const result = extractor.extractCallGraph(root);
const processCallers = result.filter((e) => e.caller === "process");
expect(processCallers.some((e) => e.callee === "transform")).toBe(true);
expect(processCallers.some((e) => e.callee === "format_output")).toBe(true);
const mainCallers = result.filter((e) => e.caller === "main");
expect(mainCallers.some((e) => e.callee === "process")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts receiver-based calls (method calls on objects)", () => {
const { tree, parser, root } = parse(`
def process
result.save
date.strftime("%Y-%m-%d")
end
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(callees).toContain("result.save");
expect(callees).toContain("date.strftime");
tree.delete();
parser.delete();
});
it("tracks correct caller context for calls inside class methods", () => {
const { tree, parser, root } = parse(`
class Service
def start
setup
run_server
end
end
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "start");
expect(startCalls.some((e) => e.callee === "setup")).toBe(true);
expect(startCalls.some((e) => e.callee === "run_server")).toBe(true);
tree.delete();
parser.delete();
});
it("does not include require/require_relative in call graph", () => {
const { tree, parser, root } = parse(`
def setup
require "json"
do_work
end
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(callees).not.toContain("require");
expect(callees).toContain("do_work");
tree.delete();
parser.delete();
});
it("does not include attr_* macros in call graph", () => {
const { tree, parser, root } = parse(`
class Foo
attr_accessor :bar
def init
setup
end
end
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(callees).not.toContain("attr_accessor");
expect(callees).toContain("setup");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`
def main
foo
bar
end
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`
puts "hello"
main
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing method, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("tracks singleton method callers with self. prefix", () => {
const { tree, parser, root } = parse(`
class Foo
def self.create(attrs)
new(attrs)
end
end
`);
const result = extractor.extractCallGraph(root);
const createCalls = result.filter((e) => e.caller === "self.create");
expect(createCalls.some((e) => e.callee === "new")).toBe(true);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Ruby file", () => {
it("handles the full test fixture", () => {
const { tree, parser, root } = parse(`
require "json"
require_relative "./helper"
class UserService
attr_accessor :name, :email
attr_reader :id
def initialize(name, email)
@name = name
@email = email
end
def find_user(id)
result = db_query(id)
format_user(result)
end
def self.create(attrs)
new(attrs[:name], attrs[:email])
end
end
module Helpers
def format_date(date)
date.strftime("%Y-%m-%d")
end
end
def standalone_helper(x)
puts x.to_s
end
`);
const result = extractor.extractStructure(root);
// Functions: initialize, find_user, self.create, format_date, standalone_helper
const funcNames = result.functions.map((f) => f.name);
expect(funcNames).toContain("initialize");
expect(funcNames).toContain("find_user");
expect(funcNames).toContain("self.create");
expect(funcNames).toContain("format_date");
expect(funcNames).toContain("standalone_helper");
expect(result.functions).toHaveLength(5);
// Classes: UserService (methods: initialize, find_user, self.create; properties: name, email, id)
expect(result.classes).toHaveLength(2);
const userService = result.classes.find((c) => c.name === "UserService");
expect(userService).toBeDefined();
expect(userService!.methods).toContain("initialize");
expect(userService!.methods).toContain("find_user");
expect(userService!.methods).toContain("self.create");
expect(userService!.properties).toEqual(
expect.arrayContaining(["name", "email", "id"]),
);
// Helpers module (methods: format_date)
const helpers = result.classes.find((c) => c.name === "Helpers");
expect(helpers).toBeDefined();
expect(helpers!.methods).toContain("format_date");
// Imports: 2 (json, ./helper)
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("json");
expect(result.imports[1].source).toBe("./helper");
// Exports: UserService, Helpers, standalone_helper (all top-level)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("Helpers");
expect(exportNames).toContain("standalone_helper");
// Call graph
const calls = extractor.extractCallGraph(root);
// find_user -> db_query, find_user -> format_user
const findUserCalls = calls.filter((e) => e.caller === "find_user");
expect(findUserCalls.some((e) => e.callee === "db_query")).toBe(true);
expect(findUserCalls.some((e) => e.callee === "format_user")).toBe(true);
// standalone_helper -> puts
const standaloneHelperCalls = calls.filter(
(e) => e.caller === "standalone_helper",
);
expect(standaloneHelperCalls.some((e) => e.callee === "puts")).toBe(true);
// Verify require/require_relative not in call graph
const allCallees = calls.map((e) => e.callee);
expect(allCallees).not.toContain("require");
expect(allCallees).not.toContain("require_relative");
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,420 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Set of method names that Ruby uses for imports.
* These are handled separately from regular call graph entries.
*/
const IMPORT_METHODS = new Set(["require", "require_relative"]);
/**
* Set of method names that define class properties (attr_* macros).
* Their arguments are symbols that become accessor methods / properties.
*/
const ATTR_METHODS = new Set(["attr_accessor", "attr_reader", "attr_writer"]);
/**
* Extract parameter names from a Ruby `method_parameters` node.
*
* Handles: identifier (plain), optional_parameter (with default),
* splat_parameter (*args), hash_splat_parameter (**kwargs),
* block_parameter (&block).
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
for (let i = 0; i < paramsNode.childCount; i++) {
const child = paramsNode.child(i);
if (!child) continue;
switch (child.type) {
case "identifier":
params.push(child.text);
break;
case "optional_parameter": {
const ident = child.childForFieldName("name");
if (ident) params.push(ident.text);
break;
}
case "splat_parameter": {
const ident = child.childForFieldName("name");
if (ident) params.push("*" + ident.text);
break;
}
case "hash_splat_parameter": {
const ident = child.childForFieldName("name");
if (ident) params.push("**" + ident.text);
break;
}
case "block_parameter": {
const ident = child.childForFieldName("name");
if (ident) params.push("&" + ident.text);
break;
}
}
}
return params;
}
/**
* Extract property names from attr_accessor/attr_reader/attr_writer calls.
* These calls take symbol arguments like `:name, :email`.
*/
function extractAttrProperties(callNode: TreeSitterNode): string[] {
const properties: string[] = [];
const args = callNode.childForFieldName("arguments");
if (!args) return properties;
for (let i = 0; i < args.childCount; i++) {
const child = args.child(i);
if (child && child.type === "simple_symbol") {
// Strip leading colon from `:name` -> `name`
properties.push(child.text.slice(1));
}
}
return properties;
}
/**
* Extract the string value from a Ruby string node.
* Ruby strings have a `string_content` child containing the unquoted value.
*/
function getStringContent(node: TreeSitterNode): string {
const content = findChild(node, "string_content");
if (content) return content.text;
// Fallback: strip surrounding quotes
return node.text.replace(/^['"`]|['"`]$/g, "");
}
/**
* Ruby extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles methods, classes, modules, require imports, and call graphs
* for Ruby source code.
*
* Ruby-specific mapping decisions:
* - Both `class` and `module` nodes are mapped to the `classes` array.
* - `singleton_method` (def self.foo) is prefixed with "self." in the name.
* - `attr_accessor`/`attr_reader`/`attr_writer` define properties on classes.
* - `require` and `require_relative` calls are mapped to imports.
* - All top-level definitions (classes, modules, methods) are treated as exports,
* since Ruby has no formal export syntax.
*/
export class RubyExtractor implements LanguageExtractor {
readonly languageIds = ["ruby"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
switch (node.type) {
case "method":
this.extractMethod(node, functions);
exports.push({
name: this.getMethodName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "singleton_method":
this.extractSingletonMethod(node, functions);
exports.push({
name: "self." + this.getSingletonMethodName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "class":
this.extractClass(node, classes, functions);
exports.push({
name: this.getClassName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "module":
this.extractModule(node, classes, functions);
exports.push({
name: this.getModuleName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "call":
this.extractTopLevelCall(node, imports);
break;
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering method definitions
if (node.type === "method") {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
} else if (node.type === "singleton_method") {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push("self." + nameNode.text);
pushedName = true;
}
}
// Extract call expressions (but not imports or attr_* macros)
if (node.type === "call") {
const methodNode = node.childForFieldName("method");
if (methodNode && functionStack.length > 0) {
const methodName = methodNode.text;
// Skip require/require_relative (imports) and attr_* macros
if (!IMPORT_METHODS.has(methodName) && !ATTR_METHODS.has(methodName)) {
const receiverNode = node.childForFieldName("receiver");
const callee = receiverNode
? receiverNode.text + "." + methodName
: methodName;
entries.push({
caller: functionStack[functionStack.length - 1],
callee,
lineNumber: node.startPosition.row + 1,
});
}
}
}
// Ruby bare method calls without arguments (e.g., `setup`) are parsed as
// `identifier` nodes inside `body_statement`, not as `call` nodes.
// Treat them as calls when inside a function context.
if (
node.type === "identifier" &&
node.parent?.type === "body_statement" &&
functionStack.length > 0
) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: node.text,
lineNumber: node.startPosition.row + 1,
});
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
private getMethodName(node: TreeSitterNode): string {
const nameNode = node.childForFieldName("name");
return nameNode ? nameNode.text : "";
}
private getSingletonMethodName(node: TreeSitterNode): string {
const nameNode = node.childForFieldName("name");
return nameNode ? nameNode.text : "";
}
private getClassName(node: TreeSitterNode): string {
const nameNode = node.childForFieldName("name");
if (!nameNode) return "";
// Can be `constant` ("Foo") or `scope_resolution` ("Foo::Bar")
return nameNode.text;
}
private getModuleName(node: TreeSitterNode): string {
const nameNode = node.childForFieldName("name");
return nameNode ? nameNode.text : "";
}
private extractMethod(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
});
}
private extractSingletonMethod(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
functions.push({
name: "self." + nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
});
}
private extractClass(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
): void {
const name = this.getClassName(node);
if (!name) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
this.extractClassBody(body, methods, properties, functions);
}
classes.push({
name,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
}
private extractModule(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
): void {
const name = this.getModuleName(node);
if (!name) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
this.extractClassBody(body, methods, properties, functions);
}
classes.push({
name,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
}
/**
* Extract methods and properties from a class/module body_statement.
* Also pushes each method into the top-level functions array.
*/
private extractClassBody(
body: TreeSitterNode,
methods: string[],
properties: string[],
functions: StructuralAnalysis["functions"],
): void {
for (let i = 0; i < body.childCount; i++) {
const member = body.child(i);
if (!member) continue;
if (member.type === "method") {
const nameNode = member.childForFieldName("name");
if (nameNode) {
methods.push(nameNode.text);
this.extractMethod(member, functions);
}
} else if (member.type === "singleton_method") {
const nameNode = member.childForFieldName("name");
if (nameNode) {
methods.push("self." + nameNode.text);
this.extractSingletonMethod(member, functions);
}
} else if (member.type === "call") {
// Check for attr_accessor/attr_reader/attr_writer
const methodNode = member.childForFieldName("method");
if (methodNode && ATTR_METHODS.has(methodNode.text)) {
properties.push(...extractAttrProperties(member));
}
}
}
}
/**
* Handle top-level call nodes: extract require/require_relative as imports.
*/
private extractTopLevelCall(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const methodNode = node.childForFieldName("method");
if (!methodNode) return;
if (IMPORT_METHODS.has(methodNode.text)) {
const args = node.childForFieldName("arguments");
if (!args) return;
// The first argument is typically the string source
const firstArg = findChild(args, "string");
if (firstArg) {
const source = getStringContent(firstArg);
imports.push({
source,
specifiers: [source],
lineNumber: node.startPosition.row + 1,
});
}
}
}
}