validate.ts 13 KB

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