feat: Add Ruby module extraction with containment and qualified names

Addresses Ruby methods inside modules missing owner in qualified_name by adding visitNode hook to extract module AST nodes. Methods inside modules now get Module::method qualified names with proper containment relationships. Includes ExtractorContext wiring with pushScope/popScope for language hooks and updates isInsideClassLikeNode to include module kind for nested method handling.
This commit is contained in:
Colby McHenry
2026-04-07 09:17:34 -05:00
parent 07d899b735
commit 59ea5a43be
7 changed files with 126 additions and 6 deletions
+23 -1
View File
@@ -6,7 +6,7 @@ export const rubyExtractor: LanguageExtractor = {
functionTypes: ['method'],
classTypes: ['class'],
methodTypes: ['method', 'singleton_method'],
interfaceTypes: [], // Ruby uses modules
interfaceTypes: [], // Ruby uses modules (handled via visitNode hook)
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
@@ -16,6 +16,28 @@ export const rubyExtractor: LanguageExtractor = {
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
visitNode: (node, ctx) => {
if (node.type !== 'module') return false;
const nameNode = node.childForFieldName('name');
if (!nameNode) return false;
const name = nameNode.text;
const moduleNode = ctx.createNode('module', name, node);
if (!moduleNode) return false;
// Push module onto scope stack so children get proper qualified names
ctx.pushScope(moduleNode.id);
const body = node.childForFieldName('body');
if (body) {
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
if (child) ctx.visitNode(child);
}
}
ctx.popScope();
return true; // handled
},
getVisibility: (node) => {
// Ruby visibility is based on preceding visibility modifiers
let sibling = node.previousNamedSibling;
+4
View File
@@ -56,6 +56,10 @@ export interface ExtractorContext {
visitFunctionBody(body: SyntaxNode, functionId: string): void;
/** Add an unresolved reference */
addUnresolvedReference(ref: UnresolvedReference): void;
/** Push a node ID onto the scope stack (for containment/qualified name building) */
pushScope(nodeId: string): void;
/** Pop the last node ID from the scope stack */
popScope(): void;
/** Current file path */
readonly filePath: string;
/** Current source text */
+30 -2
View File
@@ -17,7 +17,7 @@ import {
} from '../types';
import { getParser, detectLanguage, isLanguageSupported } from './grammars';
import { generateNodeId, getNodeText, getChildByField, getPrecedingDocstring } from './tree-sitter-helpers';
import type { LanguageExtractor } from './tree-sitter-types';
import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
import { EXTRACTORS } from './languages';
import { LiquidExtractor } from './liquid-extractor';
import { SvelteExtractor } from './svelte-extractor';
@@ -223,6 +223,13 @@ export class TreeSitterExtractor {
const nodeType = node.type;
let skipChildren = false;
// Language-specific custom visitor hook
if (this.extractor.visitNode) {
const ctx = this.makeExtractorContext();
const handled = this.extractor.visitNode(node, ctx);
if (handled) return;
}
// Pascal-specific AST handling
if (this.language === 'pascal') {
skipChildren = this.visitPascalNode(node);
@@ -409,6 +416,26 @@ export class TreeSitterExtractor {
return parts.join('::');
}
/**
* Build an ExtractorContext for passing to language-specific visitNode hooks.
*/
private makeExtractorContext(): ExtractorContext {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
return {
createNode: (kind, name, node, extra) => self.createNode(kind, name, node, extra),
visitNode: (node) => self.visitNode(node),
visitFunctionBody: (body, functionId) => self.visitFunctionBody(body, functionId),
addUnresolvedReference: (ref) => self.unresolvedReferences.push(ref),
pushScope: (nodeId) => self.nodeStack.push(nodeId),
popScope: () => self.nodeStack.pop(),
get filePath() { return self.filePath; },
get source() { return self.source; },
get nodeStack() { return self.nodeStack; },
get nodes() { return self.nodes; },
};
}
/**
* Check if the current node stack indicates we are inside a class-like node
* (class, struct, interface, trait). File nodes do not count as class-like.
@@ -424,7 +451,8 @@ export class TreeSitterExtractor {
parentNode.kind === 'struct' ||
parentNode.kind === 'interface' ||
parentNode.kind === 'trait' ||
parentNode.kind === 'enum'
parentNode.kind === 'enum' ||
parentNode.kind === 'module'
);
}