From d5be7e7e81a9be3ad6626d8e32d88eaba5891483 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Mon, 22 Apr 2024 13:33:12 +0100 Subject: [PATCH] Add scope type parser piece of custom command grammar (#2295) Initial work towards https://github.com/cursorless-dev/cursorless/issues/492; will be used to parse scope types in https://github.com/cursorless-dev/cursorless/pull/2131 Exposes a function `parseScopeType` that can parse strings like `funk`, `curly` etc into their corresponding scope type payloads Here's a railroad: https://deploy-preview-2295--cursorless.netlify.app/custom-command-railroad ## Checklist - [ ] 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) - [ ] I have not broken the cheatsheet --- .vscode/tasks.json | 1 - package.json | 1 + packages/cursorless-engine/package.json | 9 + .../customCommandGrammar/generated/grammar.ts | 51 ++++++ .../src/customCommandGrammar/grammar.ne | 14 ++ .../grammarScopeType.test.ts | 39 ++++ .../src/customCommandGrammar/lexer.test.ts | 76 ++++++++ .../src/customCommandGrammar/lexer.ts | 44 +++++ .../customCommandGrammar/parseScopeType.ts | 29 +++ packages/cursorless-engine/src/index.ts | 1 + .../src/spokenForms/defaultSpokenFormMap.ts | 4 +- .../src/util/grammarHelpers.ts | 95 ++++++++++ .../src/keyboard/grammar/command.ts | 82 +++++++++ .../src/keyboard/grammar/generated/grammar.ts | 3 +- .../src/keyboard/grammar/grammar.ne | 3 +- .../src/keyboard/grammar/grammarHelpers.ts | 168 ------------------ pnpm-lock.yaml | 16 ++ scripts/build-and-assemble-website.sh | 3 +- 18 files changed, 465 insertions(+), 174 deletions(-) create mode 100644 packages/cursorless-engine/src/customCommandGrammar/generated/grammar.ts create mode 100644 packages/cursorless-engine/src/customCommandGrammar/grammar.ne create mode 100644 packages/cursorless-engine/src/customCommandGrammar/grammarScopeType.test.ts create mode 100644 packages/cursorless-engine/src/customCommandGrammar/lexer.test.ts create mode 100644 packages/cursorless-engine/src/customCommandGrammar/lexer.ts create mode 100644 packages/cursorless-engine/src/customCommandGrammar/parseScopeType.ts create mode 100644 packages/cursorless-engine/src/util/grammarHelpers.ts create mode 100644 packages/cursorless-vscode/src/keyboard/grammar/command.ts delete mode 100644 packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 69cd1ca783..412dd4c42f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -66,7 +66,6 @@ "label": "Generate grammar", "type": "npm", "script": "generate-grammar", - "path": "packages/cursorless-vscode", "presentation": { "reveal": "silent" }, diff --git a/package.json b/package.json index d9c8855ec5..09b4ba6903 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "preinstall": "npx only-allow pnpm", "test-compile": "tsc --build", "test": "pnpm compile && pnpm lint && pnpm -F '!test-harness' test && pnpm -F test-harness test", + "generate-grammar": "pnpm -r generate-grammar", "transform-recorded-tests": "./packages/common/scripts/my-ts-node.js packages/cursorless-engine/src/scripts/transformRecordedTests/index.ts", "watch": "pnpm run -w --parallel '/^watch:.*/'", "watch:esbuild": "pnpm run -r --parallel --if-present watch:esbuild", diff --git a/packages/cursorless-engine/package.json b/packages/cursorless-engine/package.json index b601845cff..89f543a20d 100644 --- a/packages/cursorless-engine/package.json +++ b/packages/cursorless-engine/package.json @@ -8,6 +8,11 @@ "compile:tsc": "tsc --build", "compile:esbuild": "esbuild ./src/index.ts --sourcemap --format=esm --bundle --packages=external --outfile=./out/index.js", "compile": "pnpm compile:tsc && pnpm compile:esbuild", + "generate-grammar:base": "nearleyc src/customCommandGrammar/grammar.ne", + "ensure-grammar-up-to-date": "pnpm -s generate-grammar:base | diff -u src/customCommandGrammar/generated/grammar.ts -", + "generate-grammar": "pnpm generate-grammar:base -o src/customCommandGrammar/generated/grammar.ts", + "generate-railroad": "nearley-railroad src/customCommandGrammar/grammar.ne -o out/railroad.html", + "test": "pnpm ensure-grammar-up-to-date", "watch:tsc": "pnpm compile:tsc --watch", "watch:esbuild": "pnpm compile:esbuild --watch", "watch": "pnpm run --filter @cursorless/cursorless-engine --parallel '/^watch:.*/'" @@ -22,6 +27,8 @@ "immutability-helper": "^3.1.1", "itertools": "^2.2.5", "lodash": "^4.17.21", + "moo": "0.5.2", + "nearley": "2.20.1", "node-html-parser": "^6.1.12", "sbd": "^1.0.19", "uuid": "^9.0.1", @@ -32,6 +39,8 @@ "@types/js-yaml": "^4.0.9", "@types/lodash": "4.17.0", "@types/mocha": "^10.0.6", + "@types/moo": "0.5.9", + "@types/nearley": "2.11.5", "@types/sbd": "^1.0.5", "@types/sinon": "^17.0.3", "@types/uuid": "^9.0.8", diff --git a/packages/cursorless-engine/src/customCommandGrammar/generated/grammar.ts b/packages/cursorless-engine/src/customCommandGrammar/generated/grammar.ts new file mode 100644 index 0000000000..a1fce0acec --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/generated/grammar.ts @@ -0,0 +1,51 @@ +// 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 simpleScopeTypeType: any; +declare var pairedDelimiter: any; + +import { capture } from "../../util/grammarHelpers"; +import { lexer } from "../lexer"; + +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: lexer, + ParserRules: [ + {"name": "main", "symbols": ["scopeType"]}, + {"name": "scopeType", "symbols": [(lexer.has("simpleScopeTypeType") ? {type: "simpleScopeTypeType"} : simpleScopeTypeType)], "postprocess": capture("type")}, + {"name": "scopeType", "symbols": [(lexer.has("pairedDelimiter") ? {type: "pairedDelimiter"} : pairedDelimiter)], "postprocess": + ([delimiter]) => ({ type: "surroundingPair", delimiter }) + } + ], + ParserStart: "main", +}; + +export default grammar; diff --git a/packages/cursorless-engine/src/customCommandGrammar/grammar.ne b/packages/cursorless-engine/src/customCommandGrammar/grammar.ne new file mode 100644 index 0000000000..e03867b7e2 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/grammar.ne @@ -0,0 +1,14 @@ +@preprocessor typescript +@{% +import { capture } from "../../util/grammarHelpers"; +import { lexer } from "../lexer"; +%} +@lexer lexer + +main -> scopeType + +# --------------------------- Scope types --------------------------- +scopeType -> %simpleScopeTypeType {% capture("type") %} +scopeType -> %pairedDelimiter {% + ([delimiter]) => ({ type: "surroundingPair", delimiter }) +%} diff --git a/packages/cursorless-engine/src/customCommandGrammar/grammarScopeType.test.ts b/packages/cursorless-engine/src/customCommandGrammar/grammarScopeType.test.ts new file mode 100644 index 0000000000..7159c4553d --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/grammarScopeType.test.ts @@ -0,0 +1,39 @@ +import assert from "assert"; +import { ScopeType } from "@cursorless/common"; +import { parseScopeType } from "./parseScopeType"; + +interface TestCase { + input: string; + expectedOutput: ScopeType; +} + +const testCases: TestCase[] = [ + { + input: "funk", + expectedOutput: { + type: "namedFunction", + }, + }, + { + input: "curly", + expectedOutput: { + type: "surroundingPair", + delimiter: "curlyBrackets", + }, + }, + { + input: "string", + expectedOutput: { + type: "surroundingPair", + delimiter: "string", + }, + }, +]; + +suite("custom grammar: scope types", () => { + testCases.forEach(({ input, expectedOutput }) => { + test(input, () => { + assert.deepStrictEqual(parseScopeType(input), expectedOutput); + }); + }); +}); diff --git a/packages/cursorless-engine/src/customCommandGrammar/lexer.test.ts b/packages/cursorless-engine/src/customCommandGrammar/lexer.test.ts new file mode 100644 index 0000000000..09f6d84ebb --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/lexer.test.ts @@ -0,0 +1,76 @@ +import * as assert from "assert"; +import { unitTestSetup } from "../test/unitTestSetup"; +import { lexer } from "./lexer"; + +interface Token { + type: string; + value: string; +} + +interface Fixture { + input: string; + expectedOutput: Token[]; +} + +const fixtures: Fixture[] = [ + { + input: "funk", + expectedOutput: [ + { + type: "simpleScopeTypeType", + value: "namedFunction", + }, + ], + }, + { + input: "curly", + expectedOutput: [ + { + type: "pairedDelimiter", + value: "curlyBrackets", + }, + ], + }, + { + input: "state name", + expectedOutput: [ + { + type: "simpleScopeTypeType", + value: "statement", + }, + { + type: "ws", + value: " ", + }, + { + type: "simpleScopeTypeType", + value: "name", + }, + ], + }, + { + input: "funk name", + expectedOutput: [ + { + type: "simpleScopeTypeType", + value: "functionName", + }, + ], + }, +]; + +suite("custom grammar lexer", () => { + unitTestSetup(); + + fixtures.forEach(({ input, expectedOutput }) => { + test(input, () => { + assert.deepStrictEqual( + Array.from(lexer.reset(input)).map(({ type, value }) => ({ + type, + value, + })), + expectedOutput, + ); + }); + }); +}); diff --git a/packages/cursorless-engine/src/customCommandGrammar/lexer.ts b/packages/cursorless-engine/src/customCommandGrammar/lexer.ts new file mode 100644 index 0000000000..147cf35f02 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/lexer.ts @@ -0,0 +1,44 @@ +import { simpleScopeTypeTypes, surroundingPairNames } from "@cursorless/common"; +import moo from "moo"; +import { defaultSpokenFormMap } from "../spokenForms/defaultSpokenFormMap"; + +interface Token { + type: string; + value: string; +} + +const tokens: Record = {}; + +// FIXME: Remove the duplication below? + +for (const simpleScopeTypeType of simpleScopeTypeTypes) { + const { spokenForms } = + defaultSpokenFormMap.simpleScopeTypeType[simpleScopeTypeType]; + for (const spokenForm of spokenForms) { + tokens[spokenForm] = { + type: "simpleScopeTypeType", + value: simpleScopeTypeType, + }; + } +} + +for (const pairedDelimiter of surroundingPairNames) { + const { spokenForms } = defaultSpokenFormMap.pairedDelimiter[pairedDelimiter]; + for (const spokenForm of spokenForms) { + tokens[spokenForm] = { + type: "pairedDelimiter", + value: pairedDelimiter, + }; + } +} + +export const lexer = moo.compile({ + ws: /[ \t]+/, + token: { + match: Object.keys(tokens), + type: (text) => tokens[text].type, + value: (text) => tokens[text].value, + }, +}); + +(lexer as any).transform = (token: { value: string }) => token.value; diff --git a/packages/cursorless-engine/src/customCommandGrammar/parseScopeType.ts b/packages/cursorless-engine/src/customCommandGrammar/parseScopeType.ts new file mode 100644 index 0000000000..6fafc291f8 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/parseScopeType.ts @@ -0,0 +1,29 @@ +import { Parser, Grammar } from "nearley"; +import grammar from "./generated/grammar"; +import { ScopeType } from "@cursorless/common"; + +function getScopeTypeParser(): Parser { + return new Parser( + // eslint-disable-next-line @typescript-eslint/naming-convention + Grammar.fromCompiled({ ...grammar, ParserStart: "scopeType" }), + ); +} + +/** + * Given a textual representation of a scope type, parse it into a scope type. + * + * @param input A textual representation of a scope type + * @returns A parsed scope type + */ +export function parseScopeType(input: string): ScopeType { + const parser = getScopeTypeParser(); + parser.feed(input); + + if (parser.results.length !== 1) { + throw new Error( + `Expected exactly one result, got ${parser.results.length}`, + ); + } + + return parser.results[0] as ScopeType; +} diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 9087d6a60c..35b7a95d06 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -10,3 +10,4 @@ export * from "./api/CursorlessEngineApi"; export * from "./CommandRunner"; export * from "./CommandHistory"; export * from "./CommandHistoryAnalyzer"; +export * from "./util/grammarHelpers"; diff --git a/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMap.ts b/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMap.ts index 446d661082..158e784d74 100644 --- a/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMap.ts +++ b/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMap.ts @@ -1,4 +1,4 @@ -import { mapSpokenForms } from "./SpokenFormMap"; +import { SpokenFormMap, mapSpokenForms } from "./SpokenFormMap"; import { defaultSpokenFormMapCore } from "./defaultSpokenFormMapCore"; import { DefaultSpokenFormInfoMap } from "./defaultSpokenFormMap.types"; @@ -23,7 +23,7 @@ export const defaultSpokenFormInfoMap: DefaultSpokenFormInfoMap = * A spoken form map constructed from the default spoken forms. It is designed to * be used as a fallback when the Talon spoken form map is not available. */ -export const defaultSpokenFormMap = mapSpokenForms( +export const defaultSpokenFormMap: SpokenFormMap = mapSpokenForms( defaultSpokenFormInfoMap, ({ defaultSpokenForms, isDisabledByDefault, isPrivate }) => ({ spokenForms: isDisabledByDefault ? [] : defaultSpokenForms, diff --git a/packages/cursorless-engine/src/util/grammarHelpers.ts b/packages/cursorless-engine/src/util/grammarHelpers.ts new file mode 100644 index 0000000000..4d198a7d2b --- /dev/null +++ b/packages/cursorless-engine/src/util/grammarHelpers.ts @@ -0,0 +1,95 @@ +import { isString } from "lodash"; + +export const UNUSED = Symbol("unused"); +export type Unused = typeof UNUSED; + +/** + * @param args The values output by the parser rule + * @param argExtractors Extractors to get values for payload + * @returns An object with the given keys mapped to the values at the same + * positions in the parser rule's output + */ +export function constructPayload( + args: any[], + argExtractors: ArgExtractor, +): Record { + const arg: Partial> = {}; + for (const [key, value] of Object.entries(argExtractors)) { + if (value instanceof ArgPosition) { + arg[key as keyof K] = args[value.position]; + } else { + arg[key as keyof K] = value; + } + } + return arg as Record; +} + +class ArgPosition { + constructor(public position: number) {} +} + +export const argPositions: Record = { + $0: new ArgPosition(0), + $1: new ArgPosition(1), + $2: new ArgPosition(2), +}; + +export type ArgExtractor = { + [K in keyof T]: T[K] | ArgPosition; +}; + +export function getArgExtractors( + argExtractors: (keyof T | typeof UNUSED)[], +): T { + return Object.fromEntries( + argExtractors + .map((arg, i) => [arg, new ArgPosition(i)]) + .filter(([arg]) => arg !== UNUSED), + ); +} + +/** + * Creates a postprocess function for a lower-level capture in our 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 argExtractor The extractors to use to get the argument 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( + argExtractor: ArgExtractor>, +): (args: any[]) => Record; +export function capture( + ...argNames: (string | Unused)[] +): (args: any[]) => Record; +export function capture( + arg0: (string | Unused) | ArgExtractor>, + ...argNames: (string | Unused)[] +): (args: any[]) => Record { + const extractors = + isString(arg0) || arg0 === UNUSED + ? getArgExtractors([arg0, ...argNames]) + : arg0; + + return (args: any[]) => constructPayload(args, extractors); +} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/command.ts b/packages/cursorless-vscode/src/keyboard/grammar/command.ts new file mode 100644 index 0000000000..423fe938d7 --- /dev/null +++ b/packages/cursorless-vscode/src/keyboard/grammar/command.ts @@ -0,0 +1,82 @@ +import { KeyboardCommandArgTypes } from "../KeyboardCommandTypeHelpers"; +import { CommandRulePostProcessor } from "./CommandRulePostProcessor"; +import { + ArgExtractor, + Unused, + getArgExtractors, + constructPayload, +} from "@cursorless/cursorless-engine"; + +/** + * 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 argExtractors} 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. + * + * The {@link argExtractors} argument can be either: + * + * - A function that takes the output of the parser rule and returns the + * command's argument payload. For example: + * + * ```ts + * p = command("foo", (args) => ({ bar: args[0], baz: args[1] })) + * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "a", baz: "b" } } + * ``` + * - An object mapping the names of the arguments to the command's argument + * payload to the positions of the values in the parser rule's output. For + * example: + * + * ```ts + * p = command("foo", { bar: $1, baz: "hello" }) + * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "b", baz: "hello" } } + * ``` + * + * - An array of the names of the arguments to the command's argument payload. + * For example: + * + * ```ts + * p = command("foo", ["bar", "baz"]) + * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "a", baz: "b" } } + * ``` + * + * @param type The type of the command + * @param argExtractors The extractors to use to get the command's argument (see + * above) + * @returns A postprocess function for the command + */ + +export function command( + type: T, + argExtractors: + | ArgExtractor + | (keyof KeyboardCommandArgTypes[T] | Unused)[] + | ((args: any[]) => KeyboardCommandArgTypes[T]), +): CommandRulePostProcessor { + let extractArgs: (args: any[]) => KeyboardCommandArgTypes[T]; + + if (typeof argExtractors === "function") { + extractArgs = argExtractors; + } else { + const extractors = Array.isArray(argExtractors) + ? getArgExtractors(argExtractors) + : argExtractors; + extractArgs = (args: any[]) => + constructPayload(args, extractors) as KeyboardCommandArgTypes[T]; + } + + function ret(args: any[]) { + return { + type, + arg: extractArgs(args), + }; + } + ret.metadata = { type }; + return ret; +} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts b/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts index 49a73c3ed0..7c23f4f672 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts +++ b/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts @@ -18,7 +18,8 @@ declare var combineColorAndShape: any; declare var direction: any; declare var digit: any; -import { capture, command, UNUSED as _, argPositions } from "../grammarHelpers" +import { capture, UNUSED as _, argPositions } from "@cursorless/cursorless-engine" +import { command } from "../command" import { keyboardLexer } from "../keyboardLexer"; const { $0, $1, $2 } = argPositions; diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne b/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne index 65c59eabe4..f5f0d3fba0 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne +++ b/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne @@ -1,6 +1,7 @@ @preprocessor typescript @{% -import { capture, command, UNUSED as _, argPositions } from "../grammarHelpers" +import { capture, UNUSED as _, argPositions } from "@cursorless/cursorless-engine" +import { command } from "../command" import { keyboardLexer } from "../keyboardLexer"; const { $0, $1, $2 } = argPositions; diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts b/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts deleted file mode 100644 index a716da26c8..0000000000 --- a/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { isString } from "lodash"; -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 argExtractors Extractors to get values for 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[], - argExtractors: KeyboardArgExtractor, -): Record { - const arg: Partial> = {}; - for (const [key, value] of Object.entries(argExtractors)) { - if (value instanceof ArgPosition) { - arg[key as keyof K] = args[value.position]; - } else { - arg[key as keyof K] = value; - } - } - return arg as Record; -} - -class ArgPosition { - constructor(public position: number) {} -} - -export const argPositions: Record = { - $0: new ArgPosition(0), - $1: new ArgPosition(1), - $2: new ArgPosition(2), -}; - -type KeyboardArgExtractor = { - [K in keyof T]: T[K] | ArgPosition; -}; - -/** - * 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 argExtractors} 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. - * - * The {@link argExtractors} argument can be either: - * - * - A function that takes the output of the parser rule and returns the - * command's argument payload. For example: - * - * ```ts - * p = command("foo", (args) => ({ bar: args[0], baz: args[1] })) - * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "a", baz: "b" } } - * ``` - * - An object mapping the names of the arguments to the command's argument - * payload to the positions of the values in the parser rule's output. For - * example: - * - * ```ts - * p = command("foo", { bar: $1, baz: "hello" }) - * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "b", baz: "hello" } } - * ``` - * - * - An array of the names of the arguments to the command's argument payload. - * For example: - * - * ```ts - * p = command("foo", ["bar", "baz"]) - * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "a", baz: "b" } } - * ``` - * - * @param type The type of the command - * @param argExtractors The extractors to use to get the command's argument (see - * above) - * @returns A postprocess function for the command - */ -export function command( - type: T, - argExtractors: - | KeyboardArgExtractor - | (keyof KeyboardCommandArgTypes[T] | Unused)[] - | ((args: any[]) => KeyboardCommandArgTypes[T]), -): CommandRulePostProcessor { - let extractArgs: (args: any[]) => KeyboardCommandArgTypes[T]; - - if (typeof argExtractors === "function") { - extractArgs = argExtractors; - } else { - const extractors = Array.isArray(argExtractors) - ? getArgExtractors(argExtractors) - : argExtractors; - extractArgs = (args: any[]) => - constructPayload(args, extractors) as KeyboardCommandArgTypes[T]; - } - - function ret(args: any[]) { - return { - type, - arg: extractArgs(args), - }; - } - ret.metadata = { type }; - return ret; -} - -function getArgExtractors(argExtractors: (keyof T | typeof UNUSED)[]): T { - return Object.fromEntries( - argExtractors - .map((arg, i) => [arg, new ArgPosition(i)]) - .filter(([arg]) => arg !== UNUSED), - ); -} - -/** - * 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 argExtractor The extractors to use to get the argument 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( - argExtractor: KeyboardArgExtractor>, -): (args: any[]) => Record; -export function capture( - ...argNames: (string | Unused)[] -): (args: any[]) => Record; -export function capture( - arg0: (string | Unused) | KeyboardArgExtractor>, - ...argNames: (string | Unused)[] -): (args: any[]) => Record { - const extractors = - isString(arg0) || arg0 === UNUSED - ? getArgExtractors([arg0, ...argNames]) - : arg0; - - return (args: any[]) => constructPayload(args, extractors); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0744cade5c..6b0f7bd24e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,12 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + moo: + specifier: 0.5.2 + version: 0.5.2 + nearley: + specifier: 2.20.1 + version: 2.20.1(patch_hash=mg2fc7wgvzub3myuz6m74hllma) node-html-parser: specifier: ^6.1.12 version: 6.1.12 @@ -281,6 +287,12 @@ importers: '@types/mocha': specifier: ^10.0.6 version: 10.0.6 + '@types/moo': + specifier: 0.5.9 + version: 0.5.9 + '@types/nearley': + specifier: 2.11.5 + version: 2.11.5(patch_hash=5bomp3nnmdzdyzcgrxyr5kymae) '@types/sbd': specifier: ^1.0.5 version: 1.0.5 @@ -5070,6 +5082,10 @@ packages: resolution: {integrity: sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==} dev: true + /@types/moo@0.5.9: + resolution: {integrity: sha512-ZsFVecFi66jGQ6L41TonEaBhsIVeVftTz6iQKWTctzacHhzYHWvv9S0IyAJi4BhN7vb9qCQ3+kpStP2vbZqmDg==} + dev: true + /@types/ms@0.7.34: resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} diff --git a/scripts/build-and-assemble-website.sh b/scripts/build-and-assemble-website.sh index 61585f19dd..d17b50c57a 100755 --- a/scripts/build-and-assemble-website.sh +++ b/scripts/build-and-assemble-website.sh @@ -10,7 +10,7 @@ NODE_OPTIONS="--max-old-space-size=6144" \ --filter 'cursorless-org-*' \ build -pnpm -F cursorless-vscode generate-railroad +pnpm -r generate-railroad # Merge the root site and the documentation site, placing the documentation site # under docs/ @@ -24,3 +24,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" +cp packages/cursorless-engine/out/railroad.html "$root_dir/custom-command-railroad.html"