lsh-framework
Version:
A powerful, extensible shell with advanced job management, database persistence, and modern CLI features
345 lines (344 loc) • 12.7 kB
JavaScript
/**
* 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;