UNPKG

lsh-framework

Version:

A powerful, extensible shell with advanced job management, database persistence, and modern CLI features

345 lines (344 loc) 12.7 kB
/** * Tab Completion System Implementation * Provides ZSH-compatible completion functionality */ import * as fs from 'fs'; import * as path from 'path'; export class CompletionSystem { completionFunctions = new Map(); defaultCompletions = []; isEnabled = true; constructor() { this.setupDefaultCompletions(); } /** * Register a completion function for a specific command */ registerCompletion(command, func) { this.completionFunctions.set(command, func); } /** * Register a default completion function */ registerDefaultCompletion(func) { this.defaultCompletions.push(func); } /** * Get completions for the current context */ async getCompletions(context) { if (!this.isEnabled) return []; const candidates = []; // Try command-specific completion first const commandFunc = this.completionFunctions.get(context.command); if (commandFunc) { try { const commandCompletions = await commandFunc(context); candidates.push(...commandCompletions); } catch (_error) { // Continue with default completions if command-specific fails } } // If no command-specific completions, try default completions if (candidates.length === 0) { for (const defaultFunc of this.defaultCompletions) { try { const defaultCompletions = await defaultFunc(context); candidates.push(...defaultCompletions); } catch (_error) { // Continue with other default completions } } } // Filter and sort candidates return this.filterAndSortCandidates(candidates, context.currentWord); } /** * Enable/disable completion */ setEnabled(enabled) { this.isEnabled = enabled; } /** * Setup default completion functions */ setupDefaultCompletions() { // File and directory completion this.registerDefaultCompletion(async (context) => { return this.completeFilesAndDirectories(context); }); // Command completion this.registerDefaultCompletion(async (context) => { if (context.wordIndex === 0) { return this.completeCommands(context); } return []; }); // Variable completion this.registerDefaultCompletion(async (context) => { if (context.currentWord.startsWith('$')) { return this.completeVariables(context); } return []; }); // Built-in command completions this.setupBuiltinCompletions(); } /** * Complete files and directories */ async completeFilesAndDirectories(context) { const candidates = []; const currentWord = context.currentWord; // Determine search directory let searchDir = context.cwd; let pattern = currentWord; if (currentWord.includes('/')) { const lastSlash = currentWord.lastIndexOf('/'); searchDir = path.resolve(context.cwd, currentWord.substring(0, lastSlash + 1)); pattern = currentWord.substring(lastSlash + 1); } try { const entries = await fs.promises.readdir(searchDir, { withFileTypes: true }); for (const entry of entries) { // Skip hidden files unless explicitly requested if (!pattern.startsWith('.') && entry.name.startsWith('.')) { continue; } // Check if entry matches pattern if (this.matchesPattern(entry.name, pattern)) { const fullPath = path.join(searchDir, entry.name); const relativePath = path.relative(context.cwd, fullPath); candidates.push({ word: entry.isDirectory() ? relativePath + '/' : relativePath, type: entry.isDirectory() ? 'directory' : 'file', description: entry.isDirectory() ? 'Directory' : 'File', }); } } } catch (_error) { // Directory doesn't exist or not readable } return candidates; } /** * Complete commands from PATH */ async completeCommands(context) { const candidates = []; const pattern = context.currentWord; const pathDirs = (context.env.PATH || '').split(':').filter(dir => dir); // Add built-in commands const builtins = [ 'cd', 'pwd', 'echo', 'printf', 'test', '[', 'export', 'unset', 'set', 'eval', 'exec', 'return', 'shift', 'local', 'jobs', 'fg', 'bg', 'wait', 'read', 'getopts', 'trap', 'true', 'false', 'exit' ]; for (const builtin of builtins) { if (this.matchesPattern(builtin, pattern)) { candidates.push({ word: builtin, type: 'command', description: 'Built-in command', }); } } // Search PATH for executables for (const dir of pathDirs) { try { const entries = await fs.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && this.isExecutable(path.join(dir, entry.name))) { if (this.matchesPattern(entry.name, pattern)) { candidates.push({ word: entry.name, type: 'command', description: `Command in ${dir}`, }); } } } } catch (_error) { // Directory doesn't exist or not readable } } return candidates; } /** * Complete variables */ async completeVariables(context) { const candidates = []; const pattern = context.currentWord.substring(1); // Remove $ // Complete environment variables for (const [name, value] of Object.entries(context.env)) { if (this.matchesPattern(name, pattern)) { candidates.push({ word: `$${name}`, type: 'variable', description: `Environment variable: ${value}`, }); } } return candidates; } /** * Setup built-in command completions */ setupBuiltinCompletions() { // cd completion this.registerCompletion('cd', async (context) => { return this.completeDirectories(context); }); // export completion this.registerCompletion('export', async (context) => { if (context.wordIndex === 1) { return this.completeVariables(context); } return []; }); // unset completion this.registerCompletion('unset', async (context) => { if (context.wordIndex === 1) { return this.completeVariables(context); } return []; }); // test completion this.registerCompletion('test', async (context) => { return this.completeTestOptions(context); }); // Job management completions this.registerCompletion('job-start', async (context) => { return this.completeJobIds(context); }); this.registerCompletion('job-stop', async (context) => { return this.completeJobIds(context); }); this.registerCompletion('job-show', async (context) => { return this.completeJobIds(context); }); } /** * Complete directories only */ async completeDirectories(context) { const candidates = []; const currentWord = context.currentWord; let searchDir = context.cwd; let pattern = currentWord; if (currentWord.includes('/')) { const lastSlash = currentWord.lastIndexOf('/'); searchDir = path.resolve(context.cwd, currentWord.substring(0, lastSlash + 1)); pattern = currentWord.substring(lastSlash + 1); } try { const entries = await fs.promises.readdir(searchDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && this.matchesPattern(entry.name, pattern)) { const fullPath = path.join(searchDir, entry.name); const relativePath = path.relative(context.cwd, fullPath); candidates.push({ word: relativePath + '/', type: 'directory', description: 'Directory', }); } } } catch (_error) { // Directory doesn't exist or not readable } return candidates; } /** * Complete test command options */ async completeTestOptions(context) { const testOptions = [ { word: '-f', description: 'File exists and is regular file' }, { word: '-d', description: 'File exists and is directory' }, { word: '-e', description: 'File exists' }, { word: '-r', description: 'File exists and is readable' }, { word: '-w', description: 'File exists and is writable' }, { word: '-x', description: 'File exists and is executable' }, { word: '-s', description: 'File exists and has size > 0' }, { word: '-z', description: 'String is empty' }, { word: '-n', description: 'String is not empty' }, { word: '=', description: 'Strings are equal' }, { word: '!=', description: 'Strings are not equal' }, { word: '-eq', description: 'Numbers are equal' }, { word: '-ne', description: 'Numbers are not equal' }, { word: '-lt', description: 'Number is less than' }, { word: '-le', description: 'Number is less than or equal' }, { word: '-gt', description: 'Number is greater than' }, { word: '-ge', description: 'Number is greater than or equal' }, ]; return testOptions.filter(opt => this.matchesPattern(opt.word, context.currentWord)).map(opt => ({ word: opt.word, type: 'option', description: opt.description, })); } /** * Complete job IDs (placeholder - would integrate with job manager) */ async completeJobIds(_context) { // This would integrate with the job manager to get actual job IDs return [ { word: '1', description: 'Job ID 1' }, { word: '2', description: 'Job ID 2' }, { word: '3', description: 'Job ID 3' }, ]; } /** * Check if a pattern matches a string */ matchesPattern(str, pattern) { if (!pattern) return true; // Simple case-insensitive prefix matching return str.toLowerCase().startsWith(pattern.toLowerCase()); } /** * Check if a file is executable */ isExecutable(filePath) { try { fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { return false; } } /** * Filter and sort completion candidates */ filterAndSortCandidates(candidates, _currentWord) { // Remove duplicates const unique = new Map(); for (const candidate of candidates) { if (!unique.has(candidate.word)) { unique.set(candidate.word, candidate); } } // Sort by type priority and alphabetically const sorted = Array.from(unique.values()).sort((a, b) => { const typeOrder = { directory: 0, file: 1, command: 2, variable: 3, function: 4, option: 5 }; const aOrder = typeOrder[a.type || 'file']; const bOrder = typeOrder[b.type || 'file']; if (aOrder !== bOrder) { return aOrder - bOrder; } return a.word.localeCompare(b.word); }); return sorted; } } export default CompletionSystem;