| import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js' |
| import { |
| type ParseEntry, |
| quote, |
| tryParseShellCommand, |
| } from '../bash/shellQuote.js' |
| import { logForDebugging } from '../debug.js' |
| import { getShellType } from '../localInstaller.js' |
| import * as Shell from '../Shell.js' |
|
|
| |
| const MAX_SHELL_COMPLETIONS = 15 |
| const SHELL_COMPLETION_TIMEOUT_MS = 1000 |
| const COMMAND_OPERATORS = ['|', '||', '&&', ';'] as const |
|
|
| export type ShellCompletionType = 'command' | 'variable' | 'file' |
|
|
| type InputContext = { |
| prefix: string |
| completionType: ShellCompletionType |
| } |
|
|
| |
| |
| |
| function isCommandOperator(token: ParseEntry): boolean { |
| return ( |
| typeof token === 'object' && |
| token !== null && |
| 'op' in token && |
| (COMMAND_OPERATORS as readonly string[]).includes(token.op as string) |
| ) |
| } |
|
|
| |
| |
| |
| function getCompletionTypeFromPrefix(prefix: string): ShellCompletionType { |
| if (prefix.startsWith('$')) { |
| return 'variable' |
| } |
| if ( |
| prefix.includes('/') || |
| prefix.startsWith('~') || |
| prefix.startsWith('.') |
| ) { |
| return 'file' |
| } |
| return 'command' |
| } |
|
|
| |
| |
| |
| function findLastStringToken( |
| tokens: ParseEntry[], |
| ): { token: string; index: number } | null { |
| const i = tokens.findLastIndex(t => typeof t === 'string') |
| return i !== -1 ? { token: tokens[i] as string, index: i } : null |
| } |
|
|
| |
| |
| |
| |
| function isNewCommandContext( |
| tokens: ParseEntry[], |
| currentTokenIndex: number, |
| ): boolean { |
| if (currentTokenIndex === 0) { |
| return true |
| } |
| const prevToken = tokens[currentTokenIndex - 1] |
| return prevToken !== undefined && isCommandOperator(prevToken) |
| } |
|
|
| |
| |
| |
| function parseInputContext(input: string, cursorOffset: number): InputContext { |
| const beforeCursor = input.slice(0, cursorOffset) |
|
|
| |
| const varMatch = beforeCursor.match(/\$[a-zA-Z_][a-zA-Z0-9_]*$/) |
| if (varMatch) { |
| return { prefix: varMatch[0], completionType: 'variable' } |
| } |
|
|
| |
| const parseResult = tryParseShellCommand(beforeCursor) |
| if (!parseResult.success) { |
| |
| const tokens = beforeCursor.split(/\s+/) |
| const prefix = tokens[tokens.length - 1] || '' |
| const isFirstToken = tokens.length === 1 && !beforeCursor.includes(' ') |
| const completionType = isFirstToken |
| ? 'command' |
| : getCompletionTypeFromPrefix(prefix) |
| return { prefix, completionType } |
| } |
|
|
| |
| const lastToken = findLastStringToken(parseResult.tokens) |
| if (!lastToken) { |
| |
| const lastParsedToken = parseResult.tokens[parseResult.tokens.length - 1] |
| const completionType = |
| lastParsedToken && isCommandOperator(lastParsedToken) |
| ? 'command' |
| : 'command' |
| return { prefix: '', completionType } |
| } |
|
|
| |
| if (beforeCursor.endsWith(' ')) { |
| |
| return { prefix: '', completionType: 'file' } |
| } |
|
|
| |
| const baseType = getCompletionTypeFromPrefix(lastToken.token) |
|
|
| |
| if (baseType === 'variable' || baseType === 'file') { |
| return { prefix: lastToken.token, completionType: baseType } |
| } |
|
|
| |
| const completionType = isNewCommandContext( |
| parseResult.tokens, |
| lastToken.index, |
| ) |
| ? 'command' |
| : 'file' |
|
|
| return { prefix: lastToken.token, completionType } |
| } |
|
|
| |
| |
| |
| function getBashCompletionCommand( |
| prefix: string, |
| completionType: ShellCompletionType, |
| ): string { |
| if (completionType === 'variable') { |
| |
| const varName = prefix.slice(1) |
| return `compgen -v ${quote([varName])} 2>/dev/null` |
| } else if (completionType === 'file') { |
| |
| |
| return `compgen -f ${quote([prefix])} 2>/dev/null | head -${MAX_SHELL_COMPLETIONS} | while IFS= read -r f; do [ -d "$f" ] && echo "$f/" || echo "$f "; done` |
| } else { |
| |
| return `compgen -c ${quote([prefix])} 2>/dev/null` |
| } |
| } |
|
|
| |
| |
| |
| function getZshCompletionCommand( |
| prefix: string, |
| completionType: ShellCompletionType, |
| ): string { |
| if (completionType === 'variable') { |
| |
| const varName = prefix.slice(1) |
| return `print -rl -- \${(k)parameters[(I)${quote([varName])}*]} 2>/dev/null` |
| } else if (completionType === 'file') { |
| |
| |
| return `for f in ${quote([prefix])}*(N[1,${MAX_SHELL_COMPLETIONS}]); do [[ -d "$f" ]] && echo "$f/" || echo "$f "; done` |
| } else { |
| |
| return `print -rl -- \${(k)commands[(I)${quote([prefix])}*]} 2>/dev/null` |
| } |
| } |
|
|
| |
| |
| |
| async function getCompletionsForShell( |
| shellType: 'bash' | 'zsh', |
| prefix: string, |
| completionType: ShellCompletionType, |
| abortSignal: AbortSignal, |
| ): Promise<SuggestionItem[]> { |
| let command: string |
|
|
| if (shellType === 'bash') { |
| command = getBashCompletionCommand(prefix, completionType) |
| } else if (shellType === 'zsh') { |
| command = getZshCompletionCommand(prefix, completionType) |
| } else { |
| |
| return [] |
| } |
|
|
| const shellCommand = await Shell.exec(command, abortSignal, 'bash', { |
| timeout: SHELL_COMPLETION_TIMEOUT_MS, |
| }) |
| const result = await shellCommand.result |
| return result.stdout |
| .split('\n') |
| .filter((line: string) => line.trim()) |
| .slice(0, MAX_SHELL_COMPLETIONS) |
| .map((text: string) => ({ |
| id: text, |
| displayText: text, |
| description: undefined, |
| metadata: { completionType }, |
| })) |
| } |
|
|
| |
| |
| |
| |
| export async function getShellCompletions( |
| input: string, |
| cursorOffset: number, |
| abortSignal: AbortSignal, |
| ): Promise<SuggestionItem[]> { |
| const shellType = getShellType() |
|
|
| |
| if (shellType !== 'bash' && shellType !== 'zsh') { |
| return [] |
| } |
|
|
| try { |
| const { prefix, completionType } = parseInputContext(input, cursorOffset) |
|
|
| if (!prefix) { |
| return [] |
| } |
|
|
| const completions = await getCompletionsForShell( |
| shellType, |
| prefix, |
| completionType, |
| abortSignal, |
| ) |
|
|
| |
| return completions.map(suggestion => ({ |
| ...suggestion, |
| metadata: { |
| ...(suggestion.metadata as { completionType: ShellCompletionType }), |
| inputSnapshot: input, |
| }, |
| })) |
| } catch (error) { |
| logForDebugging(`Shell completion failed: ${error}`) |
| return [] |
| } |
| } |
|
|