| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | import { assert, describe, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | type KeybindingCommand, |
| 4 | 5 | type KeybindingShortcut, |
| 5 | | type KeybindingsConfig, |
| 6 | 6 | type KeybindingWhenNode, |
| 7 | 7 | type ResolvedKeybindingsConfig, |
| 8 | 8 | } from "@t3tools/contracts"; |
| ... | ... | @@ -18,14 +18,6 @@ import { |
| 18 | 18 | type ShortcutEventLike, |
| 19 | 19 | } from "./keybindings"; |
| 20 | 20 | |
| 21 | | type WhenToken = |
| 22 | | | { type: "identifier"; value: string } |
| 23 | | | { type: "not" } |
| 24 | | | { type: "and" } |
| 25 | | | { type: "or" } |
| 26 | | | { type: "lparen" } |
| 27 | | | { type: "rparen" }; |
| 28 | | |
| 29 | 21 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { |
| 30 | 22 | return { |
| 31 | 23 | key: "j", |
| ... | ... | @@ -106,144 +98,37 @@ function shortcut(value: string): KeybindingShortcut { |
| 106 | 98 | return parsed; |
| 107 | 99 | } |
| 108 | 100 | |
| 109 | | function tokenizeWhenExpression(expression: string): WhenToken[] | null { |
| 110 | | const tokens: WhenToken[] = []; |
| 111 | | let index = 0; |
| 112 | | |
| 113 | | while (index < expression.length) { |
| 114 | | const current = expression[index]; |
| 115 | | if (!current) break; |
| 116 | | |
| 117 | | if (/\s/.test(current)) { |
| 118 | | index += 1; |
| 119 | | continue; |
| 120 | | } |
| 121 | | if (expression.startsWith("&&", index)) { |
| 122 | | tokens.push({ type: "and" }); |
| 123 | | index += 2; |
| 124 | | continue; |
| 125 | | } |
| 126 | | if (expression.startsWith("||", index)) { |
| 127 | | tokens.push({ type: "or" }); |
| 128 | | index += 2; |
| 129 | | continue; |
| 130 | | } |
| 131 | | if (current === "!") { |
| 132 | | tokens.push({ type: "not" }); |
| 133 | | index += 1; |
| 134 | | continue; |
| 135 | | } |
| 136 | | if (current === "(") { |
| 137 | | tokens.push({ type: "lparen" }); |
| 138 | | index += 1; |
| 139 | | continue; |
| 140 | | } |
| 141 | | if (current === ")") { |
| 142 | | tokens.push({ type: "rparen" }); |
| 143 | | index += 1; |
| 144 | | continue; |
| 145 | | } |
| 146 | | |
| 147 | | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); |
| 148 | | if (!identifier) return null; |
| 149 | | tokens.push({ type: "identifier", value: identifier[0] }); |
| 150 | | index += identifier[0].length; |
| 151 | | } |
| 152 | | |
| 153 | | return tokens; |
| 101 | function whenIdentifier(name: string): KeybindingWhenNode { |
| 102 | return { type: "identifier", name }; |
| 154 | 103 | } |
| 155 | 104 | |
| 156 | | function parseWhenExpression(expression: string): KeybindingWhenNode | null { |
| 157 | | const tokens = tokenizeWhenExpression(expression); |
| 158 | | if (!tokens || tokens.length === 0) return null; |
| 159 | | let index = 0; |
| 160 | | |
| 161 | | const parsePrimary = (): KeybindingWhenNode | null => { |
| 162 | | const token = tokens[index]; |
| 163 | | if (!token) return null; |
| 164 | | |
| 165 | | if (token.type === "identifier") { |
| 166 | | index += 1; |
| 167 | | return { type: "identifier", name: token.value }; |
| 168 | | } |
| 169 | | |
| 170 | | if (token.type === "lparen") { |
| 171 | | index += 1; |
| 172 | | const expressionNode = parseOr(); |
| 173 | | const closeToken = tokens[index]; |
| 174 | | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { |
| 175 | | return null; |
| 176 | | } |
| 177 | | index += 1; |
| 178 | | return expressionNode; |
| 179 | | } |
| 180 | | |
| 181 | | return null; |
| 182 | | }; |
| 183 | | |
| 184 | | const parseUnary = (): KeybindingWhenNode | null => { |
| 185 | | const token = tokens[index]; |
| 186 | | if (token?.type === "not") { |
| 187 | | index += 1; |
| 188 | | const node = parseUnary(); |
| 189 | | if (!node) return null; |
| 190 | | return { type: "not", node }; |
| 191 | | } |
| 192 | | return parsePrimary(); |
| 193 | | }; |
| 194 | | |
| 195 | | const parseAnd = (): KeybindingWhenNode | null => { |
| 196 | | let left = parseUnary(); |
| 197 | | if (!left) return null; |
| 198 | | |
| 199 | | while (tokens[index]?.type === "and") { |
| 200 | | index += 1; |
| 201 | | const right = parseUnary(); |
| 202 | | if (!right) return null; |
| 203 | | left = { type: "and", left, right }; |
| 204 | | } |
| 205 | | |
| 206 | | return left; |
| 207 | | }; |
| 208 | | |
| 209 | | const parseOr = (): KeybindingWhenNode | null => { |
| 210 | | let left = parseAnd(); |
| 211 | | if (!left) return null; |
| 212 | | |
| 213 | | while (tokens[index]?.type === "or") { |
| 214 | | index += 1; |
| 215 | | const right = parseAnd(); |
| 216 | | if (!right) return null; |
| 217 | | left = { type: "or", left, right }; |
| 218 | | } |
| 105 | function whenNot(node: KeybindingWhenNode): KeybindingWhenNode { |
| 106 | return { type: "not", node }; |
| 107 | } |
| 219 | 108 | |
| 220 | | return left; |
| 221 | | }; |
| 109 | function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode { |
| 110 | return { type: "and", left, right }; |
| 111 | } |
| 222 | 112 | |
| 223 | | const ast = parseOr(); |
| 224 | | if (!ast || index !== tokens.length) return null; |
| 225 | | return ast; |
| 113 | interface TestBinding { |
| 114 | key: string; |
| 115 | command: KeybindingCommand; |
| 116 | whenAst?: KeybindingWhenNode; |
| 226 | 117 | } |
| 227 | 118 | |
| 228 | | function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { |
| 119 | function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { |
| 229 | 120 | const resolved: ResolvedKeybindingsConfig = []; |
| 230 | 121 | for (const binding of bindings) { |
| 231 | 122 | const parsedShortcut = parseShortcut(binding.key); |
| 232 | 123 | if (!parsedShortcut) { |
| 233 | 124 | throw new Error(`invalid shortcut in test fixture: ${binding.key}`); |
| 234 | 125 | } |
| 235 | | const when = binding.when?.trim(); |
| 236 | | if (when && when.length > 0) { |
| 237 | | const whenAst = parseWhenExpression(when); |
| 238 | | if (!whenAst) { |
| 239 | | throw new Error(`invalid when expression in test fixture: ${when}`); |
| 240 | | } |
| 126 | if (binding.whenAst) { |
| 241 | 127 | resolved.push({ |
| 242 | 128 | key: binding.key.trim(), |
| 243 | 129 | command: binding.command, |
| 244 | | when, |
| 245 | 130 | shortcut: parsedShortcut, |
| 246 | | whenAst, |
| 131 | whenAst: binding.whenAst, |
| 247 | 132 | }); |
| 248 | 133 | continue; |
| 249 | 134 | } |
| ... | ... | @@ -258,8 +143,16 @@ function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { |
| 258 | 143 | |
| 259 | 144 | const DEFAULT_BINDINGS = compile([ |
| 260 | 145 | { key: "mod+j", command: "terminal.toggle" }, |
| 261 | | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 262 | | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, |
| 146 | { |
| 147 | key: "mod+d", |
| 148 | command: "terminal.split", |
| 149 | whenAst: whenIdentifier("terminalFocus"), |
| 150 | }, |
| 151 | { |
| 152 | key: "mod+shift+d", |
| 153 | command: "terminal.new", |
| 154 | whenAst: whenIdentifier("terminalFocus"), |
| 155 | }, |
| 263 | 156 | { key: "mod+shift+o", command: "chat.new" }, |
| 264 | 157 | { key: "mod+o", command: "editor.openFavorite" }, |
| 265 | 158 | ]); |
| ... | ... | @@ -314,12 +207,12 @@ describe("split/new terminal shortcuts", () => { |
| 314 | 207 | { |
| 315 | 208 | key: "mod+\\", |
| 316 | 209 | command: "terminal.split", |
| 317 | | when: "terminalOpen && !terminalFocus", |
| 210 | whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), |
| 318 | 211 | }, |
| 319 | 212 | { |
| 320 | 213 | key: "mod+shift+n", |
| 321 | 214 | command: "terminal.new", |
| 322 | | when: "terminalOpen && !terminalFocus", |
| 215 | whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), |
| 323 | 216 | }, |
| 324 | 217 | { key: "mod+j", command: "terminal.toggle" }, |
| 325 | 218 | ]); |
| ... | ... | @@ -345,8 +238,8 @@ describe("split/new terminal shortcuts", () => { |
| 345 | 238 | |
| 346 | 239 | it("supports when boolean literals", () => { |
| 347 | 240 | const keybindings = compile([ |
| 348 | | { key: "mod+n", command: "terminal.new", when: "true" }, |
| 349 | | { key: "mod+m", command: "terminal.new", when: "false" }, |
| 241 | { key: "mod+n", command: "terminal.new", whenAst: whenIdentifier("true") }, |
| 242 | { key: "mod+m", command: "terminal.new", whenAst: whenIdentifier("false") }, |
| 350 | 243 | ]); |
| 351 | 244 | |
| 352 | 245 | assert.isTrue( |
| ... | ... | @@ -365,8 +258,16 @@ describe("split/new terminal shortcuts", () => { |
| 365 | 258 | describe("shortcutLabelForCommand", () => { |
| 366 | 259 | it("returns the most recent binding label", () => { |
| 367 | 260 | const bindings = compile([ |
| 368 | | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, |
| 369 | | { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, |
| 261 | { |
| 262 | key: "mod+\\", |
| 263 | command: "terminal.split", |
| 264 | whenAst: whenIdentifier("terminalFocus"), |
| 265 | }, |
| 266 | { |
| 267 | key: "mod+shift+\\", |
| 268 | command: "terminal.split", |
| 269 | whenAst: whenNot(whenIdentifier("terminalFocus")), |
| 270 | }, |
| 370 | 271 | ]); |
| 371 | 272 | assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\"); |
| 372 | 273 | }); |
| ... | ... | @@ -412,7 +313,11 @@ describe("cross-command precedence", () => { |
| 412 | 313 | it("uses when + order so a later focused rule overrides a global rule", () => { |
| 413 | 314 | const keybindings = compile([ |
| 414 | 315 | { key: "mod+n", command: "chat.new" }, |
| 415 | | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 316 | { |
| 317 | key: "mod+n", |
| 318 | command: "terminal.new", |
| 319 | whenAst: whenIdentifier("terminalFocus"), |
| 320 | }, |
| 416 | 321 | ]); |
| 417 | 322 | |
| 418 | 323 | assert.isTrue( |
| ... | ... | @@ -443,7 +348,11 @@ describe("cross-command precedence", () => { |
| 443 | 348 | |
| 444 | 349 | it("still lets a later global rule win when both rules match", () => { |
| 445 | 350 | const keybindings = compile([ |
| 446 | | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 351 | { |
| 352 | key: "mod+n", |
| 353 | command: "terminal.new", |
| 354 | whenAst: whenIdentifier("terminalFocus"), |
| 355 | }, |
| 447 | 356 | { key: "mod+n", command: "chat.new" }, |
| 448 | 357 | ]); |
| 449 | 358 | |