diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index f42a47ee7e5..4f616381262 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -11,6 +11,7 @@ export { getKey, splitKey } from "./util/splitKey"; export { hrtimeBigintToSeconds } from "./util/timeUtils"; export * from "./util/walkSync"; export * from "./util/walkAsync"; +export * from "./util/camelCaseToAllDown"; export { Notifier } from "./util/Notifier"; export type { Listener } from "./util/Notifier"; export type { TokenHatSplittingMode } from "./ide/types/Configuration"; @@ -42,6 +43,7 @@ export * from "./types/TextEditorOptions"; export * from "./types/TextLine"; export * from "./types/Token"; export * from "./types/HatTokenMap"; +export * from "./types/SpokenForm"; export * from "./util/textFormatters"; export * from "./types/snippet.types"; export * from "./testUtil/fromPlainObject"; diff --git a/packages/common/src/types/SpokenForm.ts b/packages/common/src/types/SpokenForm.ts new file mode 100644 index 00000000000..5170af44e1f --- /dev/null +++ b/packages/common/src/types/SpokenForm.ts @@ -0,0 +1,14 @@ +export interface SpokenFormSuccess { + type: "success"; + preferred: string; + alternatives: string[]; +} + +export interface SpokenFormError { + type: "error"; + reason: string; + requiresTalonUpdate: boolean; + isSecret: boolean; +} + +export type SpokenForm = SpokenFormSuccess | SpokenFormError; diff --git a/packages/common/src/util/camelCaseToAllDown.ts b/packages/common/src/util/camelCaseToAllDown.ts new file mode 100644 index 00000000000..bb21c5e8d69 --- /dev/null +++ b/packages/common/src/util/camelCaseToAllDown.ts @@ -0,0 +1,7 @@ +export function camelCaseToAllDown(input: string): string { + return input + .replace(/([A-Z])/g, " $1") + .split(" ") + .map((word) => word.toLowerCase()) + .join(" "); +} diff --git a/packages/cursorless-engine/src/DefaultSpokenFormMap.ts b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts new file mode 100644 index 00000000000..340abbd0cc1 --- /dev/null +++ b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts @@ -0,0 +1,194 @@ +import { mapValues } from "lodash"; +import { + SpokenFormMap, + SpokenFormMapEntry, + SpokenFormMapKeyTypes, +} from "./SpokenFormMap"; + +type DefaultSpokenFormMapDefinition = { + readonly [K in keyof SpokenFormMapKeyTypes]: Readonly< + Record + >; +}; + +const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { + pairedDelimiter: { + curlyBrackets: "curly", + angleBrackets: "diamond", + escapedDoubleQuotes: "escaped quad", + escapedSingleQuotes: "escaped twin", + escapedParentheses: "escaped round", + escapedSquareBrackets: "escaped box", + doubleQuotes: "quad", + parentheses: "round", + backtickQuotes: "skis", + squareBrackets: "box", + singleQuotes: "twin", + any: "pair", + string: "string", + whitespace: "void", + }, + + simpleScopeTypeType: { + argumentOrParameter: "arg", + attribute: "attribute", + functionCall: "call", + functionCallee: "callee", + className: "class name", + class: "class", + comment: "comment", + functionName: "funk name", + namedFunction: "funk", + ifStatement: "if state", + instance: "instance", + collectionItem: "item", + collectionKey: "key", + anonymousFunction: "lambda", + list: "list", + map: "map", + name: "name", + regularExpression: "regex", + section: "section", + sectionLevelOne: disabledByDefault("one section"), + sectionLevelTwo: disabledByDefault("two section"), + sectionLevelThree: disabledByDefault("three section"), + sectionLevelFour: disabledByDefault("four section"), + sectionLevelFive: disabledByDefault("five section"), + sectionLevelSix: disabledByDefault("six section"), + selector: "selector", + statement: "state", + branch: "branch", + type: "type", + value: "value", + condition: "condition", + unit: "unit", + // XML, JSX + xmlElement: "element", + xmlBothTags: "tags", + xmlStartTag: "start tag", + xmlEndTag: "end tag", + // LaTeX + part: "part", + chapter: "chapter", + subSection: "subsection", + subSubSection: "subsubsection", + namedParagraph: "paragraph", + subParagraph: "subparagraph", + environment: "environment", + // Talon + command: "command", + // Text-based scope types + character: "char", + word: "word", + token: "token", + identifier: "identifier", + line: "line", + sentence: "sentence", + paragraph: "block", + document: "file", + nonWhitespaceSequence: "paint", + boundedNonWhitespaceSequence: "short paint", + url: "link", + notebookCell: "cell", + + string: secret("parse tree string"), + switchStatementSubject: secret("subject"), + }, + + surroundingPairForceDirection: { + left: "left", + right: "right", + }, + + simpleModifier: { + excludeInterior: "bounds", + toRawSelection: "just", + leading: "leading", + trailing: "trailing", + keepContentFilter: "content", + keepEmptyFilter: "empty", + inferPreviousMark: "its", + startOf: "start of", + endOf: "end of", + interiorOnly: "inside", + extendThroughStartOf: "head", + extendThroughEndOf: "tail", + everyScope: "every", + }, + + modifierExtra: { + first: "first", + last: "last", + previous: "previous", + next: "next", + forward: "forward", + backward: "backward", + }, + + customRegex: {}, +}; + +function disabledByDefault( + ...spokenForms: string[] +): DefaultSpokenFormMapEntry { + return { + defaultSpokenForms: spokenForms, + isDisabledByDefault: true, + isSecret: false, + }; +} + +function secret(...spokenForms: string[]): DefaultSpokenFormMapEntry { + return { + defaultSpokenForms: spokenForms, + isDisabledByDefault: true, + isSecret: true, + }; +} + +export interface DefaultSpokenFormMapEntry { + defaultSpokenForms: string[]; + isDisabledByDefault: boolean; + isSecret: boolean; +} + +export type DefaultSpokenFormMap = { + readonly [K in keyof SpokenFormMapKeyTypes]: Readonly< + Record + >; +}; + +// FIXME: Don't cast here; need to make our own mapValues with stronger typing +// using tricks from our object.d.ts +export const defaultSpokenFormInfo = mapValues( + defaultSpokenFormMapCore, + (entry) => + mapValues(entry, (subEntry) => + typeof subEntry === "string" + ? { + defaultSpokenForms: [subEntry], + isDisabledByDefault: false, + isSecret: false, + } + : subEntry, + ), +) as DefaultSpokenFormMap; + +// FIXME: Don't cast here; need to make our own mapValues with stronger typing +// using tricks from our object.d.ts +export const defaultSpokenFormMap = mapValues(defaultSpokenFormInfo, (entry) => + mapValues( + entry, + ({ + defaultSpokenForms, + isDisabledByDefault, + isSecret, + }): SpokenFormMapEntry => ({ + spokenForms: isDisabledByDefault ? [] : defaultSpokenForms, + isCustom: false, + defaultSpokenForms, + requiresTalonUpdate: false, + isSecret, + }), + ), +) as SpokenFormMap; diff --git a/packages/cursorless-engine/src/SpokenFormMap.ts b/packages/cursorless-engine/src/SpokenFormMap.ts new file mode 100755 index 00000000000..4d4a4e691cb --- /dev/null +++ b/packages/cursorless-engine/src/SpokenFormMap.ts @@ -0,0 +1,52 @@ +import { + ModifierType, + SimpleScopeTypeType, + SurroundingPairName, +} from "@cursorless/common"; + +export type SpeakableSurroundingPairName = + | Exclude + | "whitespace"; + +export type SimpleModifierType = Exclude< + ModifierType, + | "containingScope" + | "ordinalScope" + | "relativeScope" + | "modifyIfUntyped" + | "cascading" + | "range" +>; + +export type ModifierExtra = + | "first" + | "last" + | "previous" + | "next" + | "forward" + | "backward"; + +export interface SpokenFormMapKeyTypes { + pairedDelimiter: SpeakableSurroundingPairName; + simpleScopeTypeType: SimpleScopeTypeType; + surroundingPairForceDirection: "left" | "right"; + simpleModifier: SimpleModifierType; + modifierExtra: ModifierExtra; + customRegex: string; +} + +export type SpokenFormType = keyof SpokenFormMapKeyTypes; + +export interface SpokenFormMapEntry { + spokenForms: string[]; + isCustom: boolean; + defaultSpokenForms: string[]; + requiresTalonUpdate: boolean; + isSecret: boolean; +} + +export type SpokenFormMap = { + readonly [K in keyof SpokenFormMapKeyTypes]: Readonly< + Record + >; +}; diff --git a/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts b/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts new file mode 100644 index 00000000000..a6b85117efa --- /dev/null +++ b/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts @@ -0,0 +1,48 @@ +import { + SpokenFormMap, + SpokenFormMapEntry, + SpokenFormMapKeyTypes, + SpokenFormType, +} from "../SpokenFormMap"; + +export type GeneratorSpokenFormMap = { + readonly [K in keyof SpokenFormMapKeyTypes]: Record< + SpokenFormMapKeyTypes[K], + SingleTermSpokenForm + >; +}; + +export interface SingleTermSpokenForm { + type: "singleTerm"; + spokenForms: SpokenFormMapEntry; + spokenFormType: SpokenFormType; + id: string; +} + +export type SpokenFormComponent = + | SingleTermSpokenForm + | string + | SpokenFormComponent[]; + +export function getGeneratorSpokenForms( + spokenFormMap: SpokenFormMap, +): GeneratorSpokenFormMap { + // FIXME: Don't cast here; need to make our own mapValues with stronger typing + // using tricks from our object.d.ts + return Object.fromEntries( + Object.entries(spokenFormMap).map(([spokenFormType, map]) => [ + spokenFormType, + Object.fromEntries( + Object.entries(map).map(([id, spokenForms]) => [ + id, + { + type: "singleTerm", + spokenForms, + spokenFormType, + id, + }, + ]), + ), + ]), + ) as GeneratorSpokenFormMap; +} diff --git a/packages/cursorless-engine/src/generateSpokenForm/NoSpokenFormError.ts b/packages/cursorless-engine/src/generateSpokenForm/NoSpokenFormError.ts index d7d00eaad81..30e055026d2 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/NoSpokenFormError.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/NoSpokenFormError.ts @@ -1,5 +1,9 @@ export class NoSpokenFormError extends Error { - constructor(public reason: string) { + constructor( + public reason: string, + public requiresTalonUpdate: boolean = false, + public isSecret: boolean = false, + ) { super(`No spoken form for: ${reason}`); } } diff --git a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts index 88852cc14cf..2b632ce328d 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts @@ -1,135 +1,12 @@ +import { CompositeKeyMap } from "@cursorless/common"; +import { SpeakableSurroundingPairName } from "../../SpokenFormMap"; import { - ModifierType, - SimpleScopeTypeType, - SurroundingPairName, - CompositeKeyMap, -} from "@cursorless/common"; - -export const modifiers = { - excludeInterior: "bounds", - toRawSelection: "just", - leading: "leading", - trailing: "trailing", - keepContentFilter: "content", - keepEmptyFilter: "empty", - inferPreviousMark: "its", - startOf: "start of", - endOf: "end of", - interiorOnly: "inside", - extendThroughStartOf: "head", - extendThroughEndOf: "tail", - everyScope: "every", - - containingScope: null, - ordinalScope: null, - relativeScope: null, - modifyIfUntyped: null, - cascading: null, - range: null, -} as const satisfies Record; - -export const modifiersExtra = { - first: "first", - last: "last", - previous: "previous", - next: "next", - forward: "forward", - backward: "backward", -}; - -export const scopeSpokenForms = { - argumentOrParameter: "arg", - attribute: "attribute", - functionCall: "call", - functionCallee: "callee", - className: "class name", - class: "class", - comment: "comment", - functionName: "funk name", - namedFunction: "funk", - ifStatement: "if state", - instance: "instance", - collectionItem: "item", - collectionKey: "key", - anonymousFunction: "lambda", - list: "list", - map: "map", - name: "name", - regularExpression: "regex", - section: "section", - sectionLevelOne: "one section", - sectionLevelTwo: "two section", - sectionLevelThree: "three section", - sectionLevelFour: "four section", - sectionLevelFive: "five section", - sectionLevelSix: "six section", - selector: "selector", - statement: "state", - string: "string", - branch: "branch", - type: "type", - value: "value", - condition: "condition", - unit: "unit", - // XML, JSX - xmlElement: "element", - xmlBothTags: "tags", - xmlStartTag: "start tag", - xmlEndTag: "end tag", - // LaTeX - part: "part", - chapter: "chapter", - subSection: "subsection", - subSubSection: "subsubsection", - namedParagraph: "paragraph", - subParagraph: "subparagraph", - environment: "environment", - // Talon - command: "command", - // Text-based scope types - character: "char", - word: "word", - token: "token", - identifier: "identifier", - line: "line", - sentence: "sentence", - paragraph: "block", - document: "file", - nonWhitespaceSequence: "paint", - boundedNonWhitespaceSequence: "short paint", - url: "link", - notebookCell: "cell", - - switchStatementSubject: null, -} as const satisfies Record; - -type ExtendedSurroundingPairName = SurroundingPairName | "whitespace"; - -const surroundingPairsSpoken: Record< - ExtendedSurroundingPairName, - string | null -> = { - curlyBrackets: "curly", - angleBrackets: "diamond", - escapedDoubleQuotes: "escaped quad", - escapedSingleQuotes: "escaped twin", - escapedParentheses: "escaped round", - escapedSquareBrackets: "escaped box", - doubleQuotes: "quad", - parentheses: "round", - backtickQuotes: "skis", - squareBrackets: "box", - singleQuotes: "twin", - any: "pair", - string: "string", - whitespace: "void", - - // Used internally by the "item" scope type - collectionBoundary: null, -}; + GeneratorSpokenFormMap, + SingleTermSpokenForm, +} from "../GeneratorSpokenFormMap"; const surroundingPairsDelimiters: Record< - ExtendedSurroundingPairName, + SpeakableSurroundingPairName, [string, string] | null > = { curlyBrackets: ["{", "}"], @@ -147,38 +24,20 @@ const surroundingPairsDelimiters: Record< any: null, string: null, - collectionBoundary: null, }; + const surroundingPairDelimiterToName = new CompositeKeyMap< [string, string], - SurroundingPairName + SpeakableSurroundingPairName >((pair) => pair); for (const [name, pair] of Object.entries(surroundingPairsDelimiters)) { if (pair != null) { - surroundingPairDelimiterToName.set(pair, name as SurroundingPairName); - } -} - -export const surroundingPairForceDirections = { - left: "left", - right: "right", -}; - -/** - * Given a pair name (eg `parentheses`), returns the spoken form of the - * surrounding pair. - * @param surroundingPair The name of the surrounding pair - * @returns The spoken form of the surrounding pair - */ -export function surroundingPairNameToSpokenForm( - surroundingPair: SurroundingPairName, -): string { - const result = surroundingPairsSpoken[surroundingPair]; - if (result == null) { - throw Error(`Unknown surrounding pair '${surroundingPair}'`); + surroundingPairDelimiterToName.set( + pair, + name as SpeakableSurroundingPairName, + ); } - return result; } /** @@ -190,12 +49,13 @@ export function surroundingPairNameToSpokenForm( * @returns The spoken form of the surrounding pair */ export function surroundingPairDelimitersToSpokenForm( + spokenFormMap: GeneratorSpokenFormMap, left: string, right: string, -): string { +): SingleTermSpokenForm { const pairName = surroundingPairDelimiterToName.get([left, right]); if (pairName == null) { throw Error(`Unknown surrounding pair delimiters '${left} ${right}'`); } - return surroundingPairNameToSpokenForm(pairName); + return spokenFormMap.pairedDelimiter[pairName]; } diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts index 6e8e2d31f42..fc063c51adc 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts @@ -8,36 +8,77 @@ import * as yaml from "js-yaml"; import * as assert from "node:assert"; import { promises as fsp } from "node:fs"; import { canonicalizeAndValidateCommand } from "../core/commandVersionUpgrades/canonicalizeAndValidateCommand"; -import { generateSpokenForm } from "./generateSpokenForm"; import { getHatMapCommand } from "./getHatMapCommand"; +import { SpokenFormGenerator } from "."; +import { defaultSpokenFormInfo } from "../DefaultSpokenFormMap"; +import { mapValues } from "lodash"; +import { SpokenFormMap, SpokenFormMapEntry } from "../SpokenFormMap"; + +const spokenFormMap = mapValues(defaultSpokenFormInfo, (entry) => + mapValues( + entry, + ({ defaultSpokenForms }): SpokenFormMapEntry => ({ + spokenForms: defaultSpokenForms, + isCustom: false, + defaultSpokenForms, + requiresTalonUpdate: false, + isSecret: false, + }), + ), +) as SpokenFormMap; suite("Generate spoken forms", () => { getRecordedTestPaths().forEach(({ name, path }) => test(name, () => runTest(path)), ); + + test("generate spoken form for custom regex", () => { + const generator = new SpokenFormGenerator({ + ...spokenFormMap, + customRegex: { + foo: { + spokenForms: ["bar"], + isCustom: false, + defaultSpokenForms: ["bar"], + requiresTalonUpdate: false, + isSecret: false, + }, + }, + }); + + const spokenForm = generator.scopeType({ + type: "customRegex", + regex: "foo", + }); + + assert(spokenForm.type === "success"); + assert.equal(spokenForm.preferred, "bar"); + }); }); async function runTest(file: string) { const buffer = await fsp.readFile(file); const fixture = yaml.load(buffer.toString()) as TestCaseFixtureLegacy; - const generatedSpokenForm = generateSpokenForm( + const generator = new SpokenFormGenerator(spokenFormMap); + + const generatedSpokenForm = generator.command( canonicalizeAndValidateCommand(fixture.command), ); if (fixture.marksToCheck != null && generatedSpokenForm.type === "success") { // If the test has marks to check (eg a hat token map test), it will end in // "take " as a way to indicate which mark to check - const hatMapSpokenForm = generateSpokenForm( + const hatMapSpokenForm = generator.command( getHatMapCommand(fixture.marksToCheck), ); assert(hatMapSpokenForm.type === "success"); - generatedSpokenForm.value += " " + hatMapSpokenForm.value; + generatedSpokenForm.preferred += " " + hatMapSpokenForm.preferred; } if (shouldUpdateFixtures()) { if (generatedSpokenForm.type === "success") { - fixture.command.spokenForm = generatedSpokenForm.value; + fixture.command.spokenForm = generatedSpokenForm.preferred; fixture.spokenFormError = undefined; } else { fixture.spokenFormError = generatedSpokenForm.reason; @@ -47,7 +88,7 @@ async function runTest(file: string) { await fsp.writeFile(file, serializeTestFixture(fixture)); } else { if (generatedSpokenForm.type === "success") { - assert.equal(fixture.command.spokenForm, generatedSpokenForm.value); + assert.equal(fixture.command.spokenForm, generatedSpokenForm.preferred); assert.equal(fixture.spokenFormError, undefined); } else { assert.equal(fixture.spokenFormError, generatedSpokenForm.reason); diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts index 34f5dda0288..9d5bea37b82 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts @@ -4,8 +4,9 @@ import { DestinationDescriptor, InsertionMode, PartialTargetDescriptor, + ScopeType, + camelCaseToAllDown, } from "@cursorless/common"; -import { RecursiveArray, flattenDeep } from "lodash"; import { NoSpokenFormError } from "./NoSpokenFormError"; import { actions } from "./defaultSpokenForms/actions"; import { connectives } from "./defaultSpokenForms/connectives"; @@ -15,191 +16,274 @@ import { wrapperSnippetToSpokenForm, } from "./defaultSpokenForms/snippets"; import { getRangeConnective } from "./getRangeConnective"; -import { primitiveTargetToSpokenForm } from "./primitiveTargetToSpokenForm"; +import { SpokenFormMap } from "../SpokenFormMap"; +import { PrimitiveTargetSpokenFormGenerator } from "./primitiveTargetToSpokenForm"; +import { + GeneratorSpokenFormMap, + SpokenFormComponent, + getGeneratorSpokenForms, +} from "./GeneratorSpokenFormMap"; +import { SpokenForm } from "@cursorless/common"; -export interface SpokenFormSuccess { - type: "success"; - value: string; -} +export class SpokenFormGenerator { + private primitiveGenerator: PrimitiveTargetSpokenFormGenerator; + private spokenFormMap: GeneratorSpokenFormMap; -export interface SpokenFormError { - type: "error"; - reason: string; -} + constructor(spokenFormMap: SpokenFormMap) { + this.spokenFormMap = getGeneratorSpokenForms(spokenFormMap); -export type SpokenForm = SpokenFormSuccess | SpokenFormError; + this.primitiveGenerator = new PrimitiveTargetSpokenFormGenerator( + this.spokenFormMap, + ); + } -/** - * Given a command, generates its spoken form. - * @param command The command to generate a spoken form for - * @returns The spoken form of the command, or null if the command has no spoken - * form - */ -export function generateSpokenForm(command: CommandComplete): SpokenForm { - try { - const components = generateSpokenFormComponents(command.action); - return { type: "success", value: flattenDeep(components).join(" ") }; - } catch (e) { - if (e instanceof NoSpokenFormError) { - return { type: "error", reason: e.reason }; - } + /** + * Given a command, generates its spoken form. + * @param command The command to generate a spoken form for + * @returns The spoken form of the command, or null if the command has no spoken + * form + */ + command(command: CommandComplete): SpokenForm { + return this.componentsToSpokenForm(() => this.handleAction(command.action)); + } - throw e; + /** + * Given a command, generates its spoken form. + * @param command The command to generate a spoken form for + * @returns The spoken form of the command, or null if the command has no spoken + * form + */ + scopeType(scopeType: ScopeType): SpokenForm { + return this.componentsToSpokenForm(() => [ + this.primitiveGenerator.handleScopeType(scopeType), + ]); } -} -function generateSpokenFormComponents( - action: ActionDescriptor, -): RecursiveArray { - switch (action.name) { - case "editNew": - case "getText": - case "replace": - case "executeCommand": - case "private.getTargets": - throw new NoSpokenFormError(`Action '${action.name}'`); - - case "replaceWithTarget": - case "moveToTarget": - return [ - actions[action.name], - targetToSpokenForm(action.source), - destinationToSpokenForm(action.destination), - ]; - - case "swapTargets": - return [ - actions[action.name], - targetToSpokenForm(action.target1), - connectives.swapConnective, - targetToSpokenForm(action.target2), - ]; - - case "callAsFunction": - if (action.argument.type === "implicit") { - return [actions[action.name], targetToSpokenForm(action.callee)]; + private componentsToSpokenForm( + getComponents: () => SpokenFormComponent, + ): SpokenForm { + try { + const components = getComponents(); + const [preferred, ...alternatives] = constructSpokenForms(components); + return { type: "success", preferred, alternatives }; + } catch (e) { + if (e instanceof NoSpokenFormError) { + return { + type: "error", + reason: e.reason, + requiresTalonUpdate: e.requiresTalonUpdate, + isSecret: e.isSecret, + }; } - return [ - actions[action.name], - targetToSpokenForm(action.callee), - "on", - targetToSpokenForm(action.argument), - ]; - - case "wrapWithPairedDelimiter": - case "rewrapWithPairedDelimiter": - return [ - surroundingPairDelimitersToSpokenForm(action.left, action.right), - actions[action.name], - targetToSpokenForm(action.target), - ]; - - case "pasteFromClipboard": - return [ - actions[action.name], - destinationToSpokenForm(action.destination), - ]; - - case "insertSnippet": - return [ - actions[action.name], - insertionSnippetToSpokenForm(action.snippetDescription), - destinationToSpokenForm(action.destination), - ]; - - case "generateSnippet": - if (action.snippetName != null) { - throw new NoSpokenFormError(`${action.name}.snippetName`); - } - return [actions[action.name], targetToSpokenForm(action.target)]; - - case "wrapWithSnippet": - return [ - wrapperSnippetToSpokenForm(action.snippetDescription), - actions[action.name], - targetToSpokenForm(action.target), - ]; - - case "highlight": { - if (action.highlightId != null) { - throw new NoSpokenFormError(`${action.name}.highlightId`); - } - return [actions[action.name], targetToSpokenForm(action.target)]; + + throw e; } + } + + private handleAction(action: ActionDescriptor): SpokenFormComponent { + switch (action.name) { + case "editNew": + case "getText": + case "replace": + case "executeCommand": + case "private.getTargets": + throw new NoSpokenFormError(`Action '${action.name}'`); + + case "replaceWithTarget": + case "moveToTarget": + return [ + actions[action.name], + this.handleTarget(action.source), + this.handleDestination(action.destination), + ]; + + case "swapTargets": + return [ + actions[action.name], + this.handleTarget(action.target1), + connectives.swapConnective, + this.handleTarget(action.target2), + ]; + + case "callAsFunction": + if (action.argument.type === "implicit") { + return [actions[action.name], this.handleTarget(action.callee)]; + } + return [ + actions[action.name], + this.handleTarget(action.callee), + "on", + this.handleTarget(action.argument), + ]; + + case "wrapWithPairedDelimiter": + case "rewrapWithPairedDelimiter": + return [ + surroundingPairDelimitersToSpokenForm( + this.spokenFormMap, + action.left, + action.right, + ), + actions[action.name], + this.handleTarget(action.target), + ]; + + case "pasteFromClipboard": + return [ + actions[action.name], + this.handleDestination(action.destination), + ]; + + case "insertSnippet": + return [ + actions[action.name], + insertionSnippetToSpokenForm(action.snippetDescription), + this.handleDestination(action.destination), + ]; + + case "generateSnippet": + if (action.snippetName != null) { + throw new NoSpokenFormError(`${action.name}.snippetName`); + } + return [actions[action.name], this.handleTarget(action.target)]; + + case "wrapWithSnippet": + return [ + wrapperSnippetToSpokenForm(action.snippetDescription), + actions[action.name], + this.handleTarget(action.target), + ]; + + case "highlight": { + if (action.highlightId != null) { + throw new NoSpokenFormError(`${action.name}.highlightId`); + } + return [actions[action.name], this.handleTarget(action.target)]; + } - default: { - return [actions[action.name], targetToSpokenForm(action.target)]; + default: { + return [actions[action.name], this.handleTarget(action.target)]; + } } } -} -function targetToSpokenForm( - target: PartialTargetDescriptor, -): RecursiveArray { - switch (target.type) { - case "list": - if (target.elements.length < 2) { - throw new NoSpokenFormError("List target with < 2 elements"); + private handleTarget(target: PartialTargetDescriptor): SpokenFormComponent { + switch (target.type) { + case "list": + if (target.elements.length < 2) { + throw new NoSpokenFormError("List target with < 2 elements"); + } + + return target.elements.map((element, i) => + i === 0 + ? this.handleTarget(element) + : [connectives.listConnective, this.handleTarget(element)], + ); + + case "range": { + const anchor = this.handleTarget(target.anchor); + const active = this.handleTarget(target.active); + const connective = getRangeConnective( + target.excludeAnchor, + target.excludeActive, + target.rangeType, + ); + return [anchor, connective, active]; } - return target.elements.map((element, i) => - i === 0 - ? targetToSpokenForm(element) - : [connectives.listConnective, targetToSpokenForm(element)], - ); - - case "range": { - const anchor = targetToSpokenForm(target.anchor); - const active = targetToSpokenForm(target.active); - const connective = getRangeConnective( - target.excludeAnchor, - target.excludeActive, - target.rangeType, - ); - return [anchor, connective, active]; + case "primitive": + return this.primitiveGenerator.handlePrimitiveTarget(target); + + case "implicit": + return []; } + } + + private handleDestination( + destination: DestinationDescriptor, + ): SpokenFormComponent { + switch (destination.type) { + case "list": + if (destination.destinations.length < 2) { + throw new NoSpokenFormError("List destination with < 2 elements"); + } + + return destination.destinations.map((destination, i) => + i === 0 + ? this.handleDestination(destination) + : [connectives.listConnective, this.handleDestination(destination)], + ); - case "primitive": - return primitiveTargetToSpokenForm(target); + case "primitive": + return [ + this.handleInsertionMode(destination.insertionMode), + this.handleTarget(destination.target), + ]; - case "implicit": - return []; + case "implicit": + return []; + } + } + + private handleInsertionMode(insertionMode: InsertionMode): string { + switch (insertionMode) { + case "to": + return connectives.sourceDestinationConnective; + case "before": + return connectives.before; + case "after": + return connectives.after; + } } } -function destinationToSpokenForm( - destination: DestinationDescriptor, -): RecursiveArray { - switch (destination.type) { - case "list": - if (destination.destinations.length < 2) { - throw new NoSpokenFormError("List destination with < 2 elements"); - } +function constructSpokenForms(component: SpokenFormComponent): string[] { + if (typeof component === "string") { + return [component]; + } - return destination.destinations.map((destination, i) => - i === 0 - ? destinationToSpokenForm(destination) - : [connectives.listConnective, destinationToSpokenForm(destination)], - ); + if (Array.isArray(component)) { + if (component.length === 0) { + return [""]; + } - case "primitive": - return [ - insertionModeToSpokenForm(destination.insertionMode), - targetToSpokenForm(destination.target), - ]; + return cartesianProduct(component.map(constructSpokenForms)).map((words) => + words.filter((word) => word.length !== 0).join(" "), + ); + } - case "implicit": - return []; + if (component.spokenForms.spokenForms.length === 0) { + throw new NoSpokenFormError( + `${camelCaseToAllDown(component.spokenFormType)} with id ${ + component.id + }; please see https://www.cursorless.org/docs/user/customization/ for more information`, + component.spokenForms.requiresTalonUpdate, + component.spokenForms.isSecret, + ); } + + return component.spokenForms.spokenForms; } -function insertionModeToSpokenForm(insertionMode: InsertionMode): string { - switch (insertionMode) { - case "to": - return connectives.sourceDestinationConnective; - case "before": - return connectives.before; - case "after": - return connectives.after; +/** + * Given an array of arrays, constructs all possible combinations of the + * elements of the arrays. For example, given [[1, 2], [3, 4]], returns [[1, 3], + * [1, 4], [2, 3], [2, 4]]. If any of the arrays are empty, returns an empty + * array. + * @param arrays The arrays to take the cartesian product of + */ +function cartesianProduct(arrays: T[][]): T[][] { + if (arrays.length === 0) { + return []; } + + if (arrays.length === 1) { + return arrays[0].map((element) => [element]); + } + + const [first, ...rest] = arrays; + const restCartesianProduct = cartesianProduct(rest); + return first.flatMap((element) => + restCartesianProduct.map((restElement) => [element, ...restElement]), + ); } diff --git a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts index 59452aa7759..79523126edd 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts @@ -6,9 +6,7 @@ import { RelativeScopeModifier, ScopeType, } from "@cursorless/common"; -import { RecursiveArray } from "lodash"; import { NoSpokenFormError } from "./NoSpokenFormError"; -import { characterToSpokenForm } from "./defaultSpokenForms/characters"; import { connectives } from "./defaultSpokenForms/connectives"; import { hatColorToSpokenForm, @@ -16,275 +14,335 @@ import { lineDirections, marks, } from "./defaultSpokenForms/marks"; -import { - modifiers, - modifiersExtra, - scopeSpokenForms, - surroundingPairForceDirections, - surroundingPairNameToSpokenForm, -} from "./defaultSpokenForms/modifiers"; + +import { getRangeConnective } from "./getRangeConnective"; import { numberToSpokenForm, ordinalToSpokenForm, } from "./defaultSpokenForms/numbers"; -import { getRangeConnective } from "./getRangeConnective"; +import { characterToSpokenForm } from "./defaultSpokenForms/characters"; +import { + GeneratorSpokenFormMap, + SpokenFormComponent, +} from "./GeneratorSpokenFormMap"; -export function primitiveTargetToSpokenForm( - target: PartialPrimitiveTargetDescriptor, -): RecursiveArray { - const components: RecursiveArray = []; - if (target.modifiers != null) { - components.push(target.modifiers.map(modifierToSpokenForm)); - } - if (target.mark != null) { - components.push(markToSpokenForm(target.mark)); +export class PrimitiveTargetSpokenFormGenerator { + constructor(private spokenFormMap: GeneratorSpokenFormMap) { + this.handleModifier = this.handleModifier.bind(this); } - return components; -} - -function modifierToSpokenForm(modifier: Modifier): RecursiveArray { - switch (modifier.type) { - case "cascading": - case "modifyIfUntyped": - throw new NoSpokenFormError(`Modifier '${modifier.type}'`); - - case "containingScope": - return [scopeTypeToSpokenForm(modifier.scopeType)]; - case "everyScope": - return [modifiers.everyScope, scopeTypeToSpokenForm(modifier.scopeType)]; - - case "extendThroughStartOf": - case "extendThroughEndOf": { - const type = modifiers[modifier.type]; - return modifier.modifiers != null - ? [type, modifier.modifiers.map(modifierToSpokenForm)] - : [type]; + handlePrimitiveTarget( + target: PartialPrimitiveTargetDescriptor, + ): SpokenFormComponent { + const components: SpokenFormComponent[] = []; + if (target.modifiers != null) { + components.push(target.modifiers.map(this.handleModifier)); } + if (target.mark != null) { + components.push(this.handleMark(target.mark)); + } + return components; + } - case "relativeScope": - return modifier.offset === 0 - ? relativeScopeInclusiveToSpokenForm(modifier) - : relativeScopeExclusiveToSpokenForm(modifier); - - case "ordinalScope": { - const scope = scopeTypeToSpokenForm(modifier.scopeType); + private handleModifier(modifier: Modifier): SpokenFormComponent { + switch (modifier.type) { + case "cascading": + case "modifyIfUntyped": + throw new NoSpokenFormError(`Modifier '${modifier.type}'`); + + case "containingScope": + return [this.handleScopeType(modifier.scopeType)]; + + case "everyScope": + return [ + this.spokenFormMap.simpleModifier.everyScope, + this.handleScopeType(modifier.scopeType), + ]; + + case "extendThroughStartOf": + case "extendThroughEndOf": { + const type = this.spokenFormMap.simpleModifier[modifier.type]; + return modifier.modifiers != null + ? [type, modifier.modifiers.map(this.handleModifier)] + : [type]; + } - if (modifier.length === 1) { - if (modifier.start === -1) { - return [modifiersExtra.last, scope]; + case "relativeScope": + return modifier.offset === 0 + ? this.handleRelativeScopeInclusive(modifier) + : this.handleRelativeScopeExclusive(modifier); + + case "ordinalScope": { + const scope = this.handleScopeType(modifier.scopeType); + + if (modifier.length === 1) { + if (modifier.start === -1) { + return [this.spokenFormMap.modifierExtra.last, scope]; + } + if (modifier.start === 0) { + return [this.spokenFormMap.modifierExtra.first, scope]; + } + if (modifier.start < 0) { + return [ + ordinalToSpokenForm(Math.abs(modifier.start)), + this.spokenFormMap.modifierExtra.last, + scope, + ]; + } + return [ordinalToSpokenForm(modifier.start + 1), scope]; } + + const number = numberToSpokenForm(modifier.length); + if (modifier.start === 0) { - return [modifiersExtra.first, scope]; + return [ + this.spokenFormMap.modifierExtra.first, + number, + pluralize(scope), + ]; } - if (modifier.start < 0) { + if (modifier.start === -modifier.length) { return [ - ordinalToSpokenForm(Math.abs(modifier.start)), - modifiersExtra.last, - scope, + this.spokenFormMap.modifierExtra.last, + number, + pluralize(scope), ]; } - return [ordinalToSpokenForm(modifier.start + 1), scope]; + + throw new NoSpokenFormError( + `'${modifier.type}' with count > 1 and offset away from start / end`, + ); } - const number = numberToSpokenForm(modifier.length); + case "range": { + if ( + modifier.anchor.type === "ordinalScope" && + modifier.active.type === "ordinalScope" && + modifier.anchor.length === 1 && + modifier.active.length === 1 && + modifier.anchor.scopeType.type === modifier.active.scopeType.type + ) { + const anchor = + modifier.anchor.start === -1 + ? this.spokenFormMap.modifierExtra.last + : ordinalToSpokenForm(modifier.anchor.start + 1); + const active = this.handleModifier(modifier.active); + const connective = getRangeConnective( + modifier.excludeAnchor, + modifier.excludeActive, + ); + return [anchor, connective, active]; + } - if (modifier.start === 0) { - return [modifiersExtra.first, number, pluralize(scope)]; - } - if (modifier.start === -modifier.length) { - return [modifiersExtra.last, number, pluralize(scope)]; + // Throw actual Error here because we're not sure we ever want to support + // a spoken form for these; we may deprecate this construct entirely + throw Error(`Modifier '${modifier.type}' is not fully implemented`); } - throw new NoSpokenFormError( - `'${modifier.type}' with count > 1 and offset away from start / end`, - ); + default: + return [this.spokenFormMap.simpleModifier[modifier.type]]; } + } - case "range": { - if ( - modifier.anchor.type === "ordinalScope" && - modifier.active.type === "ordinalScope" && - modifier.anchor.length === 1 && - modifier.active.length === 1 && - modifier.anchor.scopeType.type === modifier.active.scopeType.type - ) { - const anchor = - modifier.anchor.start === -1 - ? modifiersExtra.last - : ordinalToSpokenForm(modifier.anchor.start + 1); - const active = modifierToSpokenForm(modifier.active); - const connective = getRangeConnective( - modifier.excludeAnchor, - modifier.excludeActive, - ); - return [anchor, connective, active]; - } + private handleRelativeScopeInclusive( + modifier: RelativeScopeModifier, + ): SpokenFormComponent { + const scope = this.handleScopeType(modifier.scopeType); - // Throw actual Error here because we're not sure we ever want to support - // a spoken form for these; we may deprecate this construct entirely - throw Error(`Modifier '${modifier.type}' is not fully implemented`); - } + if (modifier.length === 1) { + const direction = + modifier.direction === "forward" + ? connectives.forward + : connectives.backward; - default: - return [modifiers[modifier.type]]; - } -} + // token forward/backward + return [scope, direction]; + } -function relativeScopeInclusiveToSpokenForm( - modifier: RelativeScopeModifier, -): RecursiveArray { - const scope = scopeTypeToSpokenForm(modifier.scopeType); + const length = numberToSpokenForm(modifier.length); + const scopePlural = pluralize(scope); - if (modifier.length === 1) { - const direction = - modifier.direction === "forward" - ? connectives.forward - : connectives.backward; + // two tokens + // This could also have been "two tokens forward"; there is no way to disambiguate. + if (modifier.direction === "forward") { + return [length, scopePlural]; + } - // token forward/backward - return [scope, direction]; + // two tokens backward + return [length, scopePlural, connectives.backward]; } - const length = numberToSpokenForm(modifier.length); - const scopePlural = pluralize(scope); + private handleRelativeScopeExclusive( + modifier: RelativeScopeModifier, + ): SpokenFormComponent { + const scope = this.handleScopeType(modifier.scopeType); + const direction = + modifier.direction === "forward" + ? connectives.next + : connectives.previous; - // two tokens - // This could also have been "two tokens forward"; there is no way to disambiguate. - if (modifier.direction === "forward") { - return [length, scopePlural]; - } + if (modifier.offset === 1) { + const number = numberToSpokenForm(modifier.length); - // two tokens backward - return [length, scopePlural, connectives.backward]; -} + if (modifier.length === 1) { + // next/previous token + return [direction, scope]; + } -function relativeScopeExclusiveToSpokenForm( - modifier: RelativeScopeModifier, -): RecursiveArray { - const scope = scopeTypeToSpokenForm(modifier.scopeType); - const direction = - modifier.direction === "forward" ? connectives.next : connectives.previous; + const scopePlural = pluralize(scope); - if (modifier.offset === 1) { - const number = numberToSpokenForm(modifier.length); + // next/previous two tokens + return [direction, number, scopePlural]; + } if (modifier.length === 1) { - // next/previous token - return [direction, scope]; + const ordinal = ordinalToSpokenForm(modifier.offset); + // second next/previous token + return [ordinal, direction, scope]; } - const scopePlural = pluralize(scope); - - // next/previous two tokens - return [direction, number, scopePlural]; + throw new NoSpokenFormError( + `${modifier.type} modifier with offset > 1 and length > 1`, + ); } - if (modifier.length === 1) { - const ordinal = ordinalToSpokenForm(modifier.offset); - // second next/previous token - return [ordinal, direction, scope]; - } + handleScopeType(scopeType: ScopeType): SpokenFormComponent { + switch (scopeType.type) { + case "oneOf": + throw new NoSpokenFormError(`Scope type '${scopeType.type}'`); + case "surroundingPair": { + if (scopeType.delimiter === "collectionBoundary") { + throw new NoSpokenFormError( + `Scope type '${scopeType.type}' with delimiter 'collectionBoundary'`, + ); + } + const pair = this.spokenFormMap.pairedDelimiter[scopeType.delimiter]; + if (scopeType.forceDirection != null) { + return [ + this.spokenFormMap.surroundingPairForceDirection[ + scopeType.forceDirection + ], + pair, + ]; + } + return pair; + } - throw new NoSpokenFormError( - `${modifier.type} modifier with offset > 1 and length > 1`, - ); -} + case "customRegex": + return ( + this.spokenFormMap.customRegex[scopeType.regex] ?? { + type: "singleTerm", + spokenForms: [], + spokenFormType: "customRegex", + id: scopeType.regex, + } + ); -function scopeTypeToSpokenForm(scopeType: ScopeType): string { - switch (scopeType.type) { - case "oneOf": - case "customRegex": - case "switchStatementSubject": - case "string": - throw new NoSpokenFormError(`Scope type '${scopeType.type}'`); - case "surroundingPair": { - const pair = surroundingPairNameToSpokenForm(scopeType.delimiter); - if (scopeType.forceDirection != null) { - const direction = - scopeType.forceDirection === "left" - ? surroundingPairForceDirections.left - : surroundingPairForceDirections.right; - return `${direction} ${pair}`; - } - return pair; + default: + return this.spokenFormMap.simpleScopeTypeType[scopeType.type]; } - - default: - return scopeSpokenForms[scopeType.type]; } -} -function markToSpokenForm(mark: PartialMark): RecursiveArray { - switch (mark.type) { - case "decoratedSymbol": { - const [color, shape] = mark.symbolColor.split("-"); - const components: string[] = []; - if (color !== "default") { - components.push(hatColorToSpokenForm(color)); - } - if (shape != null) { - components.push(hatShapeToSpokenForm(shape)); + private handleMark(mark: PartialMark): SpokenFormComponent { + switch (mark.type) { + case "decoratedSymbol": { + const [color, shape] = mark.symbolColor.split("-"); + const components: string[] = []; + if (color !== "default") { + components.push(hatColorToSpokenForm(color)); + } + if (shape != null) { + components.push(hatShapeToSpokenForm(shape)); + } + components.push(characterToSpokenForm(mark.character)); + return components; } - components.push(characterToSpokenForm(mark.character)); - return components; - } - case "lineNumber": { - return lineNumberToParts(mark); - } + case "lineNumber": { + return this.handleLineNumberMark(mark); + } - case "range": { - if ( - mark.anchor.type === "lineNumber" && - mark.active.type === "lineNumber" - ) { - const [typeAnchor, numberAnchor] = lineNumberToParts(mark.anchor); - const [typeActive, numberActive] = lineNumberToParts(mark.active); - if (typeAnchor === typeActive) { - const connective = getRangeConnective( - mark.excludeAnchor, - mark.excludeActive, + case "range": { + if ( + mark.anchor.type === "lineNumber" && + mark.active.type === "lineNumber" + ) { + const [typeAnchor, numberAnchor] = this.handleLineNumberMark( + mark.anchor, ); - // Row five past seven - return [typeAnchor, numberAnchor, connective, numberActive]; + const [typeActive, numberActive] = this.handleLineNumberMark( + mark.active, + ); + if (typeAnchor === typeActive) { + const connective = getRangeConnective( + mark.excludeAnchor, + mark.excludeActive, + ); + // Row five past seven + return [typeAnchor, numberAnchor, connective, numberActive]; + } } + // Throw actual Error here because we're not sure we ever want to support + // a spoken form for these; we may deprecate this construct entirely + throw Error(`Mark '${mark.type}' is not fully implemented`); } - // Throw actual Error here because we're not sure we ever want to support - // a spoken form for these; we may deprecate this construct entirely - throw Error(`Mark '${mark.type}' is not fully implemented`); + case "explicit": + throw new NoSpokenFormError(`Mark '${mark.type}'`); + + default: + return [marks[mark.type]]; } - case "explicit": - throw new NoSpokenFormError(`Mark '${mark.type}'`); + } - default: - return [marks[mark.type]]; + private handleLineNumberMark(mark: LineNumberMark): [string, string] { + switch (mark.lineNumberType) { + case "absolute": + throw new NoSpokenFormError("Absolute line numbers"); + case "modulo100": { + // row/ five + return [ + lineDirections.modulo100, + numberToSpokenForm(mark.lineNumber + 1), + ]; + } + case "relative": { + // up/down five + return [ + mark.lineNumber < 0 + ? lineDirections.relativeUp + : lineDirections.relativeDown, + numberToSpokenForm(Math.abs(mark.lineNumber)), + ]; + } + } } } -function lineNumberToParts(mark: LineNumberMark): [string, string] { - switch (mark.lineNumberType) { - case "absolute": - throw new NoSpokenFormError("Absolute line numbers"); - case "modulo100": { - // row/ five - return [ - lineDirections.modulo100, - numberToSpokenForm(mark.lineNumber + 1), - ]; - } - case "relative": { - // up/down five - return [ - mark.lineNumber < 0 - ? lineDirections.relativeUp - : lineDirections.relativeDown, - numberToSpokenForm(Math.abs(mark.lineNumber)), - ]; +function pluralize(name: SpokenFormComponent): SpokenFormComponent { + if (typeof name === "string") { + return pluralizeString(name); + } + + if (Array.isArray(name)) { + if (name.length === 0) { + return name; } + + const last = name[name.length - 1]; + + return [...name.slice(0, -1), pluralize(last)]; } + + return { + ...name, + spokenForms: { + ...name.spokenForms, + spokenForms: name.spokenForms.spokenForms.map(pluralizeString), + }, + }; } -function pluralize(name: string): string { +// FIXME: Properly pluralize +function pluralizeString(name: string): string { return `${name}s`; } diff --git a/packages/cursorless-engine/src/testCaseRecorder/TestCaseRecorder.ts b/packages/cursorless-engine/src/testCaseRecorder/TestCaseRecorder.ts index 6cf7d22ecc4..3035275ec1b 100644 --- a/packages/cursorless-engine/src/testCaseRecorder/TestCaseRecorder.ts +++ b/packages/cursorless-engine/src/testCaseRecorder/TestCaseRecorder.ts @@ -31,8 +31,9 @@ import { takeSnapshot } from "../testUtil/takeSnapshot"; import { TestCase } from "./TestCase"; import { StoredTargetMap } from "../core/StoredTargets"; import { CommandRunner } from "../CommandRunner"; -import { generateSpokenForm } from "../generateSpokenForm"; import { RecordTestCaseCommandOptions } from "./RecordTestCaseCommandOptions"; +import { SpokenFormGenerator } from "../generateSpokenForm"; +import { defaultSpokenFormMap } from "../DefaultSpokenFormMap"; const CALIBRATION_DISPLAY_DURATION_MS = 50; @@ -59,6 +60,7 @@ export class TestCaseRecorder { private captureFinalThatMark: boolean = false; private spyIde: SpyIDE | undefined; private originalIde: IDE | undefined; + private spokenFormGenerator = new SpokenFormGenerator(defaultSpokenFormMap); constructor( private hatTokenMap: HatTokenMap, @@ -275,14 +277,14 @@ export class TestCaseRecorder { this.spyIde = new SpyIDE(this.originalIde); injectIde(this.spyIde!); - const spokenForm = generateSpokenForm(command); + const spokenForm = this.spokenFormGenerator.command(command); this.testCase = new TestCase( { ...command, spokenForm: spokenForm.type === "success" - ? spokenForm.value + ? spokenForm.preferred : command.spokenForm, }, hatTokenMap, diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/customRegex/clearWhite.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/customRegex/clearWhite.yml index e383561f606..14cb2f4936f 100644 --- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/customRegex/clearWhite.yml +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/customRegex/clearWhite.yml @@ -9,7 +9,9 @@ command: scopeType: {type: customRegex, regex: '\p{Zs}+'} usePrePhraseSnapshot: true action: {name: clearAndSetSelection} -spokenFormError: Scope type 'customRegex' +spokenFormError: >- + custom regex with id \p{Zs}+; please see + https://www.cursorless.org/docs/user/customization/ for more information initialState: documentContents: "\" \"" selections: diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint.yml index 0b74d1458c5..d8a196cda6e 100644 --- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint.yml +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint.yml @@ -11,7 +11,9 @@ command: length: 1 usePrePhraseSnapshot: true action: {name: clearAndSetSelection} -spokenFormError: Scope type 'customRegex' +spokenFormError: >- + custom regex with id [^\s"'`]+; please see + https://www.cursorless.org/docs/user/customization/ for more information initialState: documentContents: aaa-bbb ccc-ddd eee-fff ggg-hhh selections: diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint2.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint2.yml index bfb63aa1e64..a60149376b0 100644 --- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint2.yml +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint2.yml @@ -11,7 +11,9 @@ command: length: 1 usePrePhraseSnapshot: true action: {name: clearAndSetSelection} -spokenFormError: Scope type 'customRegex' +spokenFormError: >- + custom regex with id [^\s"'`]+; please see + https://www.cursorless.org/docs/user/customization/ for more information initialState: documentContents: aaa-bbb ccc-ddd eee-fff ggg-hhh selections: diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint.yml index 879c9da17ff..d8c3cbe502d 100644 --- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint.yml +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint.yml @@ -11,7 +11,9 @@ command: length: 1 usePrePhraseSnapshot: true action: {name: clearAndSetSelection} -spokenFormError: Scope type 'customRegex' +spokenFormError: >- + custom regex with id [^\s"'`]+; please see + https://www.cursorless.org/docs/user/customization/ for more information initialState: documentContents: aaa-bbb ccc-ddd eee-fff ggg-hhh selections: diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint2.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint2.yml index f46e7fc1e23..cd4a8df92b9 100644 --- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint2.yml +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint2.yml @@ -11,7 +11,9 @@ command: length: 1 usePrePhraseSnapshot: true action: {name: clearAndSetSelection} -spokenFormError: Scope type 'customRegex' +spokenFormError: >- + custom regex with id [^\s"'`]+; please see + https://www.cursorless.org/docs/user/customization/ for more information initialState: documentContents: aaa-bbb ccc-ddd eee-fff ggg-hhh selections: diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearCustomRegex.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearCustomRegex.yml index 24d99bc00c8..a192da5d1a7 100644 --- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearCustomRegex.yml +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearCustomRegex.yml @@ -9,7 +9,9 @@ command: scopeType: {type: customRegex, regex: '[\w/_.]+'} usePrePhraseSnapshot: true action: {name: clearAndSetSelection} -spokenFormError: Scope type 'customRegex' +spokenFormError: >- + custom regex with id [\w/_.]+; please see + https://www.cursorless.org/docs/user/customization/ for more information initialState: documentContents: aa.bb/cc_dd123( ) selections: diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearEveryCustomRegex.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearEveryCustomRegex.yml index abe8c17b056..4ef31c9dbd7 100644 --- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearEveryCustomRegex.yml +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearEveryCustomRegex.yml @@ -9,7 +9,9 @@ command: scopeType: {type: customRegex, regex: '[\w/_.]+'} usePrePhraseSnapshot: true action: {name: clearAndSetSelection} -spokenFormError: Scope type 'customRegex' +spokenFormError: >- + custom regex with id [\w/_.]+; please see + https://www.cursorless.org/docs/user/customization/ for more information initialState: documentContents: aa.bb/cc_dd123 aa.bb/cc_dd123( ) selections: