mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
feat(ruby): Add Ruby language support for CLI and web (#111)
This commit is contained in:
parent
dab925d44b
commit
a2ecfbfb80
37 changed files with 886 additions and 69 deletions
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
BIN
gitnexus-web/public/wasm/ruby/tree-sitter-ruby.wasm
Normal file
BIN
gitnexus-web/public/wasm/ruby/tree-sitter-ruby.wasm
Normal file
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',
|
||||
}
|
||||
|
|
@ -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,62 @@ 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': {
|
||||
const fileId = generateId('File', file.path);
|
||||
for (const item of routed.items) {
|
||||
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
|
||||
graph.addNode({
|
||||
id: nodeId,
|
||||
label: 'Property' as any, // TODO: add 'Property' to graph node label union
|
||||
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 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 +273,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 +784,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)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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 { routeRubyCall } from './ruby-call-routing';
|
||||
|
||||
// Type: Map<FilePath, Set<ResolvedFilePath>>
|
||||
// Stores all files that a given file imports from
|
||||
|
|
@ -53,7 +54,9 @@ const resolveImportPath = (
|
|||
// Go
|
||||
'.go',
|
||||
// Rust
|
||||
'.rs', '/mod.rs'
|
||||
'.rs', '/mod.rs',
|
||||
// Ruby
|
||||
'.rb', '.rake',
|
||||
];
|
||||
|
||||
if (importPath.startsWith('.')) {
|
||||
|
|
@ -220,6 +223,34 @@ 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 routed = routeRubyCall(callNameNode.text, captureMap['call']);
|
||||
if (routed.kind === 'import') {
|
||||
totalImportsFound++;
|
||||
const resolvedPath = resolveImportPath(
|
||||
file.path, routed.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;
|
||||
}
|
||||
|
|
|
|||
116
gitnexus-web/src/core/ingestion/ruby-call-routing.ts
Normal file
116
gitnexus-web/src/core/ingestion/ruby-call-routing.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* NOTE: This file is intentionally duplicated in gitnexus-web/ because the
|
||||
* two packages have separate build targets (Node native vs WASM/browser).
|
||||
* Keep both copies in sync until a shared package is introduced.
|
||||
*/
|
||||
|
||||
// ── 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 type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer';
|
||||
|
||||
export interface RubyPropertyItem {
|
||||
propName: string;
|
||||
accessorType: RubyAccessorType;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
// ── Pre-allocated singletons for common return values ────────────────────────
|
||||
const CALL_RESULT: RubyCallRouting = { kind: 'call' };
|
||||
const SKIP_RESULT: RubyCallRouting = { kind: 'skip' };
|
||||
|
||||
/** Max depth for parent-walking loops to prevent pathological AST traversals */
|
||||
const MAX_PARENT_DEPTH = 50;
|
||||
|
||||
// ── 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 SKIP_RESULT;
|
||||
|
||||
let importPath: string = contentNode.text;
|
||||
// Validate: reject null bytes, control chars, excessively long paths
|
||||
if (!importPath || importPath.length > 1024 || /[\x00-\x1f]/.test(importPath)) {
|
||||
return SKIP_RESULT;
|
||||
}
|
||||
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;
|
||||
let depth = 0;
|
||||
while (current && ++depth <= MAX_PARENT_DEPTH) {
|
||||
if (current.type === 'class' || current.type === 'module') {
|
||||
const nameNode = current.childForFieldName?.('name');
|
||||
if (nameNode) { enclosingClass = nameNode.text; break; }
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
if (!enclosingClass) return SKIP_RESULT;
|
||||
|
||||
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 } : SKIP_RESULT;
|
||||
}
|
||||
|
||||
// ── 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.startsWith(':') ? arg.text.slice(1) : arg.text,
|
||||
accessorType: calledName as RubyAccessorType,
|
||||
startLine: arg.startPosition.row,
|
||||
endLine: arg.endPosition.row,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.length > 0 ? { kind: 'properties', items } : SKIP_RESULT;
|
||||
}
|
||||
|
||||
// ── Everything else → regular call ─────────────────────────────────────
|
||||
return CALL_RESULT;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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++ | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
|
||||
|
||||
|
|
|
|||
35
gitnexus/package-lock.json
generated
35
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -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,56 @@ 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': {
|
||||
const fileId = generateId('File', file.path);
|
||||
for (const item of routed.items) {
|
||||
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
|
||||
graph.addNode({
|
||||
id: nodeId,
|
||||
label: 'Property' as any, // TODO: add 'Property' to graph node label union
|
||||
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 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 +205,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 +231,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
|
||||
[SupportedLanguages.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)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
resolvePhpImport,
|
||||
resolveRustImport,
|
||||
} from './resolvers/index.js';
|
||||
import { routeRubyCall } from './ruby-call-routing.js';
|
||||
import type {
|
||||
SuffixIndex,
|
||||
TsconfigPaths,
|
||||
|
|
@ -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 routed = routeRubyCall(callNameNode.text, captureMap['call']);
|
||||
if (routed.kind === 'import') {
|
||||
totalImportsFound++;
|
||||
const resolvedPath = resolveImportPath(
|
||||
file.path, routed.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
|
||||
|
|
|
|||
|
|
@ -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,7 @@ export type { ComposerConfig } from './php.js';
|
|||
|
||||
export { resolveRustImport, tryRustModulePath } from './rust.js';
|
||||
|
||||
export { resolveRubyImport } from './ruby.js';
|
||||
|
||||
export { resolveImportPath, RESOLVE_CACHE_CAP } from './standard.js';
|
||||
export type { TsconfigPaths } from './standard.js';
|
||||
|
|
|
|||
23
gitnexus/src/core/ingestion/resolvers/ruby.ts
Normal file
23
gitnexus/src/core/ingestion/resolvers/ruby.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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(
|
||||
importPath: string,
|
||||
normalizedFileList: string[],
|
||||
allFileList: string[],
|
||||
index?: SuffixIndex,
|
||||
): string | null {
|
||||
const pathParts = importPath.replace(/^\.\//, '').split('/').filter(Boolean);
|
||||
return suffixResolve(pathParts, normalizedFileList, allFileList, index);
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ export const EXTENSIONS = [
|
|||
'.php', '.phtml',
|
||||
// Swift
|
||||
'.swift',
|
||||
// Ruby
|
||||
'.rb',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
116
gitnexus/src/core/ingestion/ruby-call-routing.ts
Normal file
116
gitnexus/src/core/ingestion/ruby-call-routing.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* NOTE: This file is intentionally duplicated in gitnexus-web/ because the
|
||||
* two packages have separate build targets (Node native vs WASM/browser).
|
||||
* Keep both copies in sync until a shared package is introduced.
|
||||
*/
|
||||
|
||||
// ── 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 type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer';
|
||||
|
||||
export interface RubyPropertyItem {
|
||||
propName: string;
|
||||
accessorType: RubyAccessorType;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
// ── Pre-allocated singletons for common return values ────────────────────────
|
||||
const CALL_RESULT: RubyCallRouting = { kind: 'call' };
|
||||
const SKIP_RESULT: RubyCallRouting = { kind: 'skip' };
|
||||
|
||||
/** Max depth for parent-walking loops to prevent pathological AST traversals */
|
||||
const MAX_PARENT_DEPTH = 50;
|
||||
|
||||
// ── 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 SKIP_RESULT;
|
||||
|
||||
let importPath: string = contentNode.text;
|
||||
// Validate: reject null bytes, control chars, excessively long paths
|
||||
if (!importPath || importPath.length > 1024 || /[\x00-\x1f]/.test(importPath)) {
|
||||
return SKIP_RESULT;
|
||||
}
|
||||
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;
|
||||
let depth = 0;
|
||||
while (current && ++depth <= MAX_PARENT_DEPTH) {
|
||||
if (current.type === 'class' || current.type === 'module') {
|
||||
const nameNode = current.childForFieldName?.('name');
|
||||
if (nameNode) { enclosingClass = nameNode.text; break; }
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
if (!enclosingClass) return SKIP_RESULT;
|
||||
|
||||
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 } : SKIP_RESULT;
|
||||
}
|
||||
|
||||
// ── 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.startsWith(':') ? arg.text.slice(1) : arg.text,
|
||||
accessorType: calledName as RubyAccessorType,
|
||||
startLine: arg.startPosition.row,
|
||||
endLine: arg.endPosition.row,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.length > 0 ? { kind: 'properties', items } : SKIP_RESULT;
|
||||
}
|
||||
|
||||
// ── Everything else → regular call ─────────────────────────────────────
|
||||
return CALL_RESULT;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -236,6 +236,21 @@ export const BUILT_IN_NAMES = new Set([
|
|||
'lock', 'read', 'write', 'try_lock',
|
||||
'spawn', 'join', 'sleep',
|
||||
'Some', 'None', 'Ok', 'Err',
|
||||
// Ruby built-ins and Kernel methods
|
||||
'puts', 'p', 'pp', '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', 'tap', 'yield_self',
|
||||
// Ruby enumerables
|
||||
'each', 'select', 'reject', 'detect', 'collect',
|
||||
'inject', 'flat_map', 'each_with_object', 'each_with_index',
|
||||
'any?', 'all?', 'none?', 'count', 'first', 'last',
|
||||
'sort_by', 'min_by', 'max_by',
|
||||
'group_by', 'partition', 'compact', 'flatten', 'uniq',
|
||||
]);
|
||||
|
||||
/** Check if a name is a built-in function or common noise that should be filtered out */
|
||||
|
|
@ -417,6 +432,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 +487,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 } : {}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue