mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
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:
+688
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user