/ keybindings / validate.ts
validate.ts
  1  import { plural } from '../utils/stringUtils.js'
  2  import { chordToString, parseChord, parseKeystroke } from './parser.js'
  3  import {
  4    getReservedShortcuts,
  5    normalizeKeyForComparison,
  6  } from './reservedShortcuts.js'
  7  import type {
  8    KeybindingBlock,
  9    KeybindingContextName,
 10    ParsedBinding,
 11  } from './types.js'
 12  
 13  /**
 14   * Types of validation issues that can occur with keybindings.
 15   */
 16  export type KeybindingWarningType =
 17    | 'parse_error'
 18    | 'duplicate'
 19    | 'reserved'
 20    | 'invalid_context'
 21    | 'invalid_action'
 22  
 23  /**
 24   * A warning or error about a keybinding configuration issue.
 25   */
 26  export type KeybindingWarning = {
 27    type: KeybindingWarningType
 28    severity: 'error' | 'warning'
 29    message: string
 30    key?: string
 31    context?: string
 32    action?: string
 33    suggestion?: string
 34  }
 35  
 36  /**
 37   * Type guard to check if an object is a valid KeybindingBlock.
 38   */
 39  function isKeybindingBlock(obj: unknown): obj is KeybindingBlock {
 40    if (typeof obj !== 'object' || obj === null) return false
 41    const b = obj as Record<string, unknown>
 42    return (
 43      typeof b.context === 'string' &&
 44      typeof b.bindings === 'object' &&
 45      b.bindings !== null
 46    )
 47  }
 48  
 49  /**
 50   * Type guard to check if an array contains only valid KeybindingBlocks.
 51   */
 52  function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
 53    return Array.isArray(arr) && arr.every(isKeybindingBlock)
 54  }
 55  
 56  /**
 57   * Valid context names for keybindings.
 58   * Must match KeybindingContextName in types.ts
 59   */
 60  const VALID_CONTEXTS: KeybindingContextName[] = [
 61    'Global',
 62    'Chat',
 63    'Autocomplete',
 64    'Confirmation',
 65    'Help',
 66    'Transcript',
 67    'HistorySearch',
 68    'Task',
 69    'ThemePicker',
 70    'Settings',
 71    'Tabs',
 72    'Attachments',
 73    'Footer',
 74    'MessageSelector',
 75    'DiffDialog',
 76    'ModelPicker',
 77    'Select',
 78    'Plugin',
 79  ]
 80  
 81  /**
 82   * Type guard to check if a string is a valid context name.
 83   */
 84  function isValidContext(value: string): value is KeybindingContextName {
 85    return (VALID_CONTEXTS as readonly string[]).includes(value)
 86  }
 87  
 88  /**
 89   * Validate a single keystroke string and return any parse errors.
 90   */
 91  function validateKeystroke(keystroke: string): KeybindingWarning | null {
 92    const parts = keystroke.toLowerCase().split('+')
 93  
 94    for (const part of parts) {
 95      const trimmed = part.trim()
 96      if (!trimmed) {
 97        return {
 98          type: 'parse_error',
 99          severity: 'error',
100          message: `Empty key part in "${keystroke}"`,
101          key: keystroke,
102          suggestion: 'Remove extra "+" characters',
103        }
104      }
105    }
106  
107    // Try to parse and see if it fails
108    const parsed = parseKeystroke(keystroke)
109    if (
110      !parsed.key &&
111      !parsed.ctrl &&
112      !parsed.alt &&
113      !parsed.shift &&
114      !parsed.meta
115    ) {
116      return {
117        type: 'parse_error',
118        severity: 'error',
119        message: `Could not parse keystroke "${keystroke}"`,
120        key: keystroke,
121      }
122    }
123  
124    return null
125  }
126  
127  /**
128   * Validate a keybinding block from user config.
129   */
130  function validateBlock(
131    block: unknown,
132    blockIndex: number,
133  ): KeybindingWarning[] {
134    const warnings: KeybindingWarning[] = []
135  
136    if (typeof block !== 'object' || block === null) {
137      warnings.push({
138        type: 'parse_error',
139        severity: 'error',
140        message: `Keybinding block ${blockIndex + 1} is not an object`,
141      })
142      return warnings
143    }
144  
145    const b = block as Record<string, unknown>
146  
147    // Validate context - extract to narrowed variable for type safety
148    const rawContext = b.context
149    let contextName: string | undefined
150    if (typeof rawContext !== 'string') {
151      warnings.push({
152        type: 'parse_error',
153        severity: 'error',
154        message: `Keybinding block ${blockIndex + 1} missing "context" field`,
155      })
156    } else if (!isValidContext(rawContext)) {
157      warnings.push({
158        type: 'invalid_context',
159        severity: 'error',
160        message: `Unknown context "${rawContext}"`,
161        context: rawContext,
162        suggestion: `Valid contexts: ${VALID_CONTEXTS.join(', ')}`,
163      })
164    } else {
165      contextName = rawContext
166    }
167  
168    // Validate bindings
169    if (typeof b.bindings !== 'object' || b.bindings === null) {
170      warnings.push({
171        type: 'parse_error',
172        severity: 'error',
173        message: `Keybinding block ${blockIndex + 1} missing "bindings" field`,
174      })
175      return warnings
176    }
177  
178    const bindings = b.bindings as Record<string, unknown>
179    for (const [key, action] of Object.entries(bindings)) {
180      // Validate key syntax
181      const keyError = validateKeystroke(key)
182      if (keyError) {
183        keyError.context = contextName
184        warnings.push(keyError)
185      }
186  
187      // Validate action
188      if (action !== null && typeof action !== 'string') {
189        warnings.push({
190          type: 'invalid_action',
191          severity: 'error',
192          message: `Invalid action for "${key}": must be a string or null`,
193          key,
194          context: contextName,
195        })
196      } else if (typeof action === 'string' && action.startsWith('command:')) {
197        // Validate command binding format
198        if (!/^command:[a-zA-Z0-9:\-_]+$/.test(action)) {
199          warnings.push({
200            type: 'invalid_action',
201            severity: 'warning',
202            message: `Invalid command binding "${action}" for "${key}": command name may only contain alphanumeric characters, colons, hyphens, and underscores`,
203            key,
204            context: contextName,
205            action,
206          })
207        }
208        // Command bindings must be in Chat context
209        if (contextName && contextName !== 'Chat') {
210          warnings.push({
211            type: 'invalid_action',
212            severity: 'warning',
213            message: `Command binding "${action}" must be in "Chat" context, not "${contextName}"`,
214            key,
215            context: contextName,
216            action,
217            suggestion: 'Move this binding to a block with "context": "Chat"',
218          })
219        }
220      } else if (action === 'voice:pushToTalk') {
221        // Hold detection needs OS auto-repeat. Bare letters print into the
222        // input during warmup and the activation strip is best-effort —
223        // space (default) or a modifier combo like meta+k avoid that.
224        const ks = parseChord(key)[0]
225        if (
226          ks &&
227          !ks.ctrl &&
228          !ks.alt &&
229          !ks.shift &&
230          !ks.meta &&
231          !ks.super &&
232          /^[a-z]$/.test(ks.key)
233        ) {
234          warnings.push({
235            type: 'invalid_action',
236            severity: 'warning',
237            message: `Binding "${key}" to voice:pushToTalk prints into the input during warmup; use space or a modifier combo like meta+k`,
238            key,
239            context: contextName,
240            action,
241          })
242        }
243      }
244    }
245  
246    return warnings
247  }
248  
249  /**
250   * Detect duplicate keys within the same bindings block in a JSON string.
251   * JSON.parse silently uses the last value for duplicate keys,
252   * so we need to check the raw string to warn users.
253   *
254   * Only warns about duplicates within the same context's bindings object.
255   * Duplicates across different contexts are allowed (e.g., "enter" in Chat
256   * and "enter" in Confirmation).
257   */
258  export function checkDuplicateKeysInJson(
259    jsonString: string,
260  ): KeybindingWarning[] {
261    const warnings: KeybindingWarning[] = []
262  
263    // Find each "bindings" block and check for duplicates within it
264    // Pattern: "bindings" : { ... }
265    const bindingsBlockPattern =
266      /"bindings"\s*:\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g
267  
268    let blockMatch
269    while ((blockMatch = bindingsBlockPattern.exec(jsonString)) !== null) {
270      const blockContent = blockMatch[1]
271      if (!blockContent) continue
272  
273      // Find the context for this block by looking backwards
274      const textBeforeBlock = jsonString.slice(0, blockMatch.index)
275      const contextMatch = textBeforeBlock.match(
276        /"context"\s*:\s*"([^"]+)"[^{]*$/,
277      )
278      const context = contextMatch?.[1] ?? 'unknown'
279  
280      // Find all keys within this bindings block
281      const keyPattern = /"([^"]+)"\s*:/g
282      const keysByName = new Map<string, number>()
283  
284      let keyMatch
285      while ((keyMatch = keyPattern.exec(blockContent)) !== null) {
286        const key = keyMatch[1]
287        if (!key) continue
288  
289        const count = (keysByName.get(key) ?? 0) + 1
290        keysByName.set(key, count)
291  
292        if (count === 2) {
293          // Only warn on the second occurrence
294          warnings.push({
295            type: 'duplicate',
296            severity: 'warning',
297            message: `Duplicate key "${key}" in ${context} bindings`,
298            key,
299            context,
300            suggestion: `This key appears multiple times in the same context. JSON uses the last value, earlier values are ignored.`,
301          })
302        }
303      }
304    }
305  
306    return warnings
307  }
308  
309  /**
310   * Validate user keybinding config and return all warnings.
311   */
312  export function validateUserConfig(userBlocks: unknown): KeybindingWarning[] {
313    const warnings: KeybindingWarning[] = []
314  
315    if (!Array.isArray(userBlocks)) {
316      warnings.push({
317        type: 'parse_error',
318        severity: 'error',
319        message: 'keybindings.json must contain an array',
320        suggestion: 'Wrap your bindings in [ ]',
321      })
322      return warnings
323    }
324  
325    for (let i = 0; i < userBlocks.length; i++) {
326      warnings.push(...validateBlock(userBlocks[i], i))
327    }
328  
329    return warnings
330  }
331  
332  /**
333   * Check for duplicate bindings within the same context.
334   * Only checks user bindings (not default + user merged).
335   */
336  export function checkDuplicates(
337    blocks: KeybindingBlock[],
338  ): KeybindingWarning[] {
339    const warnings: KeybindingWarning[] = []
340    const seenByContext = new Map<string, Map<string, string>>()
341  
342    for (const block of blocks) {
343      const contextMap =
344        seenByContext.get(block.context) ?? new Map<string, string>()
345      seenByContext.set(block.context, contextMap)
346  
347      for (const [key, action] of Object.entries(block.bindings)) {
348        const normalizedKey = normalizeKeyForComparison(key)
349        const existingAction = contextMap.get(normalizedKey)
350  
351        if (existingAction && existingAction !== action) {
352          warnings.push({
353            type: 'duplicate',
354            severity: 'warning',
355            message: `Duplicate binding "${key}" in ${block.context} context`,
356            key,
357            context: block.context,
358            action: action ?? 'null (unbind)',
359            suggestion: `Previously bound to "${existingAction}". Only the last binding will be used.`,
360          })
361        }
362  
363        contextMap.set(normalizedKey, action ?? 'null')
364      }
365    }
366  
367    return warnings
368  }
369  
370  /**
371   * Check for reserved shortcuts that may not work.
372   */
373  export function checkReservedShortcuts(
374    bindings: ParsedBinding[],
375  ): KeybindingWarning[] {
376    const warnings: KeybindingWarning[] = []
377    const reserved = getReservedShortcuts()
378  
379    for (const binding of bindings) {
380      const keyDisplay = chordToString(binding.chord)
381      const normalizedKey = normalizeKeyForComparison(keyDisplay)
382  
383      // Check against reserved shortcuts
384      for (const res of reserved) {
385        if (normalizeKeyForComparison(res.key) === normalizedKey) {
386          warnings.push({
387            type: 'reserved',
388            severity: res.severity,
389            message: `"${keyDisplay}" may not work: ${res.reason}`,
390            key: keyDisplay,
391            context: binding.context,
392            action: binding.action ?? undefined,
393          })
394        }
395      }
396    }
397  
398    return warnings
399  }
400  
401  /**
402   * Parse user blocks into bindings for validation.
403   * This is separate from the main parser to avoid importing it.
404   */
405  function getUserBindingsForValidation(
406    userBlocks: KeybindingBlock[],
407  ): ParsedBinding[] {
408    const bindings: ParsedBinding[] = []
409    for (const block of userBlocks) {
410      for (const [key, action] of Object.entries(block.bindings)) {
411        const chord = key.split(' ').map(k => parseKeystroke(k))
412        bindings.push({
413          chord,
414          action,
415          context: block.context,
416        })
417      }
418    }
419    return bindings
420  }
421  
422  /**
423   * Run all validations and return combined warnings.
424   */
425  export function validateBindings(
426    userBlocks: unknown,
427    _parsedBindings: ParsedBinding[],
428  ): KeybindingWarning[] {
429    const warnings: KeybindingWarning[] = []
430  
431    // Validate user config structure
432    warnings.push(...validateUserConfig(userBlocks))
433  
434    // Check for duplicates in user config
435    if (isKeybindingBlockArray(userBlocks)) {
436      warnings.push(...checkDuplicates(userBlocks))
437  
438      // Check for reserved/conflicting shortcuts - only check USER bindings
439      const userBindings = getUserBindingsForValidation(userBlocks)
440      warnings.push(...checkReservedShortcuts(userBindings))
441    }
442  
443    // Deduplicate warnings (same key+context+type)
444    const seen = new Set<string>()
445    return warnings.filter(w => {
446      const key = `${w.type}:${w.key}:${w.context}`
447      if (seen.has(key)) return false
448      seen.add(key)
449      return true
450    })
451  }
452  
453  /**
454   * Format a warning for display to the user.
455   */
456  export function formatWarning(warning: KeybindingWarning): string {
457    const icon = warning.severity === 'error' ? '✗' : '⚠'
458    let msg = `${icon} Keybinding ${warning.severity}: ${warning.message}`
459  
460    if (warning.suggestion) {
461      msg += `\n  ${warning.suggestion}`
462    }
463  
464    return msg
465  }
466  
467  /**
468   * Format multiple warnings for display.
469   */
470  export function formatWarnings(warnings: KeybindingWarning[]): string {
471    if (warnings.length === 0) return ''
472  
473    const errors = warnings.filter(w => w.severity === 'error')
474    const warns = warnings.filter(w => w.severity === 'warning')
475  
476    const lines: string[] = []
477  
478    if (errors.length > 0) {
479      lines.push(
480        `Found ${errors.length} keybinding ${plural(errors.length, 'error')}:`,
481      )
482      for (const e of errors) {
483        lines.push(formatWarning(e))
484      }
485    }
486  
487    if (warns.length > 0) {
488      if (lines.length > 0) lines.push('')
489      lines.push(
490        `Found ${warns.length} keybinding ${plural(warns.length, 'warning')}:`,
491      )
492      for (const w of warns) {
493        lines.push(formatWarning(w))
494      }
495    }
496  
497    return lines.join('\n')
498  }