Compare commits

...
Author SHA1 Message Date
claude[bot]andGergő Magyar 8d39532185 test(ruby): add ingestion integration tests for Ruby language support
Adds 8 fixture-driven integration test scenarios modelled after the
existing C/C++ and Python resolver test suites:

- ruby-pkg: require_relative imports, class inheritance (EXTENDS), module
  include (IMPLEMENTS), method detection across 5 files
- ruby-ambiguous: two Handler classes in a/ and b/, require_relative to a/
  disambiguates the EXTENDS target
- ruby-calls: arity-filtered call resolution — write_audit("hello") resolves
  to one.rb (1-param) not zero.rb (0-params) via import-resolved
- ruby-member-calls: obj.method() member call resolves User.save via the
  import map; verifies HAS_METHOD edge
- ruby-mixin-heritage: include / extend / prepend all produce IMPLEMENTS
  edges; no EXTENDS emitted for any mixin form
- ruby-attr-properties: attr_accessor / attr_reader / attr_writer create
  Property nodes with correct accessorType in description
- ruby-receiver-resolution: both User and Repo classes exist with save
  methods; HAS_METHOD verified for each; at least one save call resolved
- ruby-local-shadow: local def save in main.rb shadows the require'd
  utils.rb#save; run → save resolves to same-file definition

Co-authored-by: Gergő Magyar <magyargergo@users.noreply.github.com>
2026-03-13 19:47:23 +00:00
Candido Gomes fbd1a6f0d8 feat: add Ruby language support in parsing tests and loader functionality 2026-03-13 14:58:08 -04:00
Candido Gomes c2c2937978 feat: implement Ruby call routing for imports, heritage, and properties 2026-03-13 14:47:32 -04:00
Candido Gomes 055fa96c0d feat: add Ruby import resolution support with require and require_relative handling 2026-03-13 14:36:29 -04:00
Candido Gomes 527d0bf775 revert 2026-03-13 12:30:37 -04:00
Candido Gomes c1bd27a7e0 revert 2026-03-13 12:28:48 -04:00
Candido Gomes eb73f0f600 feat: enhance syntax highlighting support for Ruby and other file types 2026-03-13 12:06:09 -04:00
Candido Gomes 743245a620 feat: add Kotlin support to tree-sitter integration and update related configurations 2026-03-13 11:56:07 -04:00
Candido Gomes ec1815cf0b feat: add Ruby framework detection for Rakefile and .rake extensions 2026-03-13 11:48:26 -04:00
Candido Gomes fc025cfd22 fix: correct Ruby queries syntax by closing string literal 2026-03-13 11:26:19 -04:00
Candido Gomes 27415265c7 feat: enhance Ruby framework detection and update tree-sitter queries 2026-03-13 11:24:17 -04:00
Candido Gomes a2814dc26e Merge upstream/main: resolve conflicts keeping Ruby support with shared modules
Conflicts resolved by taking upstream refactored shared module approach
(extractFunctionName, isBuiltInOrNoise, isNodeExported from utils.js and
export-detection.js) while preserving Ruby-specific call handling (require,
include/extend/prepend, attr_accessor) and adding Ruby to the language matrix.
2026-03-13 09:54:03 -04:00
Candido Sales Gomes 631fec371f Merge branch 'main' into add-support-ruby-rails 2026-03-02 17:52:50 -05:00
Candido Sales Gomes d9dafdf792 Merge branch 'main' into add-support-ruby-rails 2026-03-01 14:33:09 -05:00
Candido Sales Gomes 6d0a761910 Merge branch 'main' into add-support-ruby-rails 2026-02-28 09:36:47 -05:00
Candido Gomes edd3aec044 refactor(ruby): remove outdated Ruby call routing documentation 2026-02-27 20:43:56 -05:00
Candido Gomes d51fb35634 feat(ruby): implement Ruby support with enhanced call processing and import handling 2026-02-27 20:40:52 -05:00
Candido GomesandClaude Opus 4.6 520e3ecf24 feat(ruby): add Ruby language support for CLI and web
Add Ruby as the 11th supported language in GitNexus, enabling code
intelligence for Ruby codebases. This includes:

