diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/ruby-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/ruby-extractor.test.ts new file mode 100644 index 0000000..0172e7c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/ruby-extractor.test.ts @@ -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(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/ruby-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/ruby-extractor.ts new file mode 100644 index 0000000..e4f115c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/ruby-extractor.ts @@ -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, + }); + } + } + } +}