From 7a51850e1a788bf7bdd52718ba4139ae9baf6c73 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 5 Dec 2023 15:27:34 +0000 Subject: [PATCH] keyboard: Use parser for key sequences (#2051) ## Checklist - [x] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [-] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) (keeping this somewhat under wraps while we experiment - [x] I have not broken the cheatsheet - [-] Refactor delete mapping to just issue token for delete action - [x] Extract partial parameters - [-] Add railroad to docs? --- .editorconfig | 2 +- .eslintrc.json | 7 +- .pre-commit-config.yaml | 2 +- .prettierignore | 1 + .vscode/tasks.json | 11 + docs/user/experimental/keyboard/modal.md | 10 +- package.json | 4 +- packages/common/package.json | 1 + packages/common/src/index.ts | 1 + packages/common/src/util/CompositeKeyMap.ts | 5 + .../src/util/uniqWithHash.test.ts | 0 .../src/util/uniqWithHash.ts | 0 packages/cursorless-engine/package.json | 1 - .../processTargets/TargetPipelineRunner.ts | 4 +- .../src/util/setSelectionsAndFocusEditor.ts | 8 +- .../src/suite/keyboard/basic.vscode.test.ts | 51 ++- packages/cursorless-vscode/package.json | 20 +- packages/cursorless-vscode/src/extension.ts | 6 +- .../src/keyboard/KeyboardActionType.ts | 42 +++ .../src/keyboard/KeyboardCommandHandler.ts | 119 +++++++ .../keyboard/KeyboardCommandTypeHelpers.ts | 40 +++ .../src/keyboard/KeyboardCommands.ts | 17 +- .../src/keyboard/KeyboardCommandsModal.ts | 292 ++++++++---------- .../keyboard/KeyboardCommandsModalLayer.ts | 87 ++++++ .../src/keyboard/KeyboardCommandsTargeted.ts | 35 ++- .../src/keyboard/KeyboardConfig.ts | 113 +++++++ .../src/keyboard/KeyboardHandler.ts | 10 +- .../src/keyboard/TokenTypeHelpers.ts | 18 ++ .../src/keyboard/TokenTypes.ts | 74 +++++ .../src/keyboard/buildSuffixTrie.test.ts | 145 +++++++++ .../src/keyboard/buildSuffixTrie.ts | 115 +++++++ .../src/keyboard/defaultKeymaps.ts | 51 --- .../src/keyboard/getTokenTypeKeyMaps.ts | 72 +++++ .../grammar/CommandRulePostProcessor.ts | 25 ++ .../src/keyboard/grammar/UniqueWorkQueue.ts | 27 ++ .../src/keyboard/grammar/generated/grammar.ts | 88 ++++++ .../grammar/getAcceptableTokenTypes.ts | 94 ++++++ .../src/keyboard/grammar/grammar.ne | 52 ++++ .../src/keyboard/grammar/grammar.test.ts | 165 ++++++++++ .../src/keyboard/grammar/grammarHelpers.ts | 92 ++++++ .../src/keyboard/grammar/keyboardLexer.ts | 57 ++++ .../src/keyboard/keyboard-config.fixture.json | 81 +++++ .../cursorless-vscode/src/registerCommands.ts | 2 +- patches/@types__nearley@2.11.5.patch | 50 +++ patches/nearley@2.20.1.patch | 32 ++ pnpm-lock.yaml | 79 ++++- scripts/build-and-assemble-website.sh | 3 + 47 files changed, 1955 insertions(+), 256 deletions(-) rename packages/{cursorless-engine => common}/src/util/uniqWithHash.test.ts (100%) rename packages/{cursorless-engine => common}/src/util/uniqWithHash.ts (100%) create mode 100644 packages/cursorless-vscode/src/keyboard/KeyboardActionType.ts create mode 100644 packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts create mode 100644 packages/cursorless-vscode/src/keyboard/KeyboardCommandTypeHelpers.ts create mode 100644 packages/cursorless-vscode/src/keyboard/KeyboardCommandsModalLayer.ts create mode 100644 packages/cursorless-vscode/src/keyboard/KeyboardConfig.ts create mode 100644 packages/cursorless-vscode/src/keyboard/TokenTypeHelpers.ts create mode 100644 packages/cursorless-vscode/src/keyboard/TokenTypes.ts create mode 100644 packages/cursorless-vscode/src/keyboard/buildSuffixTrie.test.ts create mode 100644 packages/cursorless-vscode/src/keyboard/buildSuffixTrie.ts delete mode 100644 packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts create mode 100644 packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/CommandRulePostProcessor.ts create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/UniqueWorkQueue.ts create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/getAcceptableTokenTypes.ts create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/grammar.ne create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/grammar.test.ts create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/keyboardLexer.ts create mode 100644 packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json create mode 100644 patches/@types__nearley@2.11.5.patch create mode 100644 patches/nearley@2.20.1.patch diff --git a/.editorconfig b/.editorconfig index 3d2dcf13eb..1b66cc3776 100644 --- a/.editorconfig +++ b/.editorconfig @@ -28,7 +28,7 @@ trim_trailing_whitespace = false [Makefile] indent_style = tab -[**/vendor/**] +[**/{vendor,generated}/**] charset = unset end_of_line = unset indent_size = unset diff --git a/.eslintrc.json b/.eslintrc.json index e638c2b233..69ff665781 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -78,5 +78,10 @@ } } }, - "ignorePatterns": ["**/vendor/**/*.ts", "**/vendor/**/*.js", "**/out/**"] + "ignorePatterns": [ + "**/vendor/**/*.ts", + "**/vendor/**/*.js", + "**/out/**", + "**/generated/**" + ] } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fdba0648cb..e9dd6b9849 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: # tests use strings with trailing white space to represent the final # document contents. For example # packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/languages/ruby/changeCondition.yml - exclude: ^packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/.*/[^/]*\.yml$ + exclude: ^packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/.*/[^/]*\.yml$|/generated/|^patches/ - repo: local hooks: - id: eslint diff --git a/.prettierignore b/.prettierignore index fdcbdf9655..bfd0fe8192 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ **/vendor +**/generated # We use our own format for our recorded yaml tests to keep them compact /packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/**/*.yml diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 056502f8c2..69cd1ca783 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -26,6 +26,7 @@ "type": "npm", "script": "esbuild", "path": "packages/cursorless-vscode", + "dependsOn": ["Generate grammar"], "presentation": { "reveal": "silent" }, @@ -61,6 +62,16 @@ }, "group": "build" }, + { + "label": "Generate grammar", + "type": "npm", + "script": "generate-grammar", + "path": "packages/cursorless-vscode", + "presentation": { + "reveal": "silent" + }, + "group": "build" + }, { "label": "Ensure test subset file exists", "type": "npm", diff --git a/docs/user/experimental/keyboard/modal.md b/docs/user/experimental/keyboard/modal.md index 1cc58dbc94..c8c0d5ba29 100644 --- a/docs/user/experimental/keyboard/modal.md +++ b/docs/user/experimental/keyboard/modal.md @@ -42,7 +42,7 @@ The above allows you to press `ctrl-c` to switch to Cursorless mode, `escape` to To bind keys that do not have modifiers (eg just pressing `a`), add entries like the following to your [VSCode `settings.json`](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) (or edit these settings in the VSCode settings gui by saying `"cursorless settings"`): ```json - "cursorless.experimental.keyboard.modal.keybindings.scopes": { + "cursorless.experimental.keyboard.modal.keybindings.scope": { "i": "line", "p": "paragraph", ";": "statement", @@ -60,7 +60,7 @@ To bind keys that do not have modifiers (eg just pressing `a`), add entries like "sa": "argumentOrParameter", "sl": "url", }, - "cursorless.experimental.keyboard.modal.keybindings.actions": { + "cursorless.experimental.keyboard.modal.keybindings.action": { "t": "setSelection", "h": "setSelectionBefore", "l": "setSelectionAfter", @@ -81,13 +81,13 @@ To bind keys that do not have modifiers (eg just pressing `a`), add entries like "ap": "pasteFromClipboard", "ad": "followLink" }, - "cursorless.experimental.keyboard.modal.keybindings.colors": { + "cursorless.experimental.keyboard.modal.keybindings.color": { "d": "default", "b": "blue", "g": "yellow", "r": "red" }, - "cursorless.experimental.keyboard.modal.keybindings.shapes": { + "cursorless.experimental.keyboard.modal.keybindings.shape": { "x": "ex", "f": "fox", "q": "frame", @@ -97,7 +97,7 @@ To bind keys that do not have modifiers (eg just pressing `a`), add entries like "z": "bolt", "w": "crosshairs" }, - "cursorless.experimental.keyboard.modal.keybindings.vscodeCommands": { + "cursorless.experimental.keyboard.modal.keybindings.vscodeCommand": { // For simple commands, just use the command name // "aa": "workbench.action.editor.changeLanguageMode", diff --git a/package.json b/package.json index a42a71e84b..c38a74fa80 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ }, "pnpm": { "patchedDependencies": { - "@docusaurus/theme-search-algolia@3.0.1": "patches/@docusaurus__theme-search-algolia@2.3.1.patch" + "@docusaurus/theme-search-algolia@3.0.1": "patches/@docusaurus__theme-search-algolia@2.3.1.patch", + "@types/nearley@2.11.5": "patches/@types__nearley@2.11.5.patch", + "nearley@2.20.1": "patches/nearley@2.20.1.patch" }, "peerDependencyRules": { "ignoreMissing": [ diff --git a/packages/common/package.json b/packages/common/package.json index 933e04878e..d7694b04d8 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -25,6 +25,7 @@ "@types/mocha": "^10.0.3", "@types/sinon": "^10.0.2", "cross-spawn": "7.0.3", + "fast-check": "3.12.0", "js-yaml": "^4.1.0", "mocha": "^10.2.0", "sinon": "^11.1.1" diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index cb0d911db0..b4ae0b9bea 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -56,6 +56,7 @@ export * from "./types/GeneralizedRange"; export * from "./types/RangeOffsets"; export * from "./util/omitByDeep"; export * from "./util/range"; +export * from "./util/uniqWithHash"; export * from "./testUtil/isTesting"; export * from "./testUtil/testConstants"; export * from "./testUtil/getFixturePaths"; diff --git a/packages/common/src/util/CompositeKeyMap.ts b/packages/common/src/util/CompositeKeyMap.ts index 1cb896fd9c..bfb935fad4 100644 --- a/packages/common/src/util/CompositeKeyMap.ts +++ b/packages/common/src/util/CompositeKeyMap.ts @@ -35,4 +35,9 @@ export class CompositeKeyMap { delete this.map[this.hash(key)]; return this; } + + clear(): this { + this.map = {}; + return this; + } } diff --git a/packages/cursorless-engine/src/util/uniqWithHash.test.ts b/packages/common/src/util/uniqWithHash.test.ts similarity index 100% rename from packages/cursorless-engine/src/util/uniqWithHash.test.ts rename to packages/common/src/util/uniqWithHash.test.ts diff --git a/packages/cursorless-engine/src/util/uniqWithHash.ts b/packages/common/src/util/uniqWithHash.ts similarity index 100% rename from packages/cursorless-engine/src/util/uniqWithHash.ts rename to packages/common/src/util/uniqWithHash.ts diff --git a/packages/cursorless-engine/package.json b/packages/cursorless-engine/package.json index 3cdebe5d54..6978b91348 100644 --- a/packages/cursorless-engine/package.json +++ b/packages/cursorless-engine/package.json @@ -31,7 +31,6 @@ "@types/mocha": "^10.0.3", "@types/sbd": "^1.0.3", "@types/sinon": "^10.0.2", - "fast-check": "3.12.0", "js-yaml": "^4.1.0", "mocha": "^10.2.0", "sinon": "^11.1.1" diff --git a/packages/cursorless-engine/src/processTargets/TargetPipelineRunner.ts b/packages/cursorless-engine/src/processTargets/TargetPipelineRunner.ts index 7cc634d1fa..86f7647244 100644 --- a/packages/cursorless-engine/src/processTargets/TargetPipelineRunner.ts +++ b/packages/cursorless-engine/src/processTargets/TargetPipelineRunner.ts @@ -4,6 +4,7 @@ import { Modifier, Range, ScopeType, + uniqWithHash, } from "@cursorless/common"; import { zip } from "lodash"; import { @@ -15,11 +16,10 @@ import { Target } from "../typings/target.types"; import { MarkStageFactory } from "./MarkStageFactory"; import { ModifierStageFactory } from "./ModifierStageFactory"; import { MarkStage, ModifierStage } from "./PipelineStages.types"; +import { createContinuousRangeTarget } from "./createContinuousRangeTarget"; import { ImplicitStage } from "./marks/ImplicitStage"; import { ContainingTokenIfUntypedEmptyStage } from "./modifiers/ConditionalModifierStages"; import { PlainTarget } from "./targets"; -import { uniqWithHash } from "../util/uniqWithHash"; -import { createContinuousRangeTarget } from "./createContinuousRangeTarget"; export class TargetPipelineRunner { constructor( diff --git a/packages/cursorless-engine/src/util/setSelectionsAndFocusEditor.ts b/packages/cursorless-engine/src/util/setSelectionsAndFocusEditor.ts index 404daefc54..2f7f845261 100644 --- a/packages/cursorless-engine/src/util/setSelectionsAndFocusEditor.ts +++ b/packages/cursorless-engine/src/util/setSelectionsAndFocusEditor.ts @@ -1,6 +1,8 @@ -import { EditableTextEditor, Selection } from "@cursorless/common"; - -import { uniqWithHash } from "./uniqWithHash"; +import { + EditableTextEditor, + Selection, + uniqWithHash, +} from "@cursorless/common"; export async function setSelectionsAndFocusEditor( editor: EditableTextEditor, diff --git a/packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts index 0c5726daf3..729555c98d 100644 --- a/packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts @@ -2,10 +2,18 @@ import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common"; import { assert } from "chai"; import * as vscode from "vscode"; import { endToEndTestSetup, sleepWithBackoff } from "../../endToEndTestSetup"; +import sinon from "sinon"; +import path from "path"; +import { getCursorlessRepoRoot } from "@cursorless/common"; +import { readFile } from "node:fs/promises"; suite("Basic keyboard test", async function () { endToEndTestSetup(this); + this.beforeEach(async () => { + await injectFakes(); + }); + this.afterEach(async () => { await vscode.commands.executeCommand("cursorless.keyboard.modal.modeOff"); }); @@ -46,7 +54,7 @@ async function basic() { await typeText("sf"); // Select target - await typeText("t"); + await typeText("at"); assert.isTrue(editor.selection.isEqual(new vscode.Selection(0, 0, 0, 17))); @@ -73,16 +81,16 @@ async function vscodeCommand() { await typeText("db"); // Comment line containing *selection* - await typeText("c"); + await typeText("va"); assert.equal(editor.document.getText(), "// aaa;\nbbb;\nccc;\n"); // Comment line containing *target* - await typeText("mc"); + await typeText("vb"); assert.equal(editor.document.getText(), "// aaa;\n// bbb;\nccc;\n"); // Comment line containing *target*, keeping changed selection and exiting // cursorless mode - await typeText("dcmma"); + await typeText("dcvca"); assert.equal(editor.document.getText(), "// aaa;\n// bbb;\n// a;\n"); await vscode.commands.executeCommand("cursorless.keyboard.modal.modeOff"); @@ -111,3 +119,38 @@ async function typeText(text: string) { await sleepWithBackoff(100); } } + +async function injectFakes(): Promise { + const { vscodeApi } = (await getCursorlessApi()).testHelpers!; + + const keyboardConfigPath = path.join( + getCursorlessRepoRoot(), + "packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json", + ); + + const keyboardConfig = JSON.parse(await readFile(keyboardConfigPath, "utf8")); + + const getConfigurationValue = sinon.fake((sectionName) => { + return keyboardConfig[ + `cursorless.experimental.keyboard.modal.keybindings.${sectionName}` + ]; + }); + + sinon.replace( + vscodeApi.workspace, + "getConfiguration", + sinon.fake((section) => { + if ( + !section?.startsWith( + "cursorless.experimental.keyboard.modal.keybindings", + ) + ) { + return vscode.workspace.getConfiguration(section); + } + + return { + get: getConfigurationValue, + } as unknown as vscode.WorkspaceConfiguration; + }), + ); +} diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 431ac45e97..bd3eb3ee9a 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -848,7 +848,7 @@ "description": "Directory containing snippets for use in Cursorless", "type": "string" }, - "cursorless.experimental.keyboard.modal.keybindings.actions": { + "cursorless.experimental.keyboard.modal.keybindings.action": { "description": "Define modal keybindings for actions", "type": "object", "additionalProperties": { @@ -909,7 +909,7 @@ ] } }, - "cursorless.experimental.keyboard.modal.keybindings.vscodeCommands": { + "cursorless.experimental.keyboard.modal.keybindings.vscodeCommand": { "description": "Define modal keybindings for running vscode commands", "type": "object", "additionalProperties": { @@ -944,7 +944,7 @@ ] } }, - "cursorless.experimental.keyboard.modal.keybindings.colors": { + "cursorless.experimental.keyboard.modal.keybindings.color": { "description": "Define modal keybindings for colors", "type": "object", "additionalProperties": { @@ -961,7 +961,7 @@ ] } }, - "cursorless.experimental.keyboard.modal.keybindings.shapes": { + "cursorless.experimental.keyboard.modal.keybindings.shape": { "description": "Define modal keybindings for shapes", "type": "object", "additionalProperties": { @@ -980,7 +980,7 @@ ] } }, - "cursorless.experimental.keyboard.modal.keybindings.scopes": { + "cursorless.experimental.keyboard.modal.keybindings.scope": { "description": "Define modal keybindings for scopes", "type": "object", "additionalProperties": { @@ -1091,7 +1091,7 @@ "funding": "https://github.com/sponsors/pokey", "scripts": { "build": "pnpm run esbuild:prod && pnpm -F cheatsheet-local build:prod && pnpm run populate-dist", - "build:dev": "pnpm run esbuild && pnpm -F cheatsheet-local build && pnpm run populate-dist", + "build:dev": "pnpm generate-grammar && pnpm run esbuild && pnpm -F cheatsheet-local build && pnpm run populate-dist", "esbuild:base": "esbuild ./src/extension.ts --conditions=cursorless:bundler --bundle --outfile=dist/extension.cjs --external:vscode --format=cjs --platform=node", "install-local": "bash ./scripts/install-local.sh", "install-from-pr": "bash ./scripts/install-from-pr.sh", @@ -1104,6 +1104,11 @@ "preprocess-svg-hats": "my-ts-node src/scripts/preprocessSvgHats.ts", "hat-adjustment-add": "my-ts-node src/scripts/hatAdjustments/add.ts", "hat-adjustment-average": "my-ts-node src/scripts/hatAdjustments/average.ts", + "generate-grammar:base": "nearleyc src/keyboard/grammar/grammar.ne", + "ensure-grammar-up-to-date": "pnpm -s generate-grammar:base | diff -u src/keyboard/grammar/generated/grammar.ts -", + "generate-grammar": "pnpm generate-grammar:base -o src/keyboard/grammar/generated/grammar.ts", + "generate-railroad": "nearley-railroad src/keyboard/grammar/grammar.ne -o out/railroad.html", + "test": "pnpm ensure-grammar-up-to-date", "compile": "tsc --build", "watch": "tsc --build --watch", "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build" @@ -1115,6 +1120,7 @@ "@types/js-yaml": "^4.0.2", "@types/lodash": "4.14.181", "@types/mocha": "^10.0.3", + "@types/nearley": "2.11.5", "@types/node": "^18.18.2", "@types/semver": "^7.3.9", "@types/sinon": "^10.0.2", @@ -1135,8 +1141,10 @@ "@cursorless/vscode-common": "workspace:*", "itertools": "^2.1.1", "lodash": "^4.17.21", + "nearley": "2.20.1", "semver": "^7.5.2", "tinycolor2": "1.6.0", + "trie-search": "2.0.0", "uuid": "^9.0.0", "vscode-uri": "^3.0.6" }, diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 0fc0096262..50104316c0 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -103,7 +103,11 @@ export async function activate( addCommandRunnerDecorator(testCaseRecorder); const statusBarItem = StatusBarItem.create("cursorless.showQuickPick"); - const keyboardCommands = KeyboardCommands.create(context, statusBarItem); + const keyboardCommands = KeyboardCommands.create( + context, + vscodeApi, + statusBarItem, + ); const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider); context.subscriptions.push( revisualizeOnCustomRegexChange(scopeVisualizer, scopeProvider), diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardActionType.ts b/packages/cursorless-vscode/src/keyboard/KeyboardActionType.ts new file mode 100644 index 0000000000..63e33f67cc --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/KeyboardActionType.ts @@ -0,0 +1,42 @@ +import { ActionType, actionNames } from "@cursorless/common"; + +// This file contains types defining the allowable identifiers for actions in +// user keyboard config settings. It is a modified version of the default action +// identifiers from @cursorless/common, with the addition of the "wrap" action +// that is designed to function like the "wrap" spoken form (ie use the same spoken +// form for both snippet and delimiter wrapping). + +const extraKeyboardActionNames = ["wrap"] as const; +const excludedKeyboardActionNames = [ + "wrapWithPairedDelimiter", + "wrapWithSnippet", +] as const; +const complexKeyboardActionTypes = ["wrap"] as const; + +type ExtraKeyboardActionType = (typeof extraKeyboardActionNames)[number]; +type ExcludedKeyboardActionType = (typeof excludedKeyboardActionNames)[number]; +type ComplexKeyboardActionType = (typeof complexKeyboardActionTypes)[number]; +export type SimpleKeyboardActionType = Exclude< + KeyboardActionType, + ComplexKeyboardActionType +>; +export type KeyboardActionType = + | Exclude + | ExtraKeyboardActionType; + +const keyboardActionNames: KeyboardActionType[] = [ + ...actionNames.filter( + ( + actionName, + ): actionName is Exclude => + !excludedKeyboardActionNames.includes(actionName as any), + ), + ...extraKeyboardActionNames, +]; + +export const simpleKeyboardActionNames = keyboardActionNames.filter( + (actionName): actionName is SimpleKeyboardActionType => + !complexKeyboardActionTypes.includes( + actionName as ComplexKeyboardActionType, + ), +); diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts new file mode 100644 index 0000000000..43c38b3d6f --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts @@ -0,0 +1,119 @@ +import { ScopeType } from "@cursorless/common"; +import * as vscode from "vscode"; +import { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; +import { SimpleKeyboardActionType } from "./KeyboardActionType"; +import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted"; +import { ModalVscodeCommandDescriptor } from "./TokenTypes"; + +/** + * This class defines the keyboard commands available to our modal keyboard + * mode. + * + * Each method in this class corresponds to a top-level rule in the grammar. The + * method name is the name of the rule, and the method's argument is the rule's + * `arg` output. + * + * We try to keep all logic out of the grammar and use this class instead + * because: + * + * 1. The grammar has no type information, autocomplete, or autoformatting + * 2. If the grammar is defined by just a list of keys, as it is today, we can + * actually detect partial arguments as they're being constructed and display + * them to the user + * + * Thus, we use this class as a simple layer where we have strong types and can + * do some simple logic. + */ +export class KeyboardCommandHandler { + constructor(private targeted: KeyboardCommandsTargeted) {} + + targetDecoratedMarkReplace({ decoratedMark }: DecoratedMarkArg) { + this.targeted.targetDecoratedMark(decoratedMark); + } + + targetDecoratedMarkExtend({ decoratedMark }: DecoratedMarkArg) { + this.targeted.targetDecoratedMark({ + ...decoratedMark, + mode: "extend", + }); + } + + async vscodeCommand({ + command: commandInfo, + }: { + command: ModalVscodeCommandDescriptor; + }) { + // plain ol' string command id + if (isString(commandInfo)) { + await vscode.commands.executeCommand(commandInfo); + return; + } + + // structured command + const { + commandId, + args, + executeAtTarget, + keepChangedSelection, + exitCursorlessMode, + } = commandInfo; + + if (executeAtTarget) { + await this.targeted.performVscodeCommandOnTarget(commandId, { + args, + keepChangedSelection, + exitCursorlessMode, + }); + return; + } + + await vscode.commands.executeCommand(commandId, ...(args ?? [])); + } + + performSimpleActionOnTarget({ + actionName, + }: { + actionName: SimpleKeyboardActionType; + }) { + this.targeted.performActionOnTarget(actionName); + } + + modifyTargetContainingScope(arg: { scopeType: ScopeType }) { + this.targeted.modifyTargetContainingScope(arg); + } + + targetRelativeExclusiveScope({ + offset, + length, + scopeType, + }: TargetRelativeExclusiveScopeArg) { + this.targeted.targetModifier({ + type: "relativeScope", + offset: offset?.number ?? 1, + direction: offset?.direction ?? "forward", + length: length ?? 1, + scopeType, + }); + } +} + +interface DecoratedMarkArg { + decoratedMark: { + color?: HatColor; + shape?: HatShape; + }; +} +interface TargetRelativeExclusiveScopeArg { + offset: Offset; + length: number | null; + scopeType: ScopeType; +} + +interface Offset { + direction: "forward" | "backward" | null; + number: number | null; +} + +function isString(input: any): input is string { + return typeof input === "string" || input instanceof String; +} diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandTypeHelpers.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandTypeHelpers.ts new file mode 100644 index 0000000000..2091dfa3f2 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandTypeHelpers.ts @@ -0,0 +1,40 @@ +import { KeyboardCommandHandler } from "./KeyboardCommandHandler"; + +/** + * Maps from the name of a method in KeyboardCommandHandler to the type of its + * argument. + */ +export type KeyboardCommandArgTypes = { + [K in keyof KeyboardCommandHandler]: KeyboardCommandHandler[K] extends ( + arg: infer T, + ) => void + ? T + : never; +}; + +export type KeyboardCommandTypeMap = { + [K in keyof KeyboardCommandHandler]: { + type: K; + arg: KeyboardCommandArgTypes[K]; + }; +}; + +export type KeyboardCommand = { + type: T; + arg: KeyboardCommandArgTypes[T]; +}; + +// Ensure that all methods in KeyboardCommandHandler take an object as their +// first argument, and return void or Promise. Note that the first check +// may look backwards, because the arg type is contravariant, so the 'extends' +// needs to be flipped. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function assertExtends() {} +assertExtends< + Record never>, + Pick +>; +assertExtends< + Pick, + Record void | Promise> +>; diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommands.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommands.ts index 445e5be4e2..e24a77eaef 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommands.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommands.ts @@ -3,6 +3,7 @@ import KeyboardCommandsModal from "./KeyboardCommandsModal"; import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted"; import KeyboardHandler from "./KeyboardHandler"; import { StatusBarItem } from "../StatusBarItem"; +import { VscodeApi } from "@cursorless/vscode-common"; export class KeyboardCommands { targeted: KeyboardCommandsTargeted; @@ -10,7 +11,8 @@ export class KeyboardCommands { keyboardHandler: KeyboardHandler; private constructor( - private context: ExtensionContext, + context: ExtensionContext, + vscodeApi: VscodeApi, statusBarItem: StatusBarItem, ) { this.keyboardHandler = new KeyboardHandler(context, statusBarItem); @@ -19,11 +21,20 @@ export class KeyboardCommands { context, this.targeted, this.keyboardHandler, + vscodeApi, ); } - static create(context: ExtensionContext, statusBarItem: StatusBarItem) { - const keyboardCommands = new KeyboardCommands(context, statusBarItem); + static create( + context: ExtensionContext, + vscodeApi: VscodeApi, + statusBarItem: StatusBarItem, + ) { + const keyboardCommands = new KeyboardCommands( + context, + vscodeApi, + statusBarItem, + ); keyboardCommands.init(); return keyboardCommands; } diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts index e5c03df801..faebcf2204 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts @@ -1,30 +1,18 @@ -import { isTesting } from "@cursorless/common"; -import { keys, merge, toPairs } from "lodash"; +import { pick, toPairs } from "lodash"; +import { Grammar, Parser } from "nearley"; import * as vscode from "vscode"; +import { KeyboardCommandsModalLayer } from "./KeyboardCommandsModalLayer"; import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted"; +import { KeyDescriptor } from "./TokenTypeHelpers"; +import { TokenTypeKeyMapMap } from "./TokenTypeHelpers"; import KeyboardHandler from "./KeyboardHandler"; -import { - DEFAULT_ACTION_KEYMAP, - DEFAULT_COLOR_KEYMAP, - DEFAULT_SCOPE_KEYMAP, - DEFAULT_SHAPE_KEYMAP, - DEFAULT_VSCODE_COMMAND_KEYMAP, - Keymap, - ModalVscodeCommandDescriptor, -} from "./defaultKeymaps"; - -type SectionName = - | "actions" - | "scopes" - | "colors" - | "shapes" - | "vscodeCommands"; - -interface KeyHandler { - sectionName: SectionName; - value: T; - handleValue(): Promise; -} +import grammar from "./grammar/generated/grammar"; +import { getAcceptableTokenTypes } from "./grammar/getAcceptableTokenTypes"; +import { KeyboardCommandHandler } from "./KeyboardCommandHandler"; +import { getTokenTypeKeyMaps } from "./getTokenTypeKeyMaps"; +import { VscodeApi } from "@cursorless/vscode-common"; +import { KeyboardConfig } from "./KeyboardConfig"; +import { CompositeKeyMap } from "@cursorless/common"; /** * Defines a mode to use with a modal version of Cursorless keyboard. @@ -41,18 +29,29 @@ export default class KeyboardCommandsModal { * Merged map from all the different sections of the key map (eg actions, * colors, etc). */ - private mergedKeymap!: Record>; + private currentLayer!: KeyboardCommandsModalLayer; + private layerCache = new CompositeKeyMap< + string[], + KeyboardCommandsModalLayer + >((keys) => keys); + private parser!: Parser; + private sections!: TokenTypeKeyMapMap; + private keyboardCommandHandler: KeyboardCommandHandler; + private compiledGrammar = Grammar.fromCompiled(grammar); + private keyboardConfig: KeyboardConfig; constructor( private extensionContext: vscode.ExtensionContext, private targeted: KeyboardCommandsTargeted, private keyboardHandler: KeyboardHandler, + vscodeApi: VscodeApi, ) { this.modeOn = this.modeOn.bind(this); this.modeOff = this.modeOff.bind(this); this.handleInput = this.handleInput.bind(this); - this.constructMergedKeymap(); + this.keyboardConfig = new KeyboardConfig(vscodeApi); + this.keyboardCommandHandler = new KeyboardCommandHandler(targeted); } init() { @@ -63,106 +62,48 @@ export default class KeyboardCommandsModal { "cursorless.experimental.keyboard.modal.keybindings", ) ) { - this.constructMergedKeymap(); + if (this.isModeOn()) { + this.modeOff(); + this.modeOn(); + } + this.layerCache.clear(); + this.processKeyMap(); } }), ); } - private async handleVscodeCommand(commandInfo: ModalVscodeCommandDescriptor) { - const { - commandId, - args, - executeAtTarget, - keepChangedSelection, - exitCursorlessMode, - } = - typeof commandInfo === "string" || commandInfo instanceof String - ? ({ commandId: commandInfo } as Exclude< - ModalVscodeCommandDescriptor, - string - >) - : commandInfo; - if (executeAtTarget) { - return await this.targeted.performVscodeCommandOnTarget(commandId, { - args, - keepChangedSelection, - exitCursorlessMode, - }); - } - return await vscode.commands.executeCommand(commandId, ...(args ?? [])); + private processKeyMap() { + this.sections = getTokenTypeKeyMaps(this.keyboardConfig); + this.resetParser(); } - private constructMergedKeymap() { - this.mergedKeymap = {}; - - this.handleSection("actions", DEFAULT_ACTION_KEYMAP, (value) => - this.targeted.performActionOnTarget(value), - ); - this.handleSection("scopes", DEFAULT_SCOPE_KEYMAP, (value) => - this.targeted.targetScopeType({ - scopeType: value, - }), - ); - this.handleSection("colors", DEFAULT_COLOR_KEYMAP, (value) => - this.targeted.targetDecoratedMark({ - color: value, - }), - ); - this.handleSection("shapes", DEFAULT_SHAPE_KEYMAP, (value) => - this.targeted.targetDecoratedMark({ - shape: value, - }), - ); - this.handleSection( - "vscodeCommands", - DEFAULT_VSCODE_COMMAND_KEYMAP, - (value) => this.handleVscodeCommand(value), - ); + private resetParser() { + this.parser = new Parser(this.compiledGrammar); + this.computeLayer(); } /** - * Adds a section (eg actions, scopes, etc) to the merged keymap. - * - * @param sectionName The name of the section (eg `"actions"`, `"scopes"`, etc) - * @param defaultKeyMap The default values for this keymap - * @param handleValue The function to call when the user presses the given key + * Given the current state of the parser, computes a keyboard layer containing + * only the keys that are currently valid. */ - private handleSection( - sectionName: SectionName, - defaultKeyMap: Keymap, - handleValue: (value: T) => Promise, - ) { - const userOverrides: Keymap = isTesting() - ? {} - : vscode.workspace - .getConfiguration( - "cursorless.experimental.keyboard.modal.keybindings", - ) - .get>(sectionName) ?? {}; - const keyMap = merge({}, defaultKeyMap, userOverrides); - - for (const [key, value] of toPairs(keyMap)) { - const conflictingEntry = this.getConflictingKeyMapEntry(key); - if (conflictingEntry != null) { - const { sectionName: conflictingSection, value: conflictingValue } = - conflictingEntry; - - vscode.window.showErrorMessage( - `Conflicting keybindings: \`${sectionName}.${value}\` and \`${conflictingSection}.${conflictingValue}\` both want key '${key}'`, - ); - - continue; - } - - const entry: KeyHandler = { - sectionName, - value, - handleValue: () => handleValue(value), - }; - - this.mergedKeymap[key] = entry; + private computeLayer() { + const acceptableTokenTypeInfos = getAcceptableTokenTypes(this.parser); + // FIXME: Here's where we'd update sidebar + const acceptableTokenTypes = acceptableTokenTypeInfos + .map(({ type }) => type) + .sort(); + let layer = this.layerCache.get(acceptableTokenTypes); + if (layer == null) { + layer = new KeyboardCommandsModalLayer( + this.keyboardHandler, + Object.values(pick(this.sections, acceptableTokenTypes)).flatMap( + toPairs, + ), + ); + this.layerCache.set(acceptableTokenTypes, layer); } + this.currentLayer = layer; } modeOn = async () => { @@ -170,6 +111,12 @@ export default class KeyboardCommandsModal { return; } + if (this.currentLayer == null) { + // Construct keymap lazily for ease of mocking and to save performance + // when the mode is never used + this.processKeyMap(); + } + this.inputDisposable = this.keyboardHandler.pushListener({ handleInput: this.handleInput, displayOptions: { @@ -184,6 +131,71 @@ export default class KeyboardCommandsModal { await this.targeted.targetSelection(); }; + async handleInput(text: string) { + try { + /** + * The text to feed to the layer. This will be a single character + * initially, when we're called by {@link KeyboardHandler}. We pass it to + * the layer, which will ask for more characters if necessary to complete + * the key sequence for a single parser token. + * + * If the parser wants more tokens, we set this to "" so that the layer + * can ask for characters for the next token from scratch. + */ + let currentText = text; + let previousKeys = ""; + while (true) { + const layerOutput = await this.currentLayer.handleInput(currentText, { + previousKeys, + }); + if (layerOutput == null) { + throw new KeySequenceCancelledError(); + } + + this.parser.feed([layerOutput.value]); + + if (this.parser.results.length > 0) { + // We've found a valid parse + break; + } + + currentText = ""; + previousKeys += layerOutput.keysPressed; + this.computeLayer(); + } + + if (this.parser.results.length > 1) { + console.error("Ambiguous parse:"); + console.error(JSON.stringify(this.parser.results, null, 2)); + throw new Error("Ambiguous parse; see console output"); + } + + const nextTokenTypes = getAcceptableTokenTypes(this.parser); + if (nextTokenTypes.length > 0) { + // Because we stop as soon as a valid parse is found, there shouldn't + // be any way to continue + console.error( + "Ambiguous whether parsing is complete. Possible following tokens:", + ); + console.error(JSON.stringify(nextTokenTypes, null, 2)); + throw new Error("Ambiguous parse; see console output"); + } + + const [{ type, arg }] = this.parser.results; + + // Run the command + this.keyboardCommandHandler[type as keyof KeyboardCommandHandler](arg); + } catch (err) { + if (!(err instanceof KeySequenceCancelledError)) { + vscode.window.showErrorMessage((err as Error).message); + throw err; + } + } finally { + // Always reset the parser when we're done + this.resetParser(); + } + } + modeOff = async () => { if (!this.isModeOn()) { return; @@ -207,54 +219,10 @@ export default class KeyboardCommandsModal { private isModeOn() { return this.inputDisposable != null; } +} - async handleInput(text: string) { - let sequence = text; - let keyHandler: KeyHandler | undefined = this.mergedKeymap[sequence]; - - // We handle multi-key sequences by repeatedly awaiting a single keypress - // until they've pressed something in the map. - while (keyHandler == null) { - if (!this.isPrefixOfKey(sequence)) { - const errorMessage = `Unknown key sequence "${sequence}"`; - vscode.window.showErrorMessage(errorMessage); - throw Error(errorMessage); - } - - const nextKey = await this.keyboardHandler.awaitSingleKeypress({ - cursorStyle: vscode.TextEditorCursorStyle.Underline, - whenClauseContext: "cursorless.keyboard.targeted.awaitingKeys", - statusBarText: "Finish sequence...", - }); - - if (nextKey == null) { - return; - } - - sequence += nextKey; - keyHandler = this.mergedKeymap[sequence]; - } - - keyHandler.handleValue(); - } - - isPrefixOfKey(text: string): boolean { - return keys(this.mergedKeymap).some((key) => key.startsWith(text)); - } - - /** - * This function can be used to deterct if a proposed map entry conflicts with - * one in the map. Used to detect if the user tries to use two map entries, - * one of which is a prefix of the other. - * @param text The proposed new map entry - * @returns The first map entry that conflicts with {@link text}, if one - * exists - */ - getConflictingKeyMapEntry(text: string): KeyHandler | undefined { - const conflictingPair = toPairs(this.mergedKeymap).find( - ([key]) => text.startsWith(key) || key.startsWith(text), - ); - - return conflictingPair == null ? undefined : conflictingPair[1]; +class KeySequenceCancelledError extends Error { + constructor() { + super("Key sequence cancelled"); } } diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModalLayer.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModalLayer.ts new file mode 100644 index 0000000000..c6fbb87e98 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModalLayer.ts @@ -0,0 +1,87 @@ +import * as vscode from "vscode"; +import KeyboardHandler from "./KeyboardHandler"; +import TrieSearch from "trie-search"; +import { buildSuffixTrie, KeyValuePair } from "./buildSuffixTrie"; + +/** + * Defines a single keyboard layer to use with a modal version of Cursorless + * keyboard. We construct one of these every time the parser changes state, + * based on the allowable tokens at the given state. + */ +export class KeyboardCommandsModalLayer { + /** + * Merged map from all the different sections of the key map (eg actions, + * colors, etc). + */ + private trie: TrieSearch<{ + key: string; + value: Param; + }>; + + constructor( + private keyboardHandler: KeyboardHandler, + entries: [string, Param][], + ) { + this.handleInput = this.handleInput.bind(this); + if (entries.length === 0) { + vscode.window.showErrorMessage("No keybindings found for current layer"); + } + const { trie, conflicts } = buildSuffixTrie(entries); + + for (const conflict of conflicts) { + const conflictStr = conflict + .map(({ key, value: { type } }) => `\`${type}.${key}\``) + .join(" and "); + vscode.window.showErrorMessage(`Conflicting keybindings: ${conflictStr}`); + } + + this.trie = trie; + } + + async handleInput(text: string, { previousKeys }: { previousKeys: string }) { + let values: KeyValuePair[]; + + if (text === "") { + values = []; + } else { + values = this.trie.search(text); + + if (values.length === 0) { + // If we haven't consumed any input yet, then it means the first + // character was a false start so we should cancel the whole thing. + const errorMessage = `Invalid key '${text}'`; + vscode.window.showErrorMessage(errorMessage); + throw Error(errorMessage); + } + } + + let sequence = text; + + // We handle multi-key sequences by repeatedly awaiting a single keypress + // until they've pressed something in the map. + while (values.length !== 1) { + const nextKey = await this.keyboardHandler.awaitSingleKeypress({ + cursorStyle: vscode.TextEditorCursorStyle.Underline, + whenClauseContext: "cursorless.keyboard.targeted.awaitingKeys", + statusBarText: `${previousKeys + sequence}...`, + }); + + if (nextKey == null) { + return undefined; + } + + const possibleNextSequence = sequence + nextKey; + const possibleNextValues = this.trie.search(possibleNextSequence); + if (possibleNextValues.length === 0) { + const errorMessage = `Invalid key '${nextKey}'`; + vscode.window.showErrorMessage(errorMessage); + continue; + } + + sequence = possibleNextSequence; + values = possibleNextValues; + } + + return { keysPressed: sequence, value: values[0].value }; + } +} diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts index b38b589986..7fd98899de 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts @@ -2,9 +2,10 @@ import { ActionDescriptor, ActionType, LATEST_VERSION, + Modifier, PartialPrimitiveTargetDescriptor, PartialTargetDescriptor, - SimpleScopeTypeType, + ScopeType, } from "@cursorless/common"; import { runCursorlessCommand } from "@cursorless/vscode-common"; import * as vscode from "vscode"; @@ -26,8 +27,8 @@ interface TargetDecoratedMarkArgument { mode?: TargetingMode; } -interface TargetScopeTypeArgument { - scopeType: SimpleScopeTypeType; +interface ModifyTargetContainingScopeArgument { + scopeType: ScopeType; type?: "containingScope" | "everyScope"; } @@ -44,7 +45,8 @@ export default class KeyboardCommandsTargeted { this.performActionOnTarget = this.performActionOnTarget.bind(this); this.performVscodeCommandOnTarget = this.performVscodeCommandOnTarget.bind(this); - this.targetScopeType = this.targetScopeType.bind(this); + this.modifyTargetContainingScope = + this.modifyTargetContainingScope.bind(this); this.targetSelection = this.targetSelection.bind(this); this.clearTarget = this.clearTarget.bind(this); } @@ -131,10 +133,10 @@ export default class KeyboardCommandsTargeted { * @param param0 Describes the desired scope type * @returns A promise that resolves to the result of the cursorless command */ - targetScopeType = async ({ + modifyTargetContainingScope = async ({ scopeType, type = "containingScope", - }: TargetScopeTypeArgument) => + }: ModifyTargetContainingScopeArgument) => await executeCursorlessCommand({ name: "highlight", target: { @@ -142,9 +144,7 @@ export default class KeyboardCommandsTargeted { modifiers: [ { type, - scopeType: { - type: scopeType, - }, + scopeType, }, ], mark: { @@ -153,6 +153,23 @@ export default class KeyboardCommandsTargeted { }, }); + /** + * Applies {@link modifier} to the current target + * @param param0 Describes the desired modifier + * @returns A promise that resolves to the result of the cursorless command + */ + targetModifier = async (modifier: Modifier) => + await executeCursorlessCommand({ + name: "highlight", + target: { + type: "primitive", + modifiers: [modifier], + mark: { + type: "that", + }, + }, + }); + private highlightTarget = () => executeCursorlessCommand({ name: "highlight", diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardConfig.ts b/packages/cursorless-vscode/src/keyboard/KeyboardConfig.ts new file mode 100644 index 0000000000..8a12a5df73 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/KeyboardConfig.ts @@ -0,0 +1,113 @@ +import { mapValues, pickBy } from "lodash"; +import { KeyMap, SectionName, TokenType } from "./TokenTypeHelpers"; +import { SectionTypes, TokenTypeValueMap } from "./TokenTypes"; +import { VscodeApi } from "@cursorless/vscode-common"; + +const LEGACY_PLURAL_SECTION_NAMES: Record = { + action: "actions", + color: "colors", + shape: "shapes", + vscodeCommand: "vscodeCommands", + scope: "scopes", +}; + +export class KeyboardConfig { + constructor(private vscodeApi: VscodeApi) {} + + /** + * Returns a keymap for a given config section that is intended to be further + * processed by eg {@link getSectionEntries} or {@link getSingularSectionEntry}. + * @param sectionName The name of the config section + * @returns A keymap for a given config section + */ + private getSectionKeyMapRaw( + sectionName: S, + ): KeyMap { + const getSection = ( + sectionName: string, + ): KeyMap | undefined => + this.vscodeApi.workspace + .getConfiguration("cursorless.experimental.keyboard.modal.keybindings") + .get>(sectionName); + + let section = getSection(sectionName); + + if (section == null || Object.keys(section).length === 0) { + const legacySectionName = LEGACY_PLURAL_SECTION_NAMES[sectionName]; + + if (legacySectionName != null) { + section = getSection(legacySectionName); + if (section != null && Object.keys(section).length > 0) { + this.vscodeApi.window.showWarningMessage( + `The config section "cursorless.experimental.keyboard.modal.keybindings.${legacySectionName}" is deprecated. Please rename it to "cursorless.experimental.keyboard.modal.keybindings.${sectionName}".`, + ); + } + } + } + + return section ?? {}; + } + + /** + * Returns a keymap mapping from key sequences to tokens for use in our key + * sequence parser. If `sectionName` is omitted, it defaults to `type`. If + * `only` is provided, we filter to include only entries with these values. + * + * Example: + * + * ```ts + * assert.equal( + * getTokenKeyMap("direction", "misc", ["forward", "backward"]), + * { + * "f": { type: "direction", value: "forward" }, + * "b": { type: "direction", value: "backward" }, + * }, + * ); + * ``` + * + * @param tokenType The type of the token + * @param sectionName The name of the config section + * @param only If provided, only entries with these values will be returned + * @returns A keymap with entries only for the given value + */ + getTokenKeyMap( + tokenType: T, + ): KeyMap<{ type: T; value: SectionTypes[T] }>; + getTokenKeyMap( + tokenType: T, + sectionName: K, + ): KeyMap<{ type: T; value: SectionTypes[K] }>; + getTokenKeyMap< + T extends TokenType, + K extends keyof SectionTypes, + V extends SectionTypes[K] & TokenTypeValueMap[T] = SectionTypes[K] & + TokenTypeValueMap[T], + >(tokenType: T, sectionName: K, only: V[]): KeyMap<{ type: T; value: V }>; + getTokenKeyMap< + T extends TokenType, + K extends keyof SectionTypes, + V extends SectionTypes[K] & TokenTypeValueMap[T] = SectionTypes[K] & + TokenTypeValueMap[T], + >( + tokenType: T, + sectionName: K = tokenType as unknown as K, + only?: V[], + ): KeyMap<{ type: T; value: V }> { + const section = this.getSectionKeyMapRaw(sectionName); + + if (only == null) { + return mapValues(section, (value) => ({ + type: tokenType, + value: value as V, + })); + } + + return mapValues( + pickBy(section, (v): v is V => only.includes(v as V)), + (value) => ({ + type: tokenType, + value, + }), + ); + } +} diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardHandler.ts b/packages/cursorless-vscode/src/keyboard/KeyboardHandler.ts index 80e161fd9c..ae18da46e2 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardHandler.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardHandler.ts @@ -145,7 +145,15 @@ export default class KeyboardHandler { } isDisposed = true; - pull(this.listeners, listenerEntry); + const index = this.listeners.indexOf(listenerEntry); + // Call handleCancelled on all listeners that were pushed after this + // one. Eg if you're in the middle of typing a command and we turn off + // the modal mode, we want to cancel the command + this.listeners + .slice(index + 1) + .reverse() + .forEach(({ listener }) => listener.handleCancelled()); + this.listeners.splice(index); this.ensureState(); }, }; diff --git a/packages/cursorless-vscode/src/keyboard/TokenTypeHelpers.ts b/packages/cursorless-vscode/src/keyboard/TokenTypeHelpers.ts new file mode 100644 index 0000000000..ac35d89ac9 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/TokenTypeHelpers.ts @@ -0,0 +1,18 @@ +import { SectionTypes, TokenTypeValueMap } from "./TokenTypes"; + +export type SectionName = keyof SectionTypes; + +export type TokenType = keyof TokenTypeValueMap; + +type TokenTypeKeyDescriptorMap = { + [K in keyof TokenTypeValueMap]: { type: K; value: TokenTypeValueMap[K] }; +}; + +export type TokenTypeKeyMapMap = { + readonly [K in keyof TokenTypeValueMap]: KeyMap; +}; + +export type KeyDescriptor = + TokenTypeKeyDescriptorMap[keyof TokenTypeKeyDescriptorMap]; + +export type KeyMap = Record; diff --git a/packages/cursorless-vscode/src/keyboard/TokenTypes.ts b/packages/cursorless-vscode/src/keyboard/TokenTypes.ts new file mode 100644 index 0000000000..f40e59c350 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/TokenTypes.ts @@ -0,0 +1,74 @@ +import { SimpleScopeTypeType } from "@cursorless/common"; +import { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; +import { + KeyboardActionType, + SimpleKeyboardActionType, +} from "./KeyboardActionType"; + +/** + * Maps from modal keyboard config section name to the type of entry expected in + * that section. + */ +export interface SectionTypes { + action: KeyboardActionType; + color: HatColor; + misc: MiscValue; + scope: SimpleScopeTypeType; + shape: HatShape; + vscodeCommand: ModalVscodeCommandDescriptor; + modifier: ModifierType; +} +type ModifierType = "nextPrev"; +type MiscValue = + | "combineColorAndShape" + | "makeRange" + | "makeList" + | "forward" + | "backward"; + +/** + * Maps from token type used in parser to the type of values that the token type + * can have. There are a few kinds of token types: + * + * 1. Those directly corresponding to a section in the config. These will have + * the same name and type as the corresponding section in + * {@link SectionTypes}. For example, {@link color} + * 2. Those corresponding to subset of entries in a config section. For example, + * {@link simpleAction} + * 3. Those corresponding to a single entry in a config section. These are + * tokens that need some special grammatical treatment. They will have a type + * which is a constant string equal to the key name. For example, + * {@link makeRange} + * 4. Others. These are tokens that are not directly related to the config. For + * example, {@link digit} + */ +export interface TokenTypeValueMap { + // tokens corresponding exactly to config sections + simpleScopeTypeType: SimpleScopeTypeType; + color: HatColor; + shape: HatShape; + vscodeCommand: ModalVscodeCommandDescriptor; + + // action config section + simpleAction: SimpleKeyboardActionType; + + // misc config section + makeRange: "makeRange"; + combineColorAndShape: "combineColorAndShape"; + direction: "forward" | "backward"; + + // modifier config section + nextPrev: "nextPrev"; + + digit: number; +} + +export type ModalVscodeCommandDescriptor = + | string + | { + commandId: string; + args?: unknown[]; + executeAtTarget?: boolean; + keepChangedSelection?: boolean; + exitCursorlessMode?: boolean; + }; diff --git a/packages/cursorless-vscode/src/keyboard/buildSuffixTrie.test.ts b/packages/cursorless-vscode/src/keyboard/buildSuffixTrie.test.ts new file mode 100644 index 0000000000..1e65c5f6fc --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/buildSuffixTrie.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert"; +import { KeyValuePair, buildSuffixTrie } from "./buildSuffixTrie"; +import { isEqual, sortBy, uniq, uniqWith } from "lodash"; + +interface TestCase { + input: string[]; + expected: KeyValuePair[]; + expectedConflicts?: KeyValuePair[][]; +} + +const testCases: TestCase[] = [ + { + input: ["a", "b", "c"], + expected: [ + { key: "a", value: "a" }, + { key: "b", value: "b" }, + { key: "c", value: "c" }, + ], + }, + { + input: ["ab", "c"], + expected: [ + { key: "ab", value: "ab" }, + { key: "b", value: "ab" }, + { key: "c", value: "c" }, + ], + }, + { + input: ["ab", "b"], + expected: [ + { key: "ab", value: "ab" }, + { key: "b", value: "b" }, + ], + }, + { + input: ["a", "ab"], + expected: [{ key: "b", value: "ab" }], + expectedConflicts: [ + [ + { key: "a", value: "a" }, + { key: "ab", value: "ab" }, + ], + ], + }, + { + input: ["ab", "cbd"], + expected: [ + { key: "ab", value: "ab" }, + { key: "cbd", value: "cbd" }, + { key: "d", value: "cbd" }, + ], + }, + { + input: ["a", "bac"], + expected: [ + { key: "a", value: "a" }, + { key: "bac", value: "bac" }, + { key: "c", value: "bac" }, + ], + }, + { + input: ["ab", "bc"], + expected: [ + { key: "ab", value: "ab" }, + { key: "bc", value: "bc" }, + { key: "c", value: "bc" }, + ], + }, + { + input: ["az", "bz", "c"], + expected: [ + { key: "az", value: "az" }, + { key: "bz", value: "bz" }, + { key: "c", value: "c" }, + ], + }, + { + input: ["ab", "cde", "cxe"], + expected: [ + { key: "ab", value: "ab" }, + { key: "b", value: "ab" }, + { key: "cde", value: "cde" }, + { key: "de", value: "cde" }, + { key: "cxe", value: "cxe" }, + { key: "xe", value: "cxe" }, + ], + }, + { + input: ["ab", "ac"], + expected: [ + { key: "ab", value: "ab" }, + { key: "ac", value: "ac" }, + { key: "b", value: "ab" }, + { key: "c", value: "ac" }, + ], + }, + { + input: ["aa"], + expected: [ + { key: "aa", value: "aa" }, + { key: "a", value: "aa" }, + ], + }, + { + input: ["aa", "ab"], + expected: [ + { key: "aa", value: "aa" }, + { key: "ab", value: "ab" }, + { key: "b", value: "ab" }, + ], + }, + { + input: ["a", "A"], + expected: [ + { key: "a", value: "a" }, + { key: "A", value: "A" }, + ], + }, +]; + +suite("buildSuffixTrie", () => { + testCases.forEach(({ input, expected, expectedConflicts }) => { + test(`input: ${input}`, () => { + const { trie, conflicts } = buildSuffixTrie( + input.map((key) => [key, key]), + ); + const chars = uniq(input.flatMap((key) => key.split(""))).sort(); + const actual = uniqWith( + sortEntries(chars.flatMap((char) => trie.search(char))), + isEqual, + ); + assert.deepStrictEqual(actual, sortEntries(expected)); + assert.deepStrictEqual( + sortBy(conflicts.map(sortEntries), (conflict) => + JSON.stringify(conflict), + ), + (expectedConflicts ?? []).map(sortEntries), + ); + }); + }); +}); + +function sortEntries(entries: KeyValuePair[]) { + return sortBy(entries, ["key", "value"]); +} diff --git a/packages/cursorless-vscode/src/keyboard/buildSuffixTrie.ts b/packages/cursorless-vscode/src/keyboard/buildSuffixTrie.ts new file mode 100644 index 0000000000..964aa73326 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/buildSuffixTrie.ts @@ -0,0 +1,115 @@ +import { isEqual, range, sortBy, uniqWith } from "lodash"; +import TrieSearch, { TrieSearchOptions } from "trie-search"; + +export interface KeyValuePair { + key: string; + value: T; +} + +interface BuildTrieReturn { + trie: TrieSearch>; + conflicts: KeyValuePair[][]; +} +type InternalEntryType = { + isTopLevel: boolean; + key: string; + value: T; + id: number; +}; + +/** + * Build a trie containing all the keymaps, as well as non-conflicting suffixes + * of each key. Also returns a list of all the conflicting keymaps. If any key is + * a prefix of any other key, we consider them to be conflicting. + * @param keyMaps The keymaps to build the trie from + * @returns The trie, and a list of conflicting keymaps + */ +export function buildSuffixTrie(entries: [string, T][]): BuildTrieReturn { + const options: TrieSearchOptions> & + TrieSearchOptions> = { + splitOnRegEx: undefined, + ignoreCase: false, + }; + + /** A trie containing all possible entries, including conflicting */ + const candidateTrie = new TrieSearch>("key", options); + + let id = 0; + /** Includes an entry for every suffix of every entry in {@link entries}, + * including {@link entries} themselves, which have `isTopLevel: true` */ + const candidateEntries = entries.flatMap(([fullKey, value]) => + range(fullKey.length).map((i) => { + const key = fullKey.substring(i); + return { + isTopLevel: fullKey === key, + key, + value, + id: id++, + }; + }), + ); + candidateTrie.addAll(candidateEntries); + + /** This will be returned; it won't contain any conflicts */ + const finalTrie = new TrieSearch>("key", options); + + /** Top-level conflicts to report to the caller */ + const conflictList: KeyValuePair[][] = []; + + /** + * The entries with these id's have conflicts and will be removed. We don't + * report them to the user if they're not top-level, though + */ + const badEntries = new Set(); + + for (const { isTopLevel, key, value, id } of candidateEntries) { + const conflicting = candidateTrie + .search(key) + .filter((other) => other.value !== value); + + if (conflicting.length === 0) { + continue; + } + + if (isTopLevel) { + // If we're top-level, we mark every conflicting entry as bad + conflicting.forEach(({ id }) => badEntries.add(id)); + + const conflictingTopLevel = conflicting.filter( + ({ isTopLevel }) => isTopLevel, + ); + if (conflictingTopLevel.length === 0) { + // No problem if there are no top-level conflicts + continue; + } + conflictList.push( + sortBy( + [ + { key, value }, + ...conflictingTopLevel.map(({ key, value }) => ({ key, value })), + ], + ["key", "value"], + ), + ); + } else { + // If we're not top-level, we only mark other non-top-level entries as bad + conflicting + .filter(({ isTopLevel }) => !isTopLevel) + .forEach(({ id }) => badEntries.add(id)); + } + + // If we got here, we have a conflict, so we mark ourselves as bad + badEntries.add(id); + } + + for (const { key, value, id } of candidateEntries) { + if (!badEntries.has(id)) { + finalTrie.add({ key, value }); + } + } + + return { + trie: finalTrie, + conflicts: uniqWith(conflictList, isEqual), + }; +} diff --git a/packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts b/packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts deleted file mode 100644 index 72b04fec10..0000000000 --- a/packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ActionType } from "@cursorless/common"; -import { SimpleScopeTypeType } from "@cursorless/common"; -import { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; -import { isTesting } from "@cursorless/common"; - -export type Keymap = Record; - -export type ModalVscodeCommandDescriptor = - | string - | { - commandId: string; - args?: unknown[]; - executeAtTarget?: boolean; - keepChangedSelection?: boolean; - exitCursorlessMode?: boolean; - }; - -// FIXME: Switch to a better mocking setup. We don't use our built in -// configuration set up because that is probably going to live server side, and -// the keyboard setup will probably live client side - -export const DEFAULT_ACTION_KEYMAP: Keymap = isTesting() - ? { t: "setSelection" } - : {}; - -export const DEFAULT_SCOPE_KEYMAP: Keymap = isTesting() - ? { sf: "namedFunction" } - : {}; - -export const DEFAULT_COLOR_KEYMAP: Keymap = isTesting() - ? { d: "default" } - : {}; - -export const DEFAULT_VSCODE_COMMAND_KEYMAP: Keymap = - isTesting() - ? { - c: "editor.action.addCommentLine", - mc: { - commandId: "editor.action.addCommentLine", - executeAtTarget: true, - }, - mm: { - commandId: "editor.action.addCommentLine", - executeAtTarget: true, - keepChangedSelection: true, - exitCursorlessMode: true, - }, - } - : {}; - -export const DEFAULT_SHAPE_KEYMAP: Keymap = isTesting() ? {} : {}; diff --git a/packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts b/packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts new file mode 100644 index 0000000000..383a11b0bf --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts @@ -0,0 +1,72 @@ +import { range } from "lodash"; +import { TokenTypeKeyMapMap } from "./TokenTypeHelpers"; +import { simpleKeyboardActionNames } from "./KeyboardActionType"; +import { KeyboardConfig } from "./KeyboardConfig"; + +/** + * Returns a map from token type names to a keymap for that token type. Something like: + * + * ```ts + * { + * action: { + * "c": { + * type: "action", + * value: "clearAndSetSelection", + * }, + * "t": { + * type: "action", + * value: "setSelection", + * }, + * }, + * makeRange: { + * "r": { + * type: "makeRange", + * }, + * }, + * ... + * } + * ``` + * @returns A map from token type names to a keymap for that token type. + */ +export function getTokenTypeKeyMaps( + config: KeyboardConfig, +): TokenTypeKeyMapMap { + return { + simpleScopeTypeType: config.getTokenKeyMap("simpleScopeTypeType", "scope"), + color: config.getTokenKeyMap("color"), + shape: config.getTokenKeyMap("shape"), + vscodeCommand: config.getTokenKeyMap("vscodeCommand"), + + // action config section + simpleAction: config.getTokenKeyMap( + "simpleAction", + "action", + simpleKeyboardActionNames, + ), + + // misc config section + makeRange: config.getTokenKeyMap("makeRange", "misc", ["makeRange"]), + combineColorAndShape: config.getTokenKeyMap( + "combineColorAndShape", + "misc", + ["combineColorAndShape"], + ), + direction: config.getTokenKeyMap("direction", "misc", [ + "forward", + "backward", + ]), + + // modifier config section + nextPrev: config.getTokenKeyMap("nextPrev", "modifier", ["nextPrev"]), + + digit: Object.fromEntries( + range(10).map((value) => [ + value.toString(), + { + type: "digit" as const, + value, + }, + ]), + ), + }; +} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/CommandRulePostProcessor.ts b/packages/cursorless-vscode/src/keyboard/grammar/CommandRulePostProcessor.ts new file mode 100644 index 0000000000..1e9581f2c1 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/CommandRulePostProcessor.ts @@ -0,0 +1,25 @@ +import { + KeyboardCommand, + KeyboardCommandArgTypes, +} from "../KeyboardCommandTypeHelpers"; +import { Unused } from "./grammarHelpers"; + +/** + * Represents a post-processing function for a top-level rule of our grammar. + * This is a function that takes the output of a rule and transforms it into a + * command usable by our command handler. We also keep metadata about the rule + * on the postprocess function so that we can display it to the user, eg in the + * sidebar. The reason we keep the metadata here is that the postprocess + * function is the only thing we have control over in the nearley parser. + */ +export interface CommandRulePostProcessor< + T extends keyof KeyboardCommandArgTypes = keyof KeyboardCommandArgTypes, +> { + (args: any[]): KeyboardCommand; + metadata: { + /** The command type */ + type: T; + /** The names of the arguments to the command's argument payload */ + argNames: (keyof KeyboardCommandArgTypes[T] | Unused)[]; + }; +} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/UniqueWorkQueue.ts b/packages/cursorless-vscode/src/keyboard/grammar/UniqueWorkQueue.ts new file mode 100644 index 0000000000..8f25c98e76 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/UniqueWorkQueue.ts @@ -0,0 +1,27 @@ +/** + * A queue that ensures that each item is only yielded once even if it is pushed + * multiple times. + */ +export class UniqueWorkQueue { + private items = new Set(); + private queue: T[] = []; + + constructor(...initialItems: T[]) { + this.push(...initialItems); + } + + push(...items: T[]) { + for (const item of items) { + if (!this.items.has(item)) { + this.items.add(item); + this.queue.push(item); + } + } + } + + *[Symbol.iterator]() { + while (this.queue.length > 0) { + yield this.queue.shift()!; + } + } +} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts b/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts new file mode 100644 index 0000000000..fd2b867d6b --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts @@ -0,0 +1,88 @@ +// Generated automatically by nearley, version 2.20.1 +// http://github.com/Hardmath123/nearley +// Bypasses TS6133. Allow declared but unused functions. +// @ts-ignore +function id(d: any[]): any { return d[0]; } +declare var makeRange: any; +declare var nextPrev: any; +declare var simpleAction: any; +declare var vscodeCommand: any; +declare var simpleScopeTypeType: any; +declare var color: any; +declare var shape: any; +declare var combineColorAndShape: any; +declare var direction: any; +declare var digit: any; + +import { capture, command, UNUSED as _ } from "../grammarHelpers" +import { keyboardLexer } from "../keyboardLexer"; + +interface NearleyToken { + value: any; + [key: string]: any; +}; + +interface NearleyLexer { + reset: (chunk: any, info: any) => void; + next: () => NearleyToken | undefined; + save: () => any; + formatError: (token: any, message: string) => string; + has: (tokenType: any) => boolean; +}; + +interface NearleyRule { + name: string; + symbols: NearleySymbol[]; + postprocess?: (d: any[], loc?: number, reject?: {}) => any; +}; + +type NearleySymbol = string | { literal: any } | { test: (token: any) => boolean }; + +interface Grammar { + Lexer: NearleyLexer | undefined; + ParserRules: NearleyRule[]; + ParserStart: string; +}; + +const grammar: Grammar = { + Lexer: keyboardLexer, + ParserRules: [ + {"name": "main", "symbols": ["decoratedMark"], "postprocess": command("targetDecoratedMarkReplace", ["decoratedMark"])}, + {"name": "main", "symbols": [(keyboardLexer.has("makeRange") ? {type: "makeRange"} : makeRange), "decoratedMark"], "postprocess": + command("targetDecoratedMarkExtend", [_, "decoratedMark"]) + }, + {"name": "main", "symbols": ["scopeType"], "postprocess": command("modifyTargetContainingScope", ["scopeType"])}, + {"name": "main$ebnf$1", "symbols": ["offset"], "postprocess": id}, + {"name": "main$ebnf$1", "symbols": [], "postprocess": () => null}, + {"name": "main$ebnf$2", "symbols": ["number"], "postprocess": id}, + {"name": "main$ebnf$2", "symbols": [], "postprocess": () => null}, + {"name": "main", "symbols": ["main$ebnf$1", (keyboardLexer.has("nextPrev") ? {type: "nextPrev"} : nextPrev), "main$ebnf$2", "scopeType"], "postprocess": + command( + "targetRelativeExclusiveScope", + ["offset", _, "length", "scopeType"], + ) + }, + {"name": "main", "symbols": [(keyboardLexer.has("simpleAction") ? {type: "simpleAction"} : simpleAction)], "postprocess": command("performSimpleActionOnTarget", ["actionName"])}, + {"name": "main", "symbols": [(keyboardLexer.has("vscodeCommand") ? {type: "vscodeCommand"} : vscodeCommand)], "postprocess": command("vscodeCommand", ["command"])}, + {"name": "scopeType", "symbols": [(keyboardLexer.has("simpleScopeTypeType") ? {type: "simpleScopeTypeType"} : simpleScopeTypeType)], "postprocess": capture("type")}, + {"name": "decoratedMark", "symbols": [(keyboardLexer.has("color") ? {type: "color"} : color)], "postprocess": capture("color")}, + {"name": "decoratedMark", "symbols": [(keyboardLexer.has("shape") ? {type: "shape"} : shape)], "postprocess": capture("shape")}, + {"name": "decoratedMark", "symbols": [(keyboardLexer.has("combineColorAndShape") ? {type: "combineColorAndShape"} : combineColorAndShape), (keyboardLexer.has("color") ? {type: "color"} : color), (keyboardLexer.has("shape") ? {type: "shape"} : shape)], "postprocess": capture(_, "color", "shape")}, + {"name": "decoratedMark", "symbols": [(keyboardLexer.has("combineColorAndShape") ? {type: "combineColorAndShape"} : combineColorAndShape), (keyboardLexer.has("shape") ? {type: "shape"} : shape), (keyboardLexer.has("color") ? {type: "color"} : color)], "postprocess": capture(_, "shape", "color")}, + {"name": "offset$ebnf$1", "symbols": [(keyboardLexer.has("direction") ? {type: "direction"} : direction)], "postprocess": id}, + {"name": "offset$ebnf$1", "symbols": [], "postprocess": () => null}, + {"name": "offset", "symbols": ["offset$ebnf$1", "number"], "postprocess": capture("direction", "number")}, + {"name": "offset$ebnf$2", "symbols": ["number"], "postprocess": id}, + {"name": "offset$ebnf$2", "symbols": [], "postprocess": () => null}, + {"name": "offset", "symbols": ["offset$ebnf$2", (keyboardLexer.has("direction") ? {type: "direction"} : direction)], "postprocess": capture("number", "direction")}, + {"name": "number$ebnf$1", "symbols": [(keyboardLexer.has("digit") ? {type: "digit"} : digit)]}, + {"name": "number$ebnf$1", "symbols": ["number$ebnf$1", (keyboardLexer.has("digit") ? {type: "digit"} : digit)], "postprocess": (d) => d[0].concat([d[1]])}, + {"name": "number", "symbols": ["number$ebnf$1"], "postprocess": + ([digits]) => + digits.reduce((total: number, digit: number) => total * 10 + digit, 0) + } + ], + ParserStart: "main", +}; + +export default grammar; diff --git a/packages/cursorless-vscode/src/keyboard/grammar/getAcceptableTokenTypes.ts b/packages/cursorless-vscode/src/keyboard/grammar/getAcceptableTokenTypes.ts new file mode 100644 index 0000000000..f98e65a01d --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/getAcceptableTokenTypes.ts @@ -0,0 +1,94 @@ +import nearley, { State } from "nearley"; +import { isEqual } from "lodash"; +import { CommandRulePostProcessor } from "./CommandRulePostProcessor"; +import { UniqueWorkQueue } from "./UniqueWorkQueue"; +import { uniqWithHash } from "@cursorless/common"; +import { UNUSED } from "./grammarHelpers"; +import { KeyboardCommandHandler } from "../KeyboardCommandHandler"; +import { KeyboardCommandArgTypes } from "../KeyboardCommandTypeHelpers"; + +/** + * Given a parser, returns a list of acceptable token types at the current state + * of the parser. We use this to display a list of possible next tokens to the + * user. We include information about which top-level rules want each token type + * so that we can display the command name in the list. We also include a partial + * argument for the command that wants the token type, which we could use to + * provide even more information to the user. + * + * @param parser The parser to get the acceptable token types of + * @returns A list of acceptable token types, along with information about which + * top-level rules want them + */ +export function getAcceptableTokenTypes(parser: nearley.Parser) { + return uniqWithHash( + parser.table[parser.table.length - 1].scannable.flatMap( + (scannableState) => { + /** The token type */ + const { type } = scannableState.rule.symbols[scannableState.dot]; + + return getRootStates(scannableState).map((root) => ({ + type, + command: getMetadata(root).type, + partialArg: computePartialArg(root), + })); + }, + ), + isEqual, + ({ type, command }) => [type, command].join("\u0000"), + ); +} + +function getMetadata( + state: nearley.State, +): CommandRulePostProcessor["metadata"] { + return (state.rule.postprocess as unknown as CommandRulePostProcessor) + .metadata; +} + +/** + * Given a state, returns all root states that are reachable from it. We use this + * to find out which top-level rules want a given token type. + * + * @param state The state to get the root states of + * @returns A list of root states + */ +function getRootStates(state: nearley.State) { + /** A queue of states to process; ensures we don't try to process state twice */ + const queue = new UniqueWorkQueue(state); + const roots: State[] = []; + + for (const state of queue) { + queue.push(...state.wantedBy); + + if (state.wantedBy.length === 0) { + roots.push(state); + } + } + + return roots; +} + +/** + * Given a root state, returns a partial argument for the command that the state + * represents. We could use this info to display which arguments have already + * been filled out while a user is typing a command. + * @param state A root state + * @returns A partial argument for the command that the state represents + */ +function computePartialArg( + state: nearley.State, +) { + const { argNames } = getMetadata(state); + let currentState = state; + const partialArg: Partial> = {}; + + while (currentState.dot > 0) { + const argName = argNames[currentState.dot - 1]!; + if (argName !== UNUSED) { + partialArg[argName] = currentState.right?.data; + } + currentState = currentState.left!; + } + + return partialArg; +} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne b/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne new file mode 100644 index 0000000000..657895d990 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne @@ -0,0 +1,52 @@ +@preprocessor typescript +@{% +import { capture, command, UNUSED as _ } from "../grammarHelpers" +import { keyboardLexer } from "../keyboardLexer"; +%} +@lexer keyboardLexer + +# ===================== Top-level commands =================== +# "air" +main -> decoratedMark {% command("targetDecoratedMarkReplace", ["decoratedMark"]) %} + +# "past air" +main -> %makeRange decoratedMark {% + command("targetDecoratedMarkExtend", [_, "decoratedMark"]) +%} + +# "funk" +main -> scopeType {% command("modifyTargetContainingScope", ["scopeType"]) %} + +# "[third] next [two] funks" +# "[third] previous [two] funks" +main -> offset:? %nextPrev number:? scopeType {% + command( + "targetRelativeExclusiveScope", + ["offset", _, "length", "scopeType"], + ) +%} + +# "chuck" +main -> %simpleAction {% command("performSimpleActionOnTarget", ["actionName"]) %} + +# Custom vscode command +main -> %vscodeCommand {% command("vscodeCommand", ["command"]) %} + +# ========================== Captures ========================= +scopeType -> %simpleScopeTypeType {% capture("type") %} + +decoratedMark -> + %color {% capture("color") %} + | %shape {% capture("shape") %} + | %combineColorAndShape %color %shape {% capture(_, "color", "shape") %} + | %combineColorAndShape %shape %color {% capture(_, "shape", "color") %} + +# Contains a direction and a number for use with nextPrev and ordinal +offset -> + %direction:? number {% capture("direction", "number") %} + | number:? %direction {% capture("number", "direction") %} + +number -> %digit:+ {% + ([digits]) => + digits.reduce((total: number, digit: number) => total * 10 + digit, 0) +%} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammar.test.ts b/packages/cursorless-vscode/src/keyboard/grammar/grammar.test.ts new file mode 100644 index 0000000000..817e994c73 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/grammar.test.ts @@ -0,0 +1,165 @@ +import { Parser, Grammar } from "nearley"; +import grammar from "./generated/grammar"; +import assert from "assert"; +import { KeyDescriptor } from "../TokenTypeHelpers"; +import { KeyboardCommandHandler } from "../KeyboardCommandHandler"; +import { KeyboardCommand } from "../KeyboardCommandTypeHelpers"; + +interface TestCase { + tokens: KeyDescriptor[]; + expected: KeyboardCommand; +} + +const testCases: TestCase[] = [ + { + tokens: [{ type: "shape", value: "fox" }], + expected: { + arg: { + decoratedMark: { + shape: "fox", + }, + }, + type: "targetDecoratedMarkReplace", + }, + }, + { + tokens: [{ type: "color", value: "green" }], + expected: { + arg: { + decoratedMark: { + color: "green", + }, + }, + type: "targetDecoratedMarkReplace", + }, + }, + { + tokens: [ + { type: "combineColorAndShape", value: "combineColorAndShape" }, + { type: "color", value: "green" }, + { type: "shape", value: "fox" }, + ], + expected: { + arg: { + decoratedMark: { + color: "green", + shape: "fox", + }, + }, + type: "targetDecoratedMarkReplace", + }, + }, + { + tokens: [ + { type: "combineColorAndShape", value: "combineColorAndShape" }, + { type: "shape", value: "fox" }, + { type: "color", value: "green" }, + ], + expected: { + arg: { + decoratedMark: { + color: "green", + shape: "fox", + }, + }, + type: "targetDecoratedMarkReplace", + }, + }, + { + tokens: [ + { type: "makeRange", value: "makeRange" }, + { type: "color", value: "green" }, + ], + expected: { + arg: { + decoratedMark: { + color: "green", + }, + }, + type: "targetDecoratedMarkExtend", + }, + }, + { + tokens: [ + { type: "digit", value: 1 }, + { type: "digit", value: 2 }, + { type: "nextPrev", value: "nextPrev" }, + { type: "simpleScopeTypeType", value: "namedFunction" }, + ], + expected: { + arg: { + length: null, + offset: { + number: 12, + direction: null, + }, + scopeType: { + type: "namedFunction", + }, + }, + type: "targetRelativeExclusiveScope", + }, + }, + { + tokens: [ + { type: "direction", value: "backward" }, + { type: "nextPrev", value: "nextPrev" }, + { type: "simpleScopeTypeType", value: "namedFunction" }, + ], + expected: { + arg: { + length: null, + offset: { + number: null, + direction: "backward", + }, + scopeType: { + type: "namedFunction", + }, + }, + type: "targetRelativeExclusiveScope", + }, + }, + { + tokens: [ + { + type: "vscodeCommand", + value: "workbench.action.editor.changeLanguageMode", + }, + ], + expected: { + arg: { + command: "workbench.action.editor.changeLanguageMode", + }, + type: "vscodeCommand", + }, + }, +]; + +suite("keyboard grammar", () => { + let parser: Parser; + setup(() => { + parser = new Parser(Grammar.fromCompiled(grammar)); + }); + + testCases.forEach(({ tokens, expected }) => { + test(`should parse \`${stringifyTokens(tokens)}\``, () => { + parser.feed(tokens); + + assert.equal(parser.results.length, 1); + assert.deepStrictEqual(parser.results[0], expected); + }); + }); +}); + +function stringifyTokens(tokens: any[]) { + return tokens + .map((token) => { + let ret = token.type; + if (token.value != null) { + ret += `:${JSON.stringify(token.value)}`; + } + return ret; + }) + .join(" "); +} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts b/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts new file mode 100644 index 0000000000..5e654b47c0 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts @@ -0,0 +1,92 @@ +import { KeyboardCommandArgTypes } from "../KeyboardCommandTypeHelpers"; +import { CommandRulePostProcessor } from "./CommandRulePostProcessor"; + +export const UNUSED = Symbol("unused"); +export type Unused = typeof UNUSED; + +/** + * @param args The values output by the parser rule + * @param argNames The keys to use for the payload + * @returns An object with the given keys mapped to the values at the same + * positions in the parser rule's output + */ +function constructPayload(args: any[], argNames: (string | Unused)[]) { + const arg: Record = {}; + for (let i = 0; i < argNames.length; i++) { + const name = argNames[i]; + if (name === UNUSED) { + continue; + } + arg[name] = args[i]; + } + return arg; +} + +/** + * Creates a postprocess function for a top-level rule of our grammar. This is a + * function that takes the output of a rule and transforms it into a command + * usable by our command handler. It does so by constructing a payload object + * with `type` as provided in {@link type}, and `args` constructed by mapping + * {@link argNames} to the values at the same positions in the parser rule's + * output. + * + * We also keep metadata about the rule on the + * postprocess function so that we can display it to the user, eg in the + * sidebar. The reason we keep the metadata here is that the postprocess + * function is the only thing we have control over in the nearley parser. + * + * @param type The type of the command + * @param argNames The names of the arguments to the command's argument payload + * @returns A postprocess function for the command + */ +export function command( + type: T, + argNames: (keyof KeyboardCommandArgTypes[T] | Unused)[], +): CommandRulePostProcessor { + function ret(args: any[]) { + return { + type, + arg: constructPayload( + args, + argNames as (string | Unused)[], + ) as KeyboardCommandArgTypes[T], + }; + } + ret.metadata = { type, argNames }; + return ret; +} + +/** + * Creates a postprocess function for a lower-level capture in our keyboard + * grammar. The output will be an object with the keys of {@link argNames} + * mapped to the values at the same positions in the parser rule's output. + * + * For example: + * + * ```ts + * const processor = capture("foo", "bar"); + * processor(["a", "b"]) === { foo: "a", bar: "b" } + * ``` + * + * When used in a parser rule, it would look like: + * + * ```nearley + * foo -> bar baz {% capture("bar", "baz") %} + * ``` + * + * Then if the rule matched with tokens 0 then 1, the output would be: + * + * ```ts + * { bar: 0, baz: 1 } + * ``` + * + * @param argNames The keys to use for the payload + * @returns A postprocess function that constructs a payload with the given keys + * mapped to the values at the same positions in the parser rule's output + */ +export function capture(...argNames: (string | Unused)[]) { + function ret(args: any[]) { + return constructPayload(args, argNames); + } + return ret; +} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/keyboardLexer.ts b/packages/cursorless-vscode/src/keyboard/grammar/keyboardLexer.ts new file mode 100644 index 0000000000..c1b7713db8 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/keyboardLexer.ts @@ -0,0 +1,57 @@ +import { Lexer } from "nearley"; +import { TokenTypeKeyMapMap } from "../TokenTypeHelpers"; + +interface LexerState { + index: number; +} + +interface Token { + type: string; + value: any; +} + +/** + * A simple lexer for our keyboard grammar designed to handle a stream of tokens + * of the form {@link Token}. It passes the token along unchanged to the parser + * to use when it is checking token types, and exposes a {@link transform} + * method that the parser will then use to transform the token into the actual + * value that will be used when constructing rule outputs. + */ +class KeyboardLexer implements Lexer { + buffer: any[] = []; + bufferIndex = 0; + index = 0; + + reset(data: Token[], { index }: LexerState = { index: 0 }) { + this.buffer = data; + this.bufferIndex = 0; + this.index = index; + } + + next() { + if (this.bufferIndex < this.buffer.length) { + this.index++; + return this.buffer[this.bufferIndex++]; + } + } + + save() { + return { + index: this.index, + }; + } + + formatError(_token: any, message: string) { + return message + " at index " + (this.index - 1); + } + + has(_type: keyof TokenTypeKeyMapMap) { + return true; + } + + transform({ value }: Token) { + return value; + } +} + +export const keyboardLexer = new KeyboardLexer(); diff --git a/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json b/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json new file mode 100644 index 0000000000..5e9006333c --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json @@ -0,0 +1,81 @@ +{ + "!!NOTE!!": "This file is for internal development purposes only; these experimental features are not supported and could break at any time.", + "cursorless.experimental.keyboard.modal.keybindings.scope": { + "i": "line", + "sb": "paragraph", + ";": "statement", + ",": "collectionItem", + ".": "functionCall", + "'": "string", + "sf": "namedFunction", + "sc": "class", + "st": "token", + "sy": "type", + "sv": "value", + "sk": "collectionKey", + "sp": "nonWhitespaceSequence", + "ss": "boundedNonWhitespaceSequence", + "sa": "argumentOrParameter", + "sl": "url" + }, + "cursorless.experimental.keyboard.modal.keybindings.action": { + "at": "setSelection", + "ah": "setSelectionBefore", + "al": "setSelectionAfter", + "aO": "editNewLineBefore", + "ao": "editNewLineAfter", + "k": "insertCopyBefore", + "j": "insertCopyAfter", + "au": "replaceWithTarget", + "am": "moveToTarget", + "c": "clearAndSetSelection", + "as": "swapTargets", + "af": "foldRegion", + "ak": "insertEmptyLineBefore", + "aj": "insertEmptyLineAfter", + "ai": "insertEmptyLinesAround", + "ac": "copyToClipboard", + "ax": "cutToClipboard", + "ap": "pasteFromClipboard", + "ad": "followLink" + }, + "cursorless.experimental.keyboard.modal.keybindings.color": { + "d": "default", + "b": "blue", + "y": "yellow", + "r": "red", + "g": "green", + "p": "pink" + }, + "cursorless.experimental.keyboard.modal.keybindings.shape": { + "hx": "ex", + "hf": "fox", + "hq": "frame", + "hv": "curve", + "he": "eye", + "hy": "play", + "hz": "bolt", + "hw": "crosshairs" + }, + "cursorless.experimental.keyboard.modal.keybindings.misc": { + "fx": "combineColorAndShape", + "fk": "makeRange", + "-": "backward" + }, + "cursorless.experimental.keyboard.modal.keybindings.modifier": { + "n": "nextPrev" + }, + "cursorless.experimental.keyboard.modal.keybindings.vscodeCommand": { + "va": "editor.action.addCommentLine", + "vb": { + "commandId": "editor.action.addCommentLine", + "executeAtTarget": true + }, + "vc": { + "commandId": "editor.action.addCommentLine", + "executeAtTarget": true, + "keepChangedSelection": true, + "exitCursorlessMode": true + } + } +} diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts index 18b18c6299..a6a5e08a8d 100644 --- a/packages/cursorless-vscode/src/registerCommands.ts +++ b/packages/cursorless-vscode/src/registerCommands.ts @@ -73,7 +73,7 @@ export function registerCommands( keyboardCommands.targeted.targetDecoratedMark, ["cursorless.keyboard.targeted.targetScope"]: - keyboardCommands.targeted.targetScopeType, + keyboardCommands.targeted.modifyTargetContainingScope, ["cursorless.keyboard.targeted.targetSelection"]: keyboardCommands.targeted.targetSelection, diff --git a/patches/@types__nearley@2.11.5.patch b/patches/@types__nearley@2.11.5.patch new file mode 100644 index 0000000000..776a237c0d --- /dev/null +++ b/patches/@types__nearley@2.11.5.patch @@ -0,0 +1,50 @@ +diff --git a/index.d.ts b/index.d.ts +index 5cca1013513217cf5ec46c04e60cbb1dc2c7b2f1..289ca02c4bf917b0f6dacfe2d60d2e91a77bcf77 100644 +--- a/index.d.ts ++++ b/index.d.ts +@@ -1,5 +1,18 @@ + export as namespace nearley; + ++export interface State { ++ rule: Rule; ++ dot: number; ++ wantedBy: State[]; ++ left?: State; ++ right?: State; ++ data: any; ++} ++ ++export interface Column { ++ scannable: State[]; ++} ++ + export class Parser { + /** + * Reserved token for indicating a parse fail. +@@ -19,6 +32,8 @@ export class Parser { + */ + results: any[]; + ++ table: Column[]; ++ + constructor(grammar: Grammar, options?: ParserOptions); + + /** +@@ -30,7 +45,7 @@ export class Parser { + * @throws If there are no possible parsings, nearley will throw an error + * whose offset property is the index of the offending token. + */ +- feed(chunk: string): this; ++ feed(chunk: string | any[]): this; + finish(): any[]; + restore(column: { [key: string]: any; lexerState: LexerState }): void; + save(): { [key: string]: any; lexerState: LexerState }; +@@ -83,7 +98,7 @@ export interface Lexer { + /** + * Sets the internal buffer to data, and restores line/col/state info taken from save(). + */ +- reset(data: string, state?: LexerState): void; ++ reset(data: string | any[], state?: LexerState): void; + /** + * Returns e.g. {type, value, line, col, …}. Only the value attribute is required. + */ \ No newline at end of file diff --git a/patches/nearley@2.20.1.patch b/patches/nearley@2.20.1.patch new file mode 100644 index 0000000000..54ddf4962a --- /dev/null +++ b/patches/nearley@2.20.1.patch @@ -0,0 +1,32 @@ +diff --git a/lib/generate.js b/lib/generate.js +index e0bd2c7bf86ed3e09fb99b792cd2d210c0c4f67a..f56765d2b6d8f5a47feaafd3e36e3e4c78fe870d 100644 +--- a/lib/generate.js ++++ b/lib/generate.js +@@ -199,11 +199,11 @@ + output += "};\n"; + output += "\n"; + output += "interface NearleyLexer {\n"; +- output += " reset: (chunk: string, info: any) => void;\n"; ++ output += " reset: (chunk: any, info: any) => void;\n"; + output += " next: () => NearleyToken | undefined;\n"; + output += " save: () => any;\n"; +- output += " formatError: (token: never) => string;\n"; +- output += " has: (tokenType: string) => boolean;\n"; ++ output += " formatError: (token: any, message: string) => string;\n"; ++ output += " has: (tokenType: any) => boolean;\n"; + output += "};\n"; + output += "\n"; + output += "interface NearleyRule {\n"; +diff --git a/lib/nearley.js b/lib/nearley.js +index b564e11e24d2bcc597d46bb01af84a1972cc6b9d..1d559ea3c96059f764bb5138a387039fe290ee26 100644 +--- a/lib/nearley.js ++++ b/lib/nearley.js +@@ -311,7 +311,7 @@ + + // Advance all tokens that expect the symbol + var literal = token.text !== undefined ? token.text : token.value; +- var value = lexer.constructor === StreamLexer ? token.value : token; ++ var value = lexer.constructor === StreamLexer ? token.value : lexer.transform?.(token) ?? token; + var scannable = column.scannable; + for (var w = scannable.length; w--; ) { + var state = scannable[w]; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32189eb05a..a3a6c663a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ patchedDependencies: '@docusaurus/theme-search-algolia@3.0.1': hash: dfty3zxmjzsb7sg6jf27rbwooe path: patches/@docusaurus__theme-search-algolia@2.3.1.patch + '@types/nearley@2.11.5': + hash: 5bomp3nnmdzdyzcgrxyr5kymae + path: patches/@types__nearley@2.11.5.patch + nearley@2.20.1: + hash: mg2fc7wgvzub3myuz6m74hllma + path: patches/nearley@2.20.1.patch importers: @@ -211,6 +217,9 @@ importers: cross-spawn: specifier: 7.0.3 version: 7.0.3 + fast-check: + specifier: 3.12.0 + version: 3.12.0 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -263,9 +272,6 @@ importers: '@types/sinon': specifier: ^10.0.2 version: 10.0.13 - fast-check: - specifier: 3.12.0 - version: 3.12.0 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -424,12 +430,18 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + nearley: + specifier: 2.20.1 + version: 2.20.1(patch_hash=mg2fc7wgvzub3myuz6m74hllma) semver: specifier: ^7.5.2 version: 7.5.2 tinycolor2: specifier: 1.6.0 version: 1.6.0 + trie-search: + specifier: 2.0.0 + version: 2.0.0 uuid: specifier: ^9.0.0 version: 9.0.0 @@ -455,6 +467,9 @@ importers: '@types/mocha': specifier: ^10.0.3 version: 10.0.3 + '@types/nearley': + specifier: 2.11.5 + version: 2.11.5(patch_hash=5bomp3nnmdzdyzcgrxyr5kymae) '@types/node': specifier: ^18.18.2 version: 18.18.2 @@ -4991,6 +5006,11 @@ packages: resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} dev: false + /@types/nearley@2.11.5(patch_hash=5bomp3nnmdzdyzcgrxyr5kymae): + resolution: {integrity: sha512-dM7TrN0bVxGGXTYGx4YhGear8ysLO5SOuouAWM9oltjQ3m9oYa13qi8Z1DJp5zxVMPukvQdsrnZmgzpeuTSEQA==} + dev: true + patched: true + /@types/node-forge@1.3.10: resolution: {integrity: sha512-y6PJDYN4xYBxwd22l+OVH35N+1fCYWiuC3aiP2SlXVE6Lo7SS+rSx9r89hLxrP4pn6n1lBGhHJ12pj3F3Mpttw==} dependencies: @@ -7561,6 +7581,10 @@ packages: dependencies: path-type: 4.0.0 + /discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dev: false + /dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} dev: true @@ -9137,6 +9161,12 @@ packages: engines: {node: '>= 0.4.0'} dev: true + /hasharray@1.1.2: + resolution: {integrity: sha512-7w3idwaVXX9gL9LiTCBSNKRGTBcp2WI/kf13UYeZ9+trOGBHVYHei6qtMY6DVnwGOouVUSRg0+L2xf4Q2/CmzA==} + dependencies: + jclass: 1.2.1 + dev: false + /hasown@2.0.0: resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} engines: {node: '>= 0.4'} @@ -10172,6 +10202,11 @@ packages: filelist: 1.0.4 minimatch: 3.1.2 + /jclass@1.2.1: + resolution: {integrity: sha512-mRx8uv1qJLOtxbRf3IWOQIH2ro7VIPn6ZkhbTcUJvJEslLzYA7BSATXDi/GR1yKYV9DASsjTZL+0YJPdqSMznw==} + engines: {node: '>= 0.6'} + dev: false + /jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -12086,6 +12121,10 @@ packages: yargs-unparser: 2.0.0 dev: true + /moo@0.5.2: + resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + dev: false + /mrmime@1.0.1: resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} engines: {node: '>=10'} @@ -12171,6 +12210,17 @@ packages: split2: 3.2.2 through2: 4.0.2 + /nearley@2.20.1(patch_hash=mg2fc7wgvzub3myuz6m74hllma): + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + dependencies: + commander: 2.20.3 + moo: 0.5.2 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + dev: false + patched: true + /negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -13719,10 +13769,22 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + /railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + dev: false + /ramda@0.29.0: resolution: {integrity: sha512-BBea6L67bYLtdbOqfp8f58fPMqEwx0doL+pAi8TZyp2YWz8R9G8z9x75CZI8W+ftqhFHCpEX2cRnUUXK130iKA==} dev: true + /randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + dev: false + /randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} dependencies: @@ -14413,6 +14475,11 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 + /ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + dev: false + /retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -15607,6 +15674,12 @@ packages: /treeverse@1.0.4: resolution: {integrity: sha512-whw60l7r+8ZU8Tu/Uc2yxtc4ZTZbR/PF3u1IPNKGQ6p8EICLb3Z2lAgoqw9bqYd8IkgnsaOcLzYHFckjqNsf0g==} + /trie-search@2.0.0: + resolution: {integrity: sha512-AJMlAQ/6E5+K45SAOqzeqr0qXWqSREclp3mAWss0PvB9ifBL+QXn2LeZBgUBUifjj5ZtTpo4uKplqUnt9VZcdQ==} + dependencies: + hasharray: 1.1.2 + dev: false + /trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} dev: false diff --git a/scripts/build-and-assemble-website.sh b/scripts/build-and-assemble-website.sh index a7a1de98e1..61585f19dd 100755 --- a/scripts/build-and-assemble-website.sh +++ b/scripts/build-and-assemble-website.sh @@ -10,6 +10,8 @@ NODE_OPTIONS="--max-old-space-size=6144" \ --filter 'cursorless-org-*' \ build +pnpm -F cursorless-vscode generate-railroad + # Merge the root site and the documentation site, placing the documentation site # under docs/ @@ -21,3 +23,4 @@ mkdir -p "$docs_dir" cp -r packages/cursorless-org/out/* "$root_dir" cp -r packages/cursorless-org-docs/build/* "$docs_dir" +cp packages/cursorless-vscode/out/railroad.html "$root_dir/keyboard-modal-railroad.html"