- Tree-sitter parsing for classes, modules, methods, and singleton methods
- Import extraction for require/require_relative via call post-processing
- Mixin heritage detection for include/extend/prepend
- attr_accessor/attr_reader/attr_writer property extraction
- Ruby-specific built-in filtering (Kernel methods + enumerables)
- Framework detection for lib/, bin/, exe/, and Rake patterns
- Entry point scoring for service objects, jobs, and CLI commands
- Test file detection for _spec.rb, _test.rb, and spec/ directories
- WASM grammar for web version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:53:41 -05:00
61 changed files with 1375 additions and 69 deletions
+1 -1
View File
@@ -327,7 +327,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
### Supported Languages
TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust, PHP, Swift
TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Ruby, Rust, PHP, Swift
---
Binary file not shown.
@@ -5,6 +5,42 @@ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useAppState } from '../hooks/useAppState';
import { NODE_COLORS } from '../lib/constants';
/** Map file extension to Prism syntax highlighter language identifier */
const getSyntaxLanguage = (filePath: string | undefined): string => {
if (!filePath) return 'text';
const ext = filePath.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js': case 'jsx': case 'mjs': case 'cjs': return 'javascript';
case 'ts': case 'tsx': case 'mts': case 'cts': return 'typescript';
case 'py': case 'pyw': return 'python';
case 'rb': case 'rake': case 'gemspec': return 'ruby';
case 'java': return 'java';
case 'go': return 'go';
case 'rs': return 'rust';
case 'c': case 'h': return 'c';
case 'cpp': case 'cc': case 'cxx': case 'hpp': case 'hxx': case 'hh': return 'cpp';
case 'cs': return 'csharp';
case 'php': return 'php';
case 'kt': case 'kts': return 'kotlin';
case 'swift': return 'swift';
case 'json': return 'json';
case 'yaml': case 'yml': return 'yaml';
case 'md': case 'mdx': return 'markdown';
case 'html': case 'htm': case 'erb': return 'markup';
case 'css': case 'scss': case 'sass': return 'css';
case 'sh': case 'bash': case 'zsh': return 'bash';
case 'sql': return 'sql';
case 'xml': return 'xml';
default: break;
}
// Handle extensionless Ruby files
const basename = filePath.split('/').pop() || '';
if (['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile'].includes(basename)) return 'ruby';
if (['Makefile'].includes(basename)) return 'makefile';
if (['Dockerfile'].includes(basename)) return 'docker';
return 'text';
};
// Match the code theme used elsewhere in the app
const customTheme = {
...vscDarkPlus,
@@ -267,12 +303,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
<div className="flex-1 min-h-0 overflow-auto scrollbar-thin">
{selectedFileContent ? (
<SyntaxHighlighter
language={
selectedFilePath?.endsWith('.py') ? 'python' :
selectedFilePath?.endsWith('.js') || selectedFilePath?.endsWith('.jsx') ? 'javascript' :
selectedFilePath?.endsWith('.ts') || selectedFilePath?.endsWith('.tsx') ? 'typescript' :
'text'
}
language={getSyntaxLanguage(selectedFilePath)}
style={customTheme as any}
showLineNumbers
startingLineNumber={1}
@@ -339,11 +370,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const hasRange = typeof ref.startLine === 'number';
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
const language =
ref.filePath.endsWith('.py') ? 'python' :
ref.filePath.endsWith('.js') || ref.filePath.endsWith('.jsx') ? 'javascript' :
ref.filePath.endsWith('.ts') || ref.filePath.endsWith('.tsx') ? 'typescript' :
'text';
const language = getSyntaxLanguage(ref.filePath);
const isGlowing = glowRefId === ref.id;
@@ -9,6 +9,6 @@ export enum SupportedLanguages {
Go = 'go',
Rust = 'rust',
PHP = 'php',
// Ruby = 'ruby',
Ruby = 'ruby',
Swift = 'swift',
}
+141 -34
View File
@@ -6,6 +6,8 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader';
import { LANGUAGE_QUERIES } from './tree-sitter-queries';
import { generateId } from '../../lib/utils';
import { getLanguageFromFilename } from './utils';
import { SupportedLanguages } from '../../config/supported-languages';
import { routeRubyCall } from './ruby-call-routing';
/**
* Node types that represent function/method definitions across languages.
@@ -35,6 +37,9 @@ const FUNCTION_NODE_TYPES = new Set([
// Rust
'function_item',
'impl_item', // Methods inside impl blocks
// Ruby
'method', // def foo
'singleton_method', // def self.foo
]);
/**
@@ -92,6 +97,18 @@ const findEnclosingFunction = (
current.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
label = 'Method'; // Treat constructors as methods for process detection
} else if (current.type === 'method') {
// Ruby instance method: def foo
const nameNode = current.childForFieldName?.('name') ||
current.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
label = 'Method';
} else if (current.type === 'singleton_method') {
// Ruby class method: def self.foo
const nameNode = current.childForFieldName?.('name') ||
current.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
label = 'Function';
} else if (current.type === 'arrow_function' || current.type === 'function_expression') {
// Arrow/expression: const foo = () => {} - check parent variable declarator
const parent = current.parent;
@@ -184,6 +201,61 @@ export const processCalls = async (
const calledName = nameNode.text;
// Ruby: route special calls to heritage or properties (imports handled by import-processor)
if (language === SupportedLanguages.Ruby) {
const callNode = captureMap['call'];
const routed = routeRubyCall(calledName, callNode);
switch (routed.kind) {
case 'skip':
case 'import': // handled by import-processor
return;
case 'heritage':
for (const item of routed.items) {
const childId = symbolTable.lookupExact(file.path, item.enclosingClass) ||
symbolTable.lookupFuzzy(item.enclosingClass)[0]?.nodeId ||
generateId('Class', `${file.path}:${item.enclosingClass}`);
const parentId = symbolTable.lookupFuzzy(item.mixinName)[0]?.nodeId ||
generateId('Module', `${item.mixinName}`);
if (childId && parentId) {
const relId = generateId('IMPLEMENTS', `${childId}->${parentId}`);
graph.addRelationship({
id: relId, sourceId: childId, targetId: parentId,
type: 'IMPLEMENTS', confidence: 1.0, reason: 'trait-impl',
});
}
}
return;
case 'properties':
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
graph.addNode({
id: nodeId,
label: 'Property' as any,
properties: {
name: item.propName, filePath: file.path,
startLine: item.startLine, endLine: item.endLine,
language: SupportedLanguages.Ruby, isExported: true,
description: item.accessorType,
},
});
symbolTable.add(file.path, item.propName, nodeId, 'Property');
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId, sourceId: fileId, targetId: nodeId,
type: 'DEFINES', confidence: 1.0, reason: '',
});
}
return;
case 'call':
break; // fall through to normal call processing below
}
}
// Skip common built-ins and noise
if (isBuiltInOrNoise(calledName)) return;
@@ -200,10 +272,10 @@ export const processCalls = async (
// 5. Find the enclosing function (caller)
const callNode = captureMap['call'];
const enclosingFuncId = findEnclosingFunction(callNode, file.path, symbolTable);
// Use enclosing function as source, fallback to file for top-level calls
const sourceId = enclosingFuncId || generateId('File', file.path);
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
graph.addRelationship({
@@ -711,37 +783,72 @@ const resolveCallTarget = (
* Filter out common built-in functions and noise
* that shouldn't be tracked as calls
*/
const isBuiltInOrNoise = (name: string): boolean => {
const builtIns = new Set([
// JavaScript/TypeScript built-ins
'console', 'log', 'warn', 'error', 'info', 'debug',
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent',
'JSON', 'parse', 'stringify',
'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt',
'Map', 'Set', 'WeakMap', 'WeakSet',
'Promise', 'resolve', 'reject', 'then', 'catch', 'finally',
'Math', 'Date', 'RegExp', 'Error',
'require', 'import', 'export',
'fetch', 'Response', 'Request',
// React hooks and common functions
'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext',
'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy',
// Common array/object methods
'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every',
'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split',
'push', 'pop', 'shift', 'unshift', 'sort', 'reverse',
'keys', 'values', 'entries', 'assign', 'freeze', 'seal',
'hasOwnProperty', 'toString', 'valueOf',
// Python built-ins
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
]);
/** Pre-built set (module-level singleton) to avoid re-creating per call */
const BUILT_IN_NAMES = new Set([
// JavaScript/TypeScript built-ins
'console', 'log', 'warn', 'error', 'info', 'debug',
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent',
'JSON', 'parse', 'stringify',
'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt',
'Map', 'Set', 'WeakMap', 'WeakSet',
'Promise', 'resolve', 'reject', 'then', 'catch', 'finally',
'Math', 'Date', 'RegExp', 'Error',
'require', 'import', 'export',
'fetch', 'Response', 'Request',
// React hooks and common functions
'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext',
'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy',
// Common array/object methods
'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every',
'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split',
'push', 'pop', 'shift', 'unshift', 'sort', 'reverse',
'keys', 'values', 'entries', 'assign', 'freeze', 'seal',
'hasOwnProperty', 'toString', 'valueOf',
// Python built-ins
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
// C/C++ standard library and common kernel helpers
'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf',
'scanf', 'fscanf', 'sscanf',
'malloc', 'calloc', 'realloc', 'free', 'memcpy', 'memmove', 'memset', 'memcmp',
'strlen', 'strcpy', 'strncpy', 'strcat', 'strncat', 'strcmp', 'strncmp', 'strstr', 'strchr', 'strrchr',
'atoi', 'atol', 'atof', 'strtol', 'strtoul', 'strtoll', 'strtoull', 'strtod',
'sizeof', 'offsetof', 'typeof',
'assert', 'abort', 'exit', '_exit',
'fopen', 'fclose', 'fread', 'fwrite', 'fseek', 'ftell', 'rewind', 'fflush', 'fgets', 'fputs',
// Linux kernel common macros/helpers (not real call targets)
'likely', 'unlikely', 'BUG', 'BUG_ON', 'WARN', 'WARN_ON', 'WARN_ONCE',
'IS_ERR', 'PTR_ERR', 'ERR_PTR', 'IS_ERR_OR_NULL',
'ARRAY_SIZE', 'container_of', 'list_for_each_entry', 'list_for_each_entry_safe',
'min', 'max', 'clamp', 'abs', 'swap',
'pr_info', 'pr_warn', 'pr_err', 'pr_debug', 'pr_notice', 'pr_crit', 'pr_emerg',
'printk', 'dev_info', 'dev_warn', 'dev_err', 'dev_dbg',
'GFP_KERNEL', 'GFP_ATOMIC',
'spin_lock', 'spin_unlock', 'spin_lock_irqsave', 'spin_unlock_irqrestore',
'mutex_lock', 'mutex_unlock', 'mutex_init',
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
'get', 'put',
// Ruby built-ins and Kernel methods
'puts', 'print', 'p', 'pp', 'warn', 'raise', 'fail',
'require', 'require_relative', 'load', 'autoload',
'include', 'extend', 'prepend',
'attr_accessor', 'attr_reader', 'attr_writer',
'public', 'private', 'protected', 'module_function',
'lambda', 'proc', 'block_given?',
'nil?', 'is_a?', 'kind_of?', 'instance_of?', 'respond_to?',
'freeze', 'frozen?', 'dup', 'clone', 'tap', 'then', 'yield_self',
// Ruby enumerables
'each', 'map', 'select', 'reject', 'find', 'detect', 'collect',
'inject', 'reduce', 'flat_map', 'each_with_object', 'each_with_index',
'any?', 'all?', 'none?', 'count', 'first', 'last',
'sort', 'sort_by', 'min', 'max', 'min_by', 'max_by',
'group_by', 'partition', 'zip', 'compact', 'flatten', 'uniq',
]);
return builtIns.has(name);
};
const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
@@ -13,7 +13,7 @@
import { detectFrameworkFromPath } from './framework-detection';
// ============================================================================
// NAME PATTERNS - All 9 supported languages
// NAME PATTERNS - All 11 supported languages
// ============================================================================
/**
@@ -143,6 +143,13 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^save$/, // Repository::save()
/^delete$/, // Repository::delete()
],
// Ruby
'ruby': [
/^call$/, // Service objects (MyService.call)
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
/^execute$/, // Command pattern
],
};
// ============================================================================
@@ -302,7 +309,12 @@ export function isTestFile(filePath: string): boolean {
p.endsWith('test.php') ||
p.endsWith('spec.php') ||
p.includes('/tests/feature/') ||
p.includes('/tests/unit/')
p.includes('/tests/unit/') ||
// Ruby test patterns
p.endsWith('_spec.rb') ||
p.endsWith('_test.rb') ||
p.includes('/spec/') ||
p.includes('/test/fixtures/')
);
}
@@ -257,6 +257,17 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
}
// ========== RUBY ==========
// Ruby: bin/ or exe/ (CLI entry points)
if ((p.includes('/bin/') || p.includes('/exe/')) && p.endsWith('.rb')) {
return { framework: 'ruby', entryPointMultiplier: 2.5, reason: 'ruby-executable' };
}
// Ruby: Rakefile or *.rake (task definitions)
if (p.endsWith('/rakefile') || p.endsWith('.rake')) {
return { framework: 'ruby', entryPointMultiplier: 1.5, reason: 'ruby-rake' };
}
// ========== SWIFT / iOS ==========
// iOS App entry points (highest priority)
@@ -53,7 +53,9 @@ const resolveImportPath = (
// Go
'.go',
// Rust
'.rs', '/mod.rs'
'.rs', '/mod.rs',
// Ruby
'.rb', '.rake',
];
if (importPath.startsWith('.')) {
@@ -220,6 +222,45 @@ export const processImports = async (
importMap.get(file.path)!.add(resolvedPath);
}
}
// ---- Ruby: require/require_relative come through @call, not @import ----
if (language === 'ruby' && captureMap['call']) {
const callNameNode = captureMap['call.name'];
if (callNameNode) {
const calledName = callNameNode.text;
if (calledName === 'require' || calledName === 'require_relative') {
const callNode = captureMap['call'];
const argList = callNode.childForFieldName?.('arguments');
const stringNode = argList?.children?.find((c: any) => c.type === 'string');
const contentNode = stringNode?.children?.find((c: any) => c.type === 'string_content');
if (contentNode) {
let importPath = contentNode.text;
// require_relative always resolves relative to current file
if (calledName === 'require_relative' && !importPath.startsWith('.')) {
importPath = './' + importPath;
}
totalImportsFound++;
const resolvedPath = resolveImportPath(
file.path, importPath, allFilePaths, allFileList, resolveCache
);
if (resolvedPath) {
const sourceId = generateId('File', file.path);
const targetId = generateId('File', resolvedPath);
const relId = generateId('IMPORTS', `${file.path}->${resolvedPath}`);
totalImportsResolved++;
graph.addRelationship({
id: relId, sourceId, targetId,
type: 'IMPORTS', confidence: 1.0, reason: '',
});
if (!importMap.has(file.path)) {
importMap.set(file.path, new Set());
}
importMap.get(file.path)!.add(resolvedPath);
}
}
}
}
}
});
// If re-parsed just for this, delete the tree to save memory
@@ -14,7 +14,7 @@ export type FileProgressCallback = (current: number, total: number, filePath: st
/**
* Check if a symbol (function, class, etc.) is exported/public
* Handles all 9 supported languages with explicit logic
* Handles all 11 supported languages with explicit logic
*
* @param node - The AST node for the symbol name
* @param name - The symbol name
@@ -104,7 +104,11 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
case 'c':
case 'cpp':
return false;
// Ruby: All top-level definitions are public by default
case 'ruby':
return true;
default:
return false;
}
@@ -0,0 +1,99 @@
/**
* Shared Ruby call routing logic.
*
* Ruby expresses imports, heritage (mixins), and property definitions as
* method calls rather than syntax-level constructs. This module provides a
* single routing function used by the CLI call-processor, CLI parse-worker,
* and the web call-processor so that the classification logic lives in one
* place.
*/
// ── Result types ────────────────────────────────────────────────────────────
export type RubyCallRouting =
| { kind: 'import'; importPath: string; isRelative: boolean }
| { kind: 'heritage'; items: RubyHeritageItem[] }
| { kind: 'properties'; items: RubyPropertyItem[] }
| { kind: 'call' }
| { kind: 'skip' };
export interface RubyHeritageItem {
enclosingClass: string;
mixinName: string;
}
export interface RubyPropertyItem {
propName: string;
accessorType: string;
startLine: number;
endLine: number;
}
// ── Routing function ────────────────────────────────────────────────────────
/**
* Classify a Ruby call node and extract its semantic payload.
*
* @param calledName - The method name (e.g. 'require', 'include', 'attr_accessor')
* @param callNode - The tree-sitter `call` AST node
* @returns A discriminated union describing the call's semantic role
*/
export function routeRubyCall(calledName: string, callNode: any): RubyCallRouting {
// ── require / require_relative → import ─────────────────────────────────
if (calledName === 'require' || calledName === 'require_relative') {
const argList = callNode.childForFieldName?.('arguments');
const stringNode = argList?.children?.find((c: any) => c.type === 'string');
const contentNode = stringNode?.children?.find((c: any) => c.type === 'string_content');
if (!contentNode) return { kind: 'skip' };
let importPath: string = contentNode.text;
const isRelative = calledName === 'require_relative';
if (isRelative && !importPath.startsWith('.')) {
importPath = './' + importPath;
}
return { kind: 'import', importPath, isRelative };
}
// ── include / extend / prepend → heritage (mixin) ──────────────────────
if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') {
let enclosingClass: string | null = null;
let current = callNode.parent;
while (current) {
if (current.type === 'class' || current.type === 'module') {
const nameNode = current.childForFieldName?.('name');
if (nameNode) { enclosingClass = nameNode.text; break; }
}
current = current.parent;
}
if (!enclosingClass) return { kind: 'skip' };
const items: RubyHeritageItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'constant' || arg.type === 'scope_resolution') {
items.push({ enclosingClass, mixinName: arg.text });
}
}
return items.length > 0 ? { kind: 'heritage', items } : { kind: 'skip' };
}
// ── attr_accessor / attr_reader / attr_writer → property definitions ───
if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') {
const items: RubyPropertyItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'simple_symbol') {
items.push({
propName: arg.text.replace(/^:/, ''),
accessorType: calledName,
startLine: arg.startPosition.row,
endLine: arg.endPosition.row,
});
}
}
return items.length > 0 ? { kind: 'properties', items } : { kind: 'skip' };
}
// ── Everything else → regular call ─────────────────────────────────────
return { kind: 'call' };
}
@@ -396,6 +396,40 @@ export const PHP_QUERIES = `
[(name) (qualified_name)] @heritage.trait))) @heritage
`;
// Ruby queries - works with tree-sitter-ruby
// NOTE: Ruby uses `call` for require, include, extend, prepend, attr_* etc.
// These are all captured as @call and routed in JS post-processing:
// - require/require_relative → import extraction
// - include/extend/prepend → heritage (mixin) extraction
// - attr_accessor/attr_reader/attr_writer → property definition extraction
// - everything else → regular call extraction
export const RUBY_QUERIES = `
; ── Modules ──────────────────────────────────────────────────────────────────
(module
name: (constant) @name) @definition.module
; ── Classes ──────────────────────────────────────────────────────────────────
(class
name: (constant) @name) @definition.class
; ── Instance methods ─────────────────────────────────────────────────────────
(method
name: (identifier) @name) @definition.method
; ── Singleton (class-level) methods ──────────────────────────────────────────
(singleton_method
name: (identifier) @name) @definition.function
; ── All calls (require, include, attr_*, and regular calls routed in JS) ─────
(call
method: (identifier) @call.name) @call
; ── Heritage: class < SuperClass ─────────────────────────────────────────────
(class
name: (constant) @heritage.class
superclass: (superclass
(constant) @heritage.extends)) @heritage`;
// Swift queries - works with tree-sitter-swift
export const SWIFT_QUERIES = `
; Classes
@@ -460,6 +494,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
[SupportedLanguages.Rust]: RUST_QUERIES,
[SupportedLanguages.PHP]: PHP_QUERIES,
[SupportedLanguages.Ruby]: RUBY_QUERIES,
[SupportedLanguages.Swift]: SWIFT_QUERIES,
};
+12
View File
@@ -1,5 +1,8 @@
import { SupportedLanguages } from '../../config/supported-languages';
/** Ruby extensionless filenames recognised as Ruby source */
const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile']);
/**
* Map file extension to SupportedLanguage enum
*/
@@ -31,6 +34,15 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
filename.endsWith('.php5') || filename.endsWith('.php8')) {
return SupportedLanguages.PHP;
}
// Ruby (extensions)
if (filename.endsWith('.rb') || filename.endsWith('.rake') || filename.endsWith('.gemspec')) {
return SupportedLanguages.Ruby;
}
// Ruby (extensionless files)
const basename = filename.split('/').pop() || filename;
if (RUBY_EXTENSIONLESS_FILES.has(basename)) {
return SupportedLanguages.Ruby;
}
// Swift
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
return null;
@@ -40,6 +40,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string =>
[SupportedLanguages.Go]: '/wasm/go/tree-sitter-go.wasm',
[SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm',
[SupportedLanguages.PHP]: '/wasm/php/tree-sitter-php.wasm',
[SupportedLanguages.Ruby]: '/wasm/ruby/tree-sitter-ruby.wasm',
[SupportedLanguages.Swift]: '/wasm/swift/tree-sitter-swift.wasm',
};
+2 -1
View File
@@ -157,7 +157,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby
### Language Feature Matrix
@@ -173,6 +173,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift
| Rust | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
| PHP | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Swift | — | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
| Ruby | ✓ | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
| C | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
| C++ | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
+33 -2
View File
@@ -31,9 +31,9 @@
"tree-sitter-go": "^0.21.0",
"tree-sitter-java": "^0.21.0",
"tree-sitter-javascript": "^0.21.0",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-php": "^0.23.12",
"tree-sitter-python": "^0.21.0",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "^0.21.0",
"tree-sitter-typescript": "^0.21.0",
"uuid": "^13.0.0"
@@ -56,6 +56,7 @@
"node": ">=18.0.0"
},
"optionalDependencies": {
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-swift": "^0.6.0"
}
},
@@ -5415,6 +5416,7 @@
"integrity": "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^7.1.0",
"node-gyp-build": "^4.8.0"
@@ -5432,7 +5434,8 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/tree-sitter-php": {
"version": "0.23.12",
@@ -5487,6 +5490,34 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/tree-sitter-ruby": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.23.1.tgz",
"integrity": "sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.2.2",
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-ruby/node_modules/node-addon-api": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz",
"integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/tree-sitter-rust": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.21.0.tgz",
+2
View File
@@ -72,11 +72,13 @@
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-php": "^0.23.12",
"tree-sitter-python": "^0.21.0",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "^0.21.0",
"tree-sitter-typescript": "^0.21.0",
"uuid": "^13.0.0"
},
"optionalDependencies": {
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
+1 -1
View File
@@ -7,9 +7,9 @@ export enum SupportedLanguages {
CPlusPlus = 'cpp',
CSharp = 'csharp',
Go = 'go',
Ruby = 'ruby',
Rust = 'rust',
PHP = 'php',
Kotlin = 'kotlin',
// Ruby = 'ruby',
Swift = 'swift',
}
+59 -5
View File
@@ -2,12 +2,13 @@ import { KnowledgeGraph } from '../graph/types.js';
import { ASTCache } from './ast-cache.js';
import type { SymbolDefinition, SymbolTable } from './symbol-table.js';
import { ImportMap, PackageMap, NamedImportMap, isFileInPackageDir } from './import-processor.js';
import { resolveSymbol, resolveSymbolInternal } from './symbol-resolver.js';
import { resolveSymbolInternal } from './symbol-resolver.js';
import { walkBindingChain } from './named-binding-extraction.js';
import Parser from 'tree-sitter';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import {
getLanguageFromFilename,
isVerboseIngestionEnabled,
@@ -21,7 +22,8 @@ import {
} from './utils.js';
import { buildTypeEnv, lookupTypeEnv } from './type-env.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedCall, ExtractedRoute } from './workers/parse-worker.js';
import type { ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js';
import { routeRubyCall } from './ruby-call-routing.js';
/**
* Walk up the AST from a node to find the enclosing function/method.
@@ -60,8 +62,9 @@ export const processCalls = async (
packageMap?: PackageMap,
onProgress?: (current: number, total: number) => void,
namedImportMap?: NamedImportMap,
) => {
): Promise<ExtractedHeritage[]> => {
const parser = await loadParser();
const collectedHeritage: ExtractedHeritage[] = [];
const logSkipped = isVerboseIngestionEnabled();
const skippedByLang = logSkipped ? new Map<string, number>() : null;
@@ -132,6 +135,55 @@ export const processCalls = async (
const calledName = nameNode.text;
// Ruby: route special calls to heritage or properties (imports handled by import-processor)
if (language === SupportedLanguages.Ruby) {
const callNode = captureMap['call'];
const routed = routeRubyCall(calledName, callNode);
switch (routed.kind) {
case 'skip':
case 'import': // handled by import-processor
return;
case 'heritage':
for (const item of routed.items) {
collectedHeritage.push({
filePath: file.path,
className: item.enclosingClass,
parentName: item.mixinName,
kind: 'trait-impl',
});
}
return;
case 'properties':
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
graph.addNode({
id: nodeId,
label: 'Property' as any,
properties: {
name: item.propName, filePath: file.path,
startLine: item.startLine, endLine: item.endLine,
language: SupportedLanguages.Ruby, isExported: true,
description: item.accessorType,
},
});
symbolTable.add(file.path, item.propName, nodeId, 'Property');
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId, sourceId: fileId, targetId: nodeId,
type: 'DEFINES', confidence: 1.0, reason: '',
});
}
return;
case 'call':
break; // fall through to normal call processing below
}
}
// Skip common built-ins and noise
if (isBuiltInOrNoise(calledName)) return;
@@ -152,10 +204,10 @@ export const processCalls = async (
// 5. Find the enclosing function (caller)
const enclosingFuncId = findEnclosingFunction(callNode, file.path, symbolTable);
// Use enclosing function as source, fallback to file for top-level calls
const sourceId = enclosingFuncId || generateId('File', file.path);
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
graph.addRelationship({
@@ -178,6 +230,8 @@ export const processCalls = async (
);
}
}
return collectedHeritage;
};
/**
@@ -14,7 +14,7 @@ import { detectFrameworkFromPath } from './framework-detection.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
// ============================================================================
// NAME PATTERNS - All 9 supported languages
// NAME PATTERNS - All 11 supported languages
// ============================================================================
/**
@@ -191,6 +191,13 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^save$/, // Repository::save()
/^delete$/, // Repository::delete()
],
// Ruby
'ruby': [
/^call$/, // Service objects (MyService.call)
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
/^execute$/, // Command pattern
],
};
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
@@ -361,7 +368,12 @@ export function isTestFile(filePath: string): boolean {
p.endsWith('test.php') ||
p.endsWith('spec.php') ||
p.includes('/tests/feature/') ||
p.includes('/tests/unit/')
p.includes('/tests/unit/') ||
// Ruby test patterns
p.endsWith('_spec.rb') ||
p.endsWith('_test.rb') ||
p.includes('/spec/') ||
p.includes('/test/fixtures/')
);
}
@@ -222,6 +222,7 @@ const exportCheckers = {
[SupportedLanguages.CPlusPlus]: cCppExportChecker,
[SupportedLanguages.PHP]: phpExportChecker,
[SupportedLanguages.Swift]: swiftExportChecker,
[SupportedLanguages.Ruby]: (_node, _name) => true,
} satisfies Record<SupportedLanguages, ExportChecker>;
// ============================================================================
@@ -330,6 +330,18 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
}
// ========== RUBY ==========
// Ruby: bin/ or exe/ (CLI entry points)
if ((p.includes('/bin/') || p.includes('/exe/')) && p.endsWith('.rb')) {
return { framework: 'ruby', entryPointMultiplier: 2.5, reason: 'ruby-executable' };
}
// Ruby: Rakefile or *.rake (task definitions)
if (p.endsWith('/rakefile') || p.endsWith('.rake')) {
return { framework: 'ruby', entryPointMultiplier: 1.5, reason: 'ruby-rake' };
}
// ========== SWIFT / iOS ==========
// iOS App entry points (highest priority)
@@ -30,6 +30,7 @@ import {
resolveCSharpNamespaceDir,
resolvePhpImport,
resolveRustImport,
extractRubyImportPath,
} from './resolvers/index.js';
import type {
SuffixIndex,
@@ -444,6 +445,24 @@ export const processImports = async (
const bindings = namedImportMap ? extractNamedBindings(captureMap['import'], language) : undefined;
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap);
}
// ---- Ruby: require/require_relative come through @call, not @import ----
if (language === SupportedLanguages.Ruby && captureMap['call']) {
const callNameNode = captureMap['call.name'];
if (callNameNode) {
const extracted = extractRubyImportPath(callNameNode.text, captureMap['call']);
if (extracted) {
totalImportsFound++;
const resolvedPath = resolveImportPath(
file.path, extracted.importPath, allFilePaths, allFileList,
normalizedFileList, resolveCache, language, configs.tsconfigPaths, index,
);
if (resolvedPath) {
addImportEdge(file.path, resolvedPath);
}
}
}
}
});
// Tree is now owned by the LRU cache — no manual delete needed
+4 -1
View File
@@ -273,8 +273,11 @@ export const runPipelineFromRepo = async (
.filter(p => chunkContents.has(p))
.map(p => ({ path: p, content: chunkContents.get(p)! }));
astCache = createASTCache(chunkFiles.length);
await processCalls(graph, chunkFiles, astCache, symbolTable, importMap, packageMap, undefined, namedImportMap);
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, symbolTable, importMap, packageMap, undefined, namedImportMap);
await processHeritage(graph, chunkFiles, astCache, symbolTable, importMap, packageMap);
if (rubyHeritage.length > 0) {
await processHeritageFromExtracted(graph, rubyHeritage, symbolTable, importMap, packageMap);
}
astCache.clear();
}
@@ -19,5 +19,8 @@ export type { ComposerConfig } from './php.js';
export { resolveRustImport, tryRustModulePath } from './rust.js';
export { resolveRubyImport, extractRubyImportPath } from './ruby.js';
export type { GemfileConfig } from './ruby.js';
export { resolveImportPath, RESOLVE_CACHE_CAP } from './standard.js';
export type { TsconfigPaths } from './standard.js';
@@ -0,0 +1,54 @@
/**
* Ruby require/require_relative import resolution.
* Handles path resolution for Ruby's require and require_relative calls.
*/
import type { SuffixIndex } from './utils.js';
import { suffixResolve } from './utils.js';
/** Bundler config parsed from Gemfile (future: gem path resolution) */
export interface GemfileConfig {
gemPaths: Map<string, string>;
}
/**
* Resolve a Ruby require/require_relative path to a matching .rb file.
*
* require_relative paths are pre-normalized to './' prefix by the caller.
* require paths use suffix matching (gem-style paths like 'json', 'net/http').
*/
export function resolveRubyImport(
sourceFile: string,
importPath: string,
isRelative: boolean,
normalizedFileList: string[],
allFileList: string[],
index?: SuffixIndex,
): string | null {
const pathParts = importPath.replace(/^\.\//, '').split('/').filter(Boolean);
return suffixResolve(pathParts, normalizedFileList, allFileList, index);
}
/**
* Extract the import path string from a Ruby call AST node.
* Returns null if the call is not a require/require_relative.
*/
export function extractRubyImportPath(
calledName: string,
callNode: any,
): { importPath: string; isRelative: boolean } | null {
if (calledName !== 'require' && calledName !== 'require_relative') return null;
const argList = callNode.childForFieldName?.('arguments');
const stringNode = argList?.children?.find((c: any) => c.type === 'string');
const contentNode = stringNode?.children?.find((c: any) => c.type === 'string_content');
if (!contentNode) return null;
let importPath = contentNode.text;
const isRelative = calledName === 'require_relative';
if (isRelative && !importPath.startsWith('.')) {
importPath = './' + importPath;
}
return { importPath, isRelative };
}
@@ -26,6 +26,8 @@ export const EXTENSIONS = [
'.php', '.phtml',
// Swift
'.swift',
// Ruby
'.rb',
];
/**
@@ -0,0 +1,99 @@
/**
* Shared Ruby call routing logic.
*
* Ruby expresses imports, heritage (mixins), and property definitions as
* method calls rather than syntax-level constructs. This module provides a
* single routing function used by the CLI call-processor, CLI parse-worker,
* and the web call-processor so that the classification logic lives in one
* place.
*/
// ── Result types ────────────────────────────────────────────────────────────
export type RubyCallRouting =
| { kind: 'import'; importPath: string; isRelative: boolean }
| { kind: 'heritage'; items: RubyHeritageItem[] }
| { kind: 'properties'; items: RubyPropertyItem[] }
| { kind: 'call' }
| { kind: 'skip' };
export interface RubyHeritageItem {
enclosingClass: string;
mixinName: string;
}
export interface RubyPropertyItem {
propName: string;
accessorType: string;
startLine: number;
endLine: number;
}
// ── Routing function ────────────────────────────────────────────────────────
/**
* Classify a Ruby call node and extract its semantic payload.
*
* @param calledName - The method name (e.g. 'require', 'include', 'attr_accessor')
* @param callNode - The tree-sitter `call` AST node
* @returns A discriminated union describing the call's semantic role
*/
export function routeRubyCall(calledName: string, callNode: any): RubyCallRouting {
// ── require / require_relative → import ─────────────────────────────────
if (calledName === 'require' || calledName === 'require_relative') {
const argList = callNode.childForFieldName?.('arguments');
const stringNode = argList?.children?.find((c: any) => c.type === 'string');
const contentNode = stringNode?.children?.find((c: any) => c.type === 'string_content');
if (!contentNode) return { kind: 'skip' };
let importPath: string = contentNode.text;
const isRelative = calledName === 'require_relative';
if (isRelative && !importPath.startsWith('.')) {
importPath = './' + importPath;
}
return { kind: 'import', importPath, isRelative };
}
// ── include / extend / prepend → heritage (mixin) ──────────────────────
if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') {
let enclosingClass: string | null = null;
let current = callNode.parent;
while (current) {
if (current.type === 'class' || current.type === 'module') {
const nameNode = current.childForFieldName?.('name');
if (nameNode) { enclosingClass = nameNode.text; break; }
}
current = current.parent;
}
if (!enclosingClass) return { kind: 'skip' };
const items: RubyHeritageItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'constant' || arg.type === 'scope_resolution') {
items.push({ enclosingClass, mixinName: arg.text });
}
}
return items.length > 0 ? { kind: 'heritage', items } : { kind: 'skip' };
}
// ── attr_accessor / attr_reader / attr_writer → property definitions ───
if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') {
const items: RubyPropertyItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'simple_symbol') {
items.push({
propName: arg.text.replace(/^:/, ''),
accessorType: calledName,
startLine: arg.startPosition.row,
endLine: arg.endPosition.row,
});
}
}
return items.length > 0 ? { kind: 'properties', items } : { kind: 'skip' };
}
// ── Everything else → regular call ─────────────────────────────────────
return { kind: 'call' };
}
@@ -496,6 +496,41 @@ export const PHP_QUERIES = `
[(name) (qualified_name)] @heritage.trait))) @heritage
`;
// Ruby queries - works with tree-sitter-ruby
// NOTE: Ruby uses `call` for require, include, extend, prepend, attr_* etc.
// These are all captured as @call and routed in JS post-processing:
// - require/require_relative → import extraction
// - include/extend/prepend → heritage (mixin) extraction
// - attr_accessor/attr_reader/attr_writer → property definition extraction
// - everything else → regular call extraction
export const RUBY_QUERIES = `
; ── Modules ──────────────────────────────────────────────────────────────────
(module
name: (constant) @name) @definition.module
; ── Classes ──────────────────────────────────────────────────────────────────
(class
name: (constant) @name) @definition.class
; ── Instance methods ─────────────────────────────────────────────────────────
(method
name: (identifier) @name) @definition.method
; ── Singleton (class-level) methods ──────────────────────────────────────────
(singleton_method
name: (identifier) @name) @definition.function
; ── All calls (require, include, attr_*, and regular calls routed in JS) ─────
(call
method: (identifier) @call.name) @call
; ── Heritage: class < SuperClass ─────────────────────────────────────────────
(class
name: (constant) @heritage.class
superclass: (superclass
(constant) @heritage.extends)) @heritage
`;
// Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin)
// Based on official tags.scm; functions use simple_identifier, classes use type_identifier
export const KOTLIN_QUERIES = `
@@ -643,6 +678,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Go]: GO_QUERIES,
[SupportedLanguages.CPlusPlus]: CPP_QUERIES,
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
[SupportedLanguages.Ruby]: RUBY_QUERIES,
[SupportedLanguages.Rust]: RUST_QUERIES,
[SupportedLanguages.PHP]: PHP_QUERIES,
[SupportedLanguages.Kotlin]: KOTLIN_QUERIES,
@@ -29,6 +29,11 @@ export const typeConfigs = {
[SupportedLanguages.C]: cCppConfig,
[SupportedLanguages.CPlusPlus]: cCppConfig,
[SupportedLanguages.PHP]: phpConfig,
[SupportedLanguages.Ruby]: {
declarationNodeTypes: new Set<string>(),
extractDeclaration: () => {},
extractParameter: () => {},
},
} satisfies Record<SupportedLanguages, LanguageTypeConfig>;
export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor } from './types.js';
+13
View File
@@ -417,6 +417,9 @@ export const extractFunctionName = (node: any): { funcName: string | null; label
*/
export const yieldToEventLoop = (): Promise<void> => new Promise(resolve => setImmediate(resolve));
/** Ruby extensionless filenames recognised as Ruby source */
const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile']);
/**
* Find a child of `childType` within a sibling node of `siblingType`.
* Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling.
@@ -469,6 +472,16 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
filename.endsWith('.php5') || filename.endsWith('.php8')) {
return SupportedLanguages.PHP;
}
// Ruby (extensions)
if (filename.endsWith('.rb') || filename.endsWith('.rake') || filename.endsWith('.gemspec')) {
return SupportedLanguages.Ruby;
}
// Ruby (extensionless files)
const basename = filename.split('/').pop() || filename;
if (RUBY_EXTENSIONLESS_FILES.has(basename)) {
return SupportedLanguages.Ruby;
}
// Swift (extensions)
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
return null;
};
@@ -11,6 +11,7 @@ import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import Kotlin from 'tree-sitter-kotlin';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import { createRequire } from 'node:module';
import { SupportedLanguages } from '../../../config/supported-languages.js';
import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js';
@@ -38,6 +39,7 @@ import { detectFrameworkFromAST } from '../framework-detection.js';
import { generateId } from '../../../lib/utils.js';
import { extractNamedBindings } from '../named-binding-extraction.js';
import { appendKotlinWildcard } from '../resolvers/index.js';
import { routeRubyCall } from '../ruby-call-routing.js';
// ============================================================================
// Types for serializable results
@@ -155,6 +157,7 @@ const languageMap: Record<string, any> = {
[SupportedLanguages.Rust]: Rust,
[SupportedLanguages.Kotlin]: Kotlin,
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
};
@@ -858,6 +861,91 @@ const processFileGroup = (
const callNameNode = captureMap['call.name'];
if (callNameNode) {
const calledName = callNameNode.text;
// Ruby: route special calls to imports, heritage, or properties
if (language === SupportedLanguages.Ruby) {
const callNode = captureMap['call'];
const routed = routeRubyCall(calledName, callNode);
switch (routed.kind) {
case 'skip':
continue;
case 'import':
result.imports.push({
filePath: file.path,
rawImportPath: routed.importPath,
language,
});
continue;
case 'heritage':
for (const item of routed.items) {
result.heritage.push({
filePath: file.path,
className: item.enclosingClass,
parentName: item.mixinName,
kind: 'trait-impl',
});
}
continue;
case 'properties':
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
result.nodes.push({
id: nodeId,
label: 'Property',
properties: {
name: item.propName,
filePath: file.path,
startLine: item.startLine,
endLine: item.endLine,
language,
isExported: true,
description: item.accessorType,
},
});
result.symbols.push({
filePath: file.path,
name: item.propName,
nodeId,
type: 'Property',
});
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
result.relationships.push({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
}
continue;
case 'call':
if (!isBuiltInOrNoise(calledName)) {
const sourceId = findEnclosingFunctionId(callNode, file.path)
|| generateId('File', file.path);
const callForm = inferCallForm(callNode, callNameNode);
const receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined;
const receiverTypeName = receiverName ? lookupTypeEnv(typeEnv, receiverName, callNode) : undefined;
result.calls.push({
filePath: file.path,
calledName,
sourceId,
argCount: countCallArguments(callNode),
...(callForm !== undefined ? { callForm } : {}),
...(receiverName !== undefined ? { receiverName } : {}),
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
});
}
continue;
}
}
if (!isBuiltInOrNoise(calledName)) {
const callNode = captureMap['call'];
const sourceId = findEnclosingFunctionId(callNode, file.path)
@@ -10,6 +10,7 @@ import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import Kotlin from 'tree-sitter-kotlin';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import { createRequire } from 'node:module';
import { SupportedLanguages } from '../../config/supported-languages.js';
@@ -33,6 +34,7 @@ const languageMap: Record<string, any> = {
[SupportedLanguages.Rust]: Rust,
[SupportedLanguages.Kotlin]: Kotlin,
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
};
+1
View File
@@ -32,6 +32,7 @@ export function isTestFilePath(filePath: string): boolean {
p.includes('/test/') || p.includes('/tests/') ||
p.includes('/testing/') || p.includes('/fixtures/') ||
p.endsWith('_test.go') || p.endsWith('_test.py') ||
p.endsWith('_spec.rb') || p.endsWith('_test.rb') || p.includes('/spec/') ||
p.includes('/test_') || p.includes('/conftest.')
);
}
@@ -0,0 +1,5 @@
class Handler
def handle
# handle request
end
end
@@ -0,0 +1,5 @@
class Handler
def process
# process request
end
end
@@ -0,0 +1,7 @@
require_relative '../handlers/a/handler'
class Processor < Handler
def run
handle
end
end
@@ -0,0 +1,9 @@
class Person
attr_accessor :name, :age
attr_reader :id
attr_writer :email
def greet
"Hello, #{@name}"
end
end
@@ -0,0 +1,3 @@
def write_audit(message)
message
end
@@ -0,0 +1,6 @@
require_relative 'one'
require_relative 'zero'
def run
write_audit('hello')
end
@@ -0,0 +1,3 @@
def write_audit
'default'
end
@@ -0,0 +1,10 @@
require_relative 'utils'
def save(data)
# local save shadows utils#save
data.to_s
end
def run
save('test')
end
@@ -0,0 +1,4 @@
def save(data)
# remote save
data
end
@@ -0,0 +1,6 @@
require_relative 'user'
def process_user
user = User.new
user.save
end
@@ -0,0 +1,5 @@
class User
def save
true
end
end
@@ -0,0 +1,5 @@
module Auditable
def audit
# log changes
end
end
@@ -0,0 +1,5 @@
module Cacheable
def cached
# cache result
end
end
@@ -0,0 +1,5 @@
module Hookable
def hooks
# lifecycle hooks
end
end
@@ -0,0 +1,9 @@
require_relative '../concerns/auditable'
require_relative '../concerns/cacheable'
require_relative '../concerns/hookable'
class Record
include Auditable
extend Cacheable
prepend Hookable
end
@@ -0,0 +1,9 @@
class BaseModel
def save
# persist
end
def validate
# validate
end
end
@@ -0,0 +1,5 @@
module Serializable
def serialize
to_s
end
end
@@ -0,0 +1,10 @@
require_relative 'base_model'
require_relative 'serializable'
class User < BaseModel
include Serializable
def get_name
@name
end
end
@@ -0,0 +1,8 @@
require_relative '../lib/user'
class AuthService
def authenticate(user)
user.validate
true
end
end
@@ -0,0 +1,6 @@
require_relative '../lib/base_model'
def process_model(model)
model.validate
model.save
end
@@ -0,0 +1,9 @@
require_relative 'user'
require_relative 'repo'
def process_entities
user = User.new
repo = Repo.new
user.save
repo.save
end
@@ -0,0 +1,5 @@
class Repo
def save
true
end
end
@@ -0,0 +1,5 @@
class User
def save
true
end
end
+2 -2
View File
@@ -742,8 +742,8 @@ describe('parsing', () => {
it('falls back gracefully for unsupported language', async () => {
// getLanguageFromFilename returns null for extensions with no grammar mapping.
const rubyLang = getLanguageFromFilename('script.rb');
expect(rubyLang).toBeNull();
const scalaLang = getLanguageFromFilename('Main.scala');
expect(scalaLang).toBeNull();
const luaLang = getLanguageFromFilename('module.lua');
expect(luaLang).toBeNull();
@@ -0,0 +1,354 @@
/**
* Ruby: require/require_relative imports + class inheritance + mixin heritage +
* attr_* properties + arity-filtered call resolution + member-call resolution
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
// ---------------------------------------------------------------------------
// Package: require_relative imports + class inheritance + module include
// ---------------------------------------------------------------------------
describe('Ruby require_relative imports and class hierarchy', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-pkg'),
() => {},
);
}, 60000);
it('detects exactly 3 classes and 1 module', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['AuthService', 'BaseModel', 'User']);
expect(getNodesByLabel(result, 'Module')).toEqual(['Serializable']);
});
it('detects all 6 methods', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('save');
expect(methods).toContain('validate');
expect(methods).toContain('serialize');
expect(methods).toContain('get_name');
expect(methods).toContain('authenticate');
expect(methods).toContain('process_model');
});
it('emits exactly 1 EXTENDS edge: User → BaseModel', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('User');
expect(extends_[0].target).toBe('BaseModel');
expect(extends_[0].targetFilePath).toBe('lib/base_model.rb');
});
it('emits exactly 1 IMPLEMENTS edge: User → Serializable (include)', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(1);
expect(implements_[0].source).toBe('User');
expect(implements_[0].target).toBe('Serializable');
});
it('resolves all 4 require_relative imports', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(4);
expect(edgeSet(imports)).toEqual([
'auth_service.rb → user.rb',
'helpers.rb → base_model.rb',
'user.rb → base_model.rb',
'user.rb → serializable.rb',
]);
});
it('no OVERRIDES edges target Property nodes', () => {
const overrides = getRelationships(result, 'OVERRIDES');
for (const edge of overrides) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.label).not.toBe('Property');
}
});
});
// ---------------------------------------------------------------------------
// Ambiguous: two files with same class name, require_relative disambiguates
// ---------------------------------------------------------------------------
describe('Ruby ambiguous symbol resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-ambiguous'),
() => {},
);
}, 60000);
it('detects 2 Handler classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes.filter(n => n === 'Handler').length).toBe(2);
expect(classes).toContain('Processor');
});
it('resolves EXTENDS to handlers/a/handler.rb (not handlers/b/handler.rb)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('Processor');
expect(extends_[0].target).toBe('Handler');
expect(extends_[0].targetFilePath).toBe('handlers/a/handler.rb');
});
it('require_relative resolves to handlers/a/handler.rb', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].targetFilePath).toBe('handlers/a/handler.rb');
});
it('all heritage edges point to real graph nodes', () => {
for (const edge of getRelationships(result, 'EXTENDS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.properties.name).toBe(edge.target);
}
});
});
// ---------------------------------------------------------------------------
// Call resolution with arity filtering
// ---------------------------------------------------------------------------
describe('Ruby call resolution with arity filtering', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-calls'),
() => {},
);
}, 60000);
it('resolves run → write_audit to one.rb via arity narrowing', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.length).toBe(1);
expect(calls[0].source).toBe('run');
expect(calls[0].target).toBe('write_audit');
expect(calls[0].targetFilePath).toBe('one.rb');
expect(calls[0].rel.reason).toBe('import-resolved');
});
});
// ---------------------------------------------------------------------------
// Member-call resolution: obj.method() resolves through pipeline
// ---------------------------------------------------------------------------
describe('Ruby member-call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-member-calls'),
() => {},
);
}, 60000);
it('detects User class (Class node) and save method (Method node)', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
// Ruby instance methods are Method nodes
expect(getNodesByLabel(result, 'Method')).toContain('save');
});
it('emits HAS_METHOD edge from User to save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
expect(edge).toBeDefined();
});
it('resolves process_user → save as a member call on User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process_user');
expect(saveCall!.targetFilePath).toBe('user.rb');
});
it('emits 1 require_relative IMPORTS edge', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].source).toBe('app.rb');
expect(imports[0].targetFilePath).toBe('user.rb');
});
});
// ---------------------------------------------------------------------------
// Mixin heritage: include / extend / prepend all produce IMPLEMENTS edges
// ---------------------------------------------------------------------------
describe('Ruby mixin heritage (include / extend / prepend)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-mixin-heritage'),
() => {},
);
}, 60000);
it('detects 1 class and 3 modules', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Record']);
expect(getNodesByLabel(result, 'Module')).toEqual(['Auditable', 'Cacheable', 'Hookable']);
});
it('emits exactly 3 IMPLEMENTS edges for all three mixin types', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(3);
expect(edgeSet(implements_)).toEqual([
'Record → Auditable',
'Record → Cacheable',
'Record → Hookable',
]);
});
it('emits no EXTENDS edges (mixins are not inheritance)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(0);
});
it('resolves 3 require_relative IMPORTS edges', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(3);
expect(edgeSet(imports)).toEqual([
'record.rb → auditable.rb',
'record.rb → cacheable.rb',
'record.rb → hookable.rb',
]);
});
it('all IMPLEMENTS targets are real graph nodes (Module label)', () => {
for (const edge of getRelationships(result, 'IMPLEMENTS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.label).toBe('Module');
}
});
});
// ---------------------------------------------------------------------------
// Property extraction: attr_accessor / attr_reader / attr_writer
// ---------------------------------------------------------------------------
describe('Ruby attr_* property extraction', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-attr-properties'),
() => {},
);
}, 60000);
it('detects Person class and greet method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Person');
expect(getNodesByLabel(result, 'Method')).toContain('greet');
});
it('creates 4 Property nodes for attr_accessor, attr_reader, attr_writer', () => {
const props = getNodesByLabel(result, 'Property');
expect(props).toEqual(['age', 'email', 'id', 'name']);
});
it('all Property nodes have filePath pointing to person.rb', () => {
result.graph.forEachNode(n => {
if (n.label === 'Property') {
expect(n.properties.filePath).toBe('models/person.rb');
}
});
});
it('Property nodes carry accessorType in description field', () => {
const accessorTypes = new Map<string, string>();
result.graph.forEachNode(n => {
if (n.label === 'Property') {
accessorTypes.set(n.properties.name as string, n.properties.description as string);
}
});
expect(accessorTypes.get('name')).toBe('attr_accessor');
expect(accessorTypes.get('age')).toBe('attr_accessor');
expect(accessorTypes.get('id')).toBe('attr_reader');
expect(accessorTypes.get('email')).toBe('attr_writer');
});
});
// ---------------------------------------------------------------------------
// Receiver-constrained resolution: two classes with same method name
// ---------------------------------------------------------------------------
describe('Ruby receiver-constrained resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-receiver-resolution'),
() => {},
);
}, 60000);
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('emits HAS_METHOD edges for both User.save and Repo.save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const userSave = hasMethod.find(e => e.source === 'User' && e.target === 'save');
const repoSave = hasMethod.find(e => e.source === 'Repo' && e.target === 'save');
expect(userSave).toBeDefined();
expect(repoSave).toBeDefined();
});
it('resolves at least one save call from process_entities', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'process_entities');
expect(saveCalls.length).toBeGreaterThanOrEqual(1);
});
it('all heritage edges point to real graph nodes', () => {
for (const edge of getRelationships(result, 'EXTENDS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
}
});
});
// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over require'd name
// ---------------------------------------------------------------------------
describe('Ruby local definition shadows require', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-local-shadow'),
() => {},
);
}, 60000);
it('resolves run → save to same-file definition in main.rb, not utils.rb', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'save' && c.source === 'run');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('src/main.rb');
});
it('does NOT resolve save to utils.rb', () => {
const calls = getRelationships(result, 'CALLS');
const saveToUtils = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/utils.rb');
expect(saveToUtils).toBeUndefined();
});
});
+18 -1
View File
@@ -93,6 +93,23 @@ describe('getLanguageFromFilename', () => {
});
});
describe('Ruby', () => {
it.each(['.rb', '.rake', '.gemspec'])(
'detects %s files',
(ext) => {
expect(getLanguageFromFilename(`file${ext}`)).toBe(SupportedLanguages.Ruby);
}
);
it('detects extensionless Rakefile', () => {
expect(getLanguageFromFilename('Rakefile')).toBe(SupportedLanguages.Ruby);
});
it('detects extensionless Gemfile', () => {
expect(getLanguageFromFilename('Gemfile')).toBe(SupportedLanguages.Ruby);
});
});
describe('Kotlin', () => {
it.each(['.kt', '.kts'])(
'detects %s files',
@@ -103,7 +120,7 @@ describe('getLanguageFromFilename', () => {
});
describe('unsupported', () => {
it.each(['.rb', '.scala', '.r', '.lua', '.zig', '.txt', '.md', '.json', '.yaml'])(
it.each(['.scala', '.r', '.lua', '.zig', '.txt', '.md', '.json', '.yaml'])(
'returns null for %s files',
(ext) => {
expect(getLanguageFromFilename(`file${ext}`)).toBeNull();
+5 -1
View File
@@ -67,8 +67,12 @@ describe('parser-loader', () => {
await expect(loadLanguage(SupportedLanguages.TypeScript, 'utils.ts')).resolves.not.toThrow();
});
it('loads Ruby language', async () => {
await expect(loadLanguage(SupportedLanguages.Ruby)).resolves.not.toThrow();
});
it('throws for unsupported language', async () => {
await expect(loadLanguage('ruby' as SupportedLanguages)).rejects.toThrow('Unsupported language');
await expect(loadLanguage('erlang' as SupportedLanguages)).rejects.toThrow('Unsupported language');
});
});
@@ -76,7 +76,7 @@ describe('sequential native parser availability', () => {
createASTCache(),
createSymbolTable(),
createImportMap(),
)).resolves.toBeUndefined();
)).resolves.toEqual([]);
expect(parserLoader.loadLanguage).not.toHaveBeenCalled();
});