/ utils / bash / shellCompletion.ts
shellCompletion.ts
  1  import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js'
  2  import {
  3    type ParseEntry,
  4    quote,
  5    tryParseShellCommand,
  6  } from '../bash/shellQuote.js'
  7  import { logForDebugging } from '../debug.js'
  8  import { getShellType } from '../localInstaller.js'
  9  import * as Shell from '../Shell.js'
 10  
 11  // Constants
 12  const MAX_SHELL_COMPLETIONS = 15
 13  const SHELL_COMPLETION_TIMEOUT_MS = 1000
 14  const COMMAND_OPERATORS = ['|', '||', '&&', ';'] as const
 15  
 16  export type ShellCompletionType = 'command' | 'variable' | 'file'
 17  
 18  type InputContext = {
 19    prefix: string
 20    completionType: ShellCompletionType
 21  }
 22  
 23  /**
 24   * Check if a parsed token is a command operator (|, ||, &&, ;)
 25   */
 26  function isCommandOperator(token: ParseEntry): boolean {
 27    return (
 28      typeof token === 'object' &&
 29      token !== null &&
 30      'op' in token &&
 31      (COMMAND_OPERATORS as readonly string[]).includes(token.op as string)
 32    )
 33  }
 34  
 35  /**
 36   * Determine completion type based solely on prefix characteristics
 37   */
 38  function getCompletionTypeFromPrefix(prefix: string): ShellCompletionType {
 39    if (prefix.startsWith('$')) {
 40      return 'variable'
 41    }
 42    if (
 43      prefix.includes('/') ||
 44      prefix.startsWith('~') ||
 45      prefix.startsWith('.')
 46    ) {
 47      return 'file'
 48    }
 49    return 'command'
 50  }
 51  
 52  /**
 53   * Find the last string token and its index in parsed tokens
 54   */
 55  function findLastStringToken(
 56    tokens: ParseEntry[],
 57  ): { token: string; index: number } | null {
 58    const i = tokens.findLastIndex(t => typeof t === 'string')
 59    return i !== -1 ? { token: tokens[i] as string, index: i } : null
 60  }
 61  
 62  /**
 63   * Check if we're in a context that expects a new command
 64   * (at start of input or after a command operator)
 65   */
 66  function isNewCommandContext(
 67    tokens: ParseEntry[],
 68    currentTokenIndex: number,
 69  ): boolean {
 70    if (currentTokenIndex === 0) {
 71      return true
 72    }
 73    const prevToken = tokens[currentTokenIndex - 1]
 74    return prevToken !== undefined && isCommandOperator(prevToken)
 75  }
 76  
 77  /**
 78   * Parse input to extract completion context
 79   */
 80  function parseInputContext(input: string, cursorOffset: number): InputContext {
 81    const beforeCursor = input.slice(0, cursorOffset)
 82  
 83    // Check if it's a variable prefix, before expanding with shell-quote
 84    const varMatch = beforeCursor.match(/\$[a-zA-Z_][a-zA-Z0-9_]*$/)
 85    if (varMatch) {
 86      return { prefix: varMatch[0], completionType: 'variable' }
 87    }
 88  
 89    // Parse with shell-quote
 90    const parseResult = tryParseShellCommand(beforeCursor)
 91    if (!parseResult.success) {
 92      // Fallback to simple parsing
 93      const tokens = beforeCursor.split(/\s+/)
 94      const prefix = tokens[tokens.length - 1] || ''
 95      const isFirstToken = tokens.length === 1 && !beforeCursor.includes(' ')
 96      const completionType = isFirstToken
 97        ? 'command'
 98        : getCompletionTypeFromPrefix(prefix)
 99      return { prefix, completionType }
100    }
101  
102    // Extract current token
103    const lastToken = findLastStringToken(parseResult.tokens)
104    if (!lastToken) {
105      // No string token found - check if after operator
106      const lastParsedToken = parseResult.tokens[parseResult.tokens.length - 1]
107      const completionType =
108        lastParsedToken && isCommandOperator(lastParsedToken)
109          ? 'command'
110          : 'command' // Default to command at start
111      return { prefix: '', completionType }
112    }
113  
114    // If there's a trailing space, the user is starting a new argument
115    if (beforeCursor.endsWith(' ')) {
116      // After first token (command) with space = file argument expected
117      return { prefix: '', completionType: 'file' }
118    }
119  
120    // Determine completion type from context
121    const baseType = getCompletionTypeFromPrefix(lastToken.token)
122  
123    // If it's clearly a file or variable based on prefix, use that type
124    if (baseType === 'variable' || baseType === 'file') {
125      return { prefix: lastToken.token, completionType: baseType }
126    }
127  
128    // For command-like tokens, check context: are we starting a new command?
129    const completionType = isNewCommandContext(
130      parseResult.tokens,
131      lastToken.index,
132    )
133      ? 'command'
134      : 'file' // Not after operator = file argument
135  
136    return { prefix: lastToken.token, completionType }
137  }
138  
139  /**
140   * Generate bash completion command using compgen
141   */
142  function getBashCompletionCommand(
143    prefix: string,
144    completionType: ShellCompletionType,
145  ): string {
146    if (completionType === 'variable') {
147      // Variable completion - remove $ prefix
148      const varName = prefix.slice(1)
149      return `compgen -v ${quote([varName])} 2>/dev/null`
150    } else if (completionType === 'file') {
151      // File completion with trailing slash for directories and trailing space for files
152      // Use 'while read' to prevent command injection from filenames containing newlines
153      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`
154    } else {
155      // Command completion
156      return `compgen -c ${quote([prefix])} 2>/dev/null`
157    }
158  }
159  
160  /**
161   * Generate zsh completion command using native zsh commands
162   */
163  function getZshCompletionCommand(
164    prefix: string,
165    completionType: ShellCompletionType,
166  ): string {
167    if (completionType === 'variable') {
168      // Variable completion - use zsh pattern matching for safe filtering
169      const varName = prefix.slice(1)
170      return `print -rl -- \${(k)parameters[(I)${quote([varName])}*]} 2>/dev/null`
171    } else if (completionType === 'file') {
172      // File completion with trailing slash for directories and trailing space for files
173      // Note: zsh glob expansion is safe from command injection (unlike bash for-in loops)
174      return `for f in ${quote([prefix])}*(N[1,${MAX_SHELL_COMPLETIONS}]); do [[ -d "$f" ]] && echo "$f/" || echo "$f "; done`
175    } else {
176      // Command completion - use zsh pattern matching for safe filtering
177      return `print -rl -- \${(k)commands[(I)${quote([prefix])}*]} 2>/dev/null`
178    }
179  }
180  
181  /**
182   * Get completions for the given shell type
183   */
184  async function getCompletionsForShell(
185    shellType: 'bash' | 'zsh',
186    prefix: string,
187    completionType: ShellCompletionType,
188    abortSignal: AbortSignal,
189  ): Promise<SuggestionItem[]> {
190    let command: string
191  
192    if (shellType === 'bash') {
193      command = getBashCompletionCommand(prefix, completionType)
194    } else if (shellType === 'zsh') {
195      command = getZshCompletionCommand(prefix, completionType)
196    } else {
197      // Unsupported shell type
198      return []
199    }
200  
201    const shellCommand = await Shell.exec(command, abortSignal, 'bash', {
202      timeout: SHELL_COMPLETION_TIMEOUT_MS,
203    })
204    const result = await shellCommand.result
205    return result.stdout
206      .split('\n')
207      .filter((line: string) => line.trim())
208      .slice(0, MAX_SHELL_COMPLETIONS)
209      .map((text: string) => ({
210        id: text,
211        displayText: text,
212        description: undefined,
213        metadata: { completionType },
214      }))
215  }
216  
217  /**
218   * Get shell completions for the given input
219   * Supports bash and zsh shells (matches Shell.ts execution support)
220   */
221  export async function getShellCompletions(
222    input: string,
223    cursorOffset: number,
224    abortSignal: AbortSignal,
225  ): Promise<SuggestionItem[]> {
226    const shellType = getShellType()
227  
228    // Only support bash/zsh (matches Shell.ts execution support)
229    if (shellType !== 'bash' && shellType !== 'zsh') {
230      return []
231    }
232  
233    try {
234      const { prefix, completionType } = parseInputContext(input, cursorOffset)
235  
236      if (!prefix) {
237        return []
238      }
239  
240      const completions = await getCompletionsForShell(
241        shellType,
242        prefix,
243        completionType,
244        abortSignal,
245      )
246  
247      // Add inputSnapshot to all suggestions so we can detect when input changes
248      return completions.map(suggestion => ({
249        ...suggestion,
250        metadata: {
251          ...(suggestion.metadata as { completionType: ShellCompletionType }),
252          inputSnapshot: input,
253        },
254      }))
255    } catch (error) {
256      logForDebugging(`Shell completion failed: ${error}`)
257      return [] // Silent fail
258    }
259  }