From 6a6d06acc88aa3a10718ae34fb4fc46ce3021b7c Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 12 Oct 2023 16:04:44 +0100 Subject: [PATCH] Get custom spoken forms from Talon --- packages/common/src/index.ts | 1 + packages/common/src/util/Disposer.ts | 30 ++++ .../src/CustomSpokenForms.ts | 167 ++++++++++++++++++ .../src/api/CursorlessEngineApi.ts | 14 +- .../cursorless-engine/src/cursorlessEngine.ts | 11 +- .../CustomSpokenFormGeneratorImpl.ts | 54 ++++++ .../src/scopeProviders/SpokenFormEntry.ts | 37 ++++ .../TalonSpokenFormsJsonReader.ts | 87 +++++++++ .../src/constructTestHelpers.ts | 6 + packages/cursorless-vscode/src/extension.ts | 2 +- .../VscodeIterationScopeVisualizer.ts | 8 +- .../VscodeScopeTargetVisualizer.ts | 3 +- .../VscodeScopeVisualizer.ts | 3 +- .../createVscodeScopeVisualizer.ts | 3 +- 14 files changed, 417 insertions(+), 9 deletions(-) create mode 100644 packages/common/src/util/Disposer.ts create mode 100644 packages/cursorless-engine/src/CustomSpokenForms.ts create mode 100644 packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts create mode 100644 packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts create mode 100644 packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4f616381262..cc4884476b4 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/Disposer"; export * from "./util/camelCaseToAllDown"; export { Notifier } from "./util/Notifier"; export type { Listener } from "./util/Notifier"; diff --git a/packages/common/src/util/Disposer.ts b/packages/common/src/util/Disposer.ts new file mode 100644 index 00000000000..cf300b746d3 --- /dev/null +++ b/packages/common/src/util/Disposer.ts @@ -0,0 +1,30 @@ +import { Disposable } from "../ide/types/ide.types"; + +/** + * A class that can be used to dispose of multiple disposables at once. This is + * useful for managing the lifetime of multiple disposables that are created + * together. It ensures that if one of the disposables throws an error during + * disposal, the rest of the disposables will still be disposed. + */ +export class Disposer implements Disposable { + private disposables: Disposable[] = []; + + constructor(...disposables: Disposable[]) { + this.push(...disposables); + } + + public push(...disposables: Disposable[]) { + this.disposables.push(...disposables); + } + + dispose(): void { + this.disposables.forEach(({ dispose }) => { + try { + dispose(); + } catch (e) { + // do nothing; some of the VSCode disposables misbehave, and we don't + // want that to prevent us from disposing the rest of the disposables + } + }); + } +} diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts new file mode 100644 index 00000000000..00ca71953fa --- /dev/null +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -0,0 +1,167 @@ +import { + CustomRegexScopeType, + Disposer, + Notifier, + showError, +} from "@cursorless/common"; +import { isEqual } from "lodash"; +import { + DefaultSpokenFormMapEntry, + defaultSpokenFormInfo, + defaultSpokenFormMap, +} from "./DefaultSpokenFormMap"; +import { + SpokenFormMap, + SpokenFormMapEntry, + SpokenFormType, +} from "./SpokenFormMap"; +import { + NeedsInitialTalonUpdateError, + SpokenFormEntry, + TalonSpokenForms, +} from "./scopeProviders/SpokenFormEntry"; +import { ide } from "./singletons/ide.singleton"; + +const ENTRY_TYPES = [ + "simpleScopeTypeType", + "customRegex", + "pairedDelimiter", +] as const; + +type Writable = { + -readonly [K in keyof T]: T[K]; +}; + +/** + * Maintains a list of all scope types and notifies listeners when it changes. + */ +export class CustomSpokenForms { + private disposer = new Disposer(); + private notifier = new Notifier(); + + private spokenFormMap_: Writable = { ...defaultSpokenFormMap }; + + get spokenFormMap(): SpokenFormMap { + return this.spokenFormMap_; + } + + private customSpokenFormsInitialized_ = false; + private needsInitialTalonUpdate_: boolean | undefined; + + /** + * If `true`, indicates they need to update their Talon files to get the + * machinery used to share spoken forms from Talon to the VSCode extension. + */ + get needsInitialTalonUpdate() { + return this.needsInitialTalonUpdate_; + } + + /** + * Whether the custom spoken forms have been initialized. If `false`, the + * default spoken forms are currently being used while the custom spoken forms + * are being loaded. + */ + get customSpokenFormsInitialized() { + return this.customSpokenFormsInitialized_; + } + + constructor(private talonSpokenForms: TalonSpokenForms) { + this.disposer.push( + talonSpokenForms.onDidChange(() => this.updateSpokenFormMaps()), + ); + + this.updateSpokenFormMaps(); + } + + /** + * Registers a callback to be run when the custom spoken forms change. + * @param callback The callback to run when the scope ranges change + * @returns A {@link Disposable} which will stop the callback from running + */ + onDidChangeCustomSpokenForms = this.notifier.registerListener; + + private async updateSpokenFormMaps(): Promise { + let entries: SpokenFormEntry[]; + try { + entries = await this.talonSpokenForms.getSpokenFormEntries(); + } catch (err) { + if (err instanceof NeedsInitialTalonUpdateError) { + // Handle case where spokenForms.json doesn't exist yet + this.needsInitialTalonUpdate_ = true; + } else { + console.error("Error loading custom spoken forms", err); + showError( + ide().messages, + "CustomSpokenForms.updateSpokenFormMaps", + `Error loading custom spoken forms: ${ + (err as Error).message + }}}. Falling back to default spoken forms.`, + ); + } + + this.spokenFormMap_ = { ...defaultSpokenFormMap }; + this.customSpokenFormsInitialized_ = false; + this.notifier.notifyListeners(); + + return; + } + + for (const entryType of ENTRY_TYPES) { + // FIXME: How to avoid the type assertion? + const entry = Object.fromEntries( + entries + .filter((entry) => entry.type === entryType) + .map(({ id, spokenForms }) => [id, spokenForms]), + ); + + const defaultEntry: Partial> = + defaultSpokenFormInfo[entryType]; + const ids = Array.from( + new Set([...Object.keys(defaultEntry), ...Object.keys(entry)]), + ); + this.spokenFormMap_[entryType] = Object.fromEntries( + ids.map((id): [SpokenFormType, SpokenFormMapEntry] => { + const { defaultSpokenForms = [], isSecret = false } = + defaultEntry[id] ?? {}; + const customSpokenForms = entry[id]; + if (customSpokenForms != null) { + return [ + id as SpokenFormType, + { + defaultSpokenForms, + spokenForms: customSpokenForms, + requiresTalonUpdate: false, + isCustom: isEqual(defaultSpokenForms, customSpokenForms), + isSecret, + }, + ]; + } else { + return [ + id as SpokenFormType, + { + defaultSpokenForms, + spokenForms: [], + // If it's not a secret spoken form, then it's a new scope type + requiresTalonUpdate: !isSecret, + isCustom: false, + isSecret, + }, + ]; + } + }), + ) as any; + } + + this.customSpokenFormsInitialized_ = true; + this.notifier.notifyListeners(); + } + + getCustomRegexScopeTypes(): CustomRegexScopeType[] { + return Object.keys(this.spokenFormMap_.customRegex).map((regex) => ({ + type: "customRegex", + regex, + })); + } + + dispose = this.disposer.dispose; +} diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index 4dd323bf279..a25e4dfea76 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -2,19 +2,31 @@ import { Command, HatTokenMap, IDE } from "@cursorless/common"; import { Snippets } from "../core/Snippets"; import { StoredTargetMap } from "../core/StoredTargets"; import { TestCaseRecorder } from "../testCaseRecorder/TestCaseRecorder"; -import { ScopeProvider } from "./ScopeProvider"; +import { ScopeProvider } from "@cursorless/common"; export interface CursorlessEngine { commandApi: CommandApi; scopeProvider: ScopeProvider; + customSpokenFormGenerator: CustomSpokenFormGenerator; testCaseRecorder: TestCaseRecorder; storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; snippets: Snippets; + spokenFormsJsonPath: string; injectIde: (ide: IDE | undefined) => void; runIntegrationTests: () => Promise; } +export interface CustomSpokenFormGenerator { + /** + * If `true`, indicates they need to update their Talon files to get the + * machinery used to share spoken forms from Talon to the VSCode extension. + */ + readonly needsInitialTalonUpdate: boolean | undefined; + + onDidChangeCustomSpokenForms: (listener: () => void) => void; +} + export interface CommandApi { /** * Runs a command. This is the core of the Cursorless engine. diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index b45fbc993e1..3210b7342f6 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -15,11 +15,13 @@ import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; +import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl"; import { LanguageDefinitions } from "./languages/LanguageDefinitions"; import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl"; import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers"; import { runCommand } from "./runCommand"; import { runIntegrationTests } from "./runIntegrationTests"; +import { TalonSpokenFormsJsonReader } from "./scopeProviders/TalonSpokenFormsJsonReader"; import { injectIde } from "./singletons/ide.singleton"; import { ScopeRangeWatcher } from "./ScopeVisualizer/ScopeRangeWatcher"; @@ -53,6 +55,12 @@ export function createCursorlessEngine( const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter); + const talonSpokenForms = new TalonSpokenFormsJsonReader(fileSystem); + + const customSpokenFormGenerator = new CustomSpokenFormGeneratorImpl( + talonSpokenForms, + ); + ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug); return { @@ -85,11 +93,12 @@ export function createCursorlessEngine( ); }, }, - scopeProvider: createScopeProvider(languageDefinitions, storedTargets), + customSpokenFormGenerator, testCaseRecorder, storedTargets, hatTokenMap, snippets, + spokenFormsJsonPath: talonSpokenForms.spokenFormsPath, injectIde, runIntegrationTests: () => runIntegrationTests(treeSitter, languageDefinitions), diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts new file mode 100644 index 00000000000..5aa0aeb3393 --- /dev/null +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -0,0 +1,54 @@ +import { + CommandComplete, + Disposer, + Listener, + ScopeType, +} from "@cursorless/common"; +import { SpokenFormGenerator } from "."; +import { CustomSpokenFormGenerator } from ".."; +import { CustomSpokenForms } from "../CustomSpokenForms"; +import { TalonSpokenForms } from "../scopeProviders/SpokenFormEntry"; + +export class CustomSpokenFormGeneratorImpl + implements CustomSpokenFormGenerator +{ + private customSpokenForms: CustomSpokenForms; + private spokenFormGenerator: SpokenFormGenerator; + private disposer = new Disposer(); + + constructor(talonSpokenForms: TalonSpokenForms) { + this.customSpokenForms = new CustomSpokenForms(talonSpokenForms); + this.spokenFormGenerator = new SpokenFormGenerator( + this.customSpokenForms.spokenFormMap, + ); + this.disposer.push( + this.customSpokenForms.onDidChangeCustomSpokenForms(() => { + this.spokenFormGenerator = new SpokenFormGenerator( + this.customSpokenForms.spokenFormMap, + ); + }), + ); + } + + onDidChangeCustomSpokenForms(listener: Listener<[]>) { + return this.customSpokenForms.onDidChangeCustomSpokenForms(listener); + } + + commandToSpokenForm(command: CommandComplete) { + return this.spokenFormGenerator.command(command); + } + + scopeTypeToSpokenForm(scopeType: ScopeType) { + return this.spokenFormGenerator.scopeType(scopeType); + } + + getCustomRegexScopeTypes() { + return this.customSpokenForms.getCustomRegexScopeTypes(); + } + + get needsInitialTalonUpdate() { + return this.customSpokenForms.needsInitialTalonUpdate; + } + + dispose = this.disposer.dispose; +} diff --git a/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts b/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts new file mode 100644 index 00000000000..b0ecf250d0e --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts @@ -0,0 +1,37 @@ +import { Notifier, SimpleScopeTypeType } from "@cursorless/common"; +import { SpeakableSurroundingPairName } from "../SpokenFormMap"; + +export interface TalonSpokenForms { + getSpokenFormEntries(): Promise; + onDidChange: Notifier["registerListener"]; +} + +export interface CustomRegexSpokenFormEntry { + type: "customRegex"; + id: string; + spokenForms: string[]; +} + +export interface PairedDelimiterSpokenFormEntry { + type: "pairedDelimiter"; + id: SpeakableSurroundingPairName; + spokenForms: string[]; +} + +export interface SimpleScopeTypeTypeSpokenFormEntry { + type: "simpleScopeTypeType"; + id: SimpleScopeTypeType; + spokenForms: string[]; +} + +export type SpokenFormEntry = + | CustomRegexSpokenFormEntry + | PairedDelimiterSpokenFormEntry + | SimpleScopeTypeTypeSpokenFormEntry; + +export class NeedsInitialTalonUpdateError extends Error { + constructor(message: string) { + super(message); + this.name = "NeedsInitialTalonUpdateError"; + } +} diff --git a/packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts new file mode 100644 index 00000000000..4de24e982fe --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts @@ -0,0 +1,87 @@ +import { + Disposer, + FileSystem, + LATEST_VERSION, + Notifier, + isTesting, +} from "@cursorless/common"; +import * as crypto from "crypto"; +import { mkdir, readFile } from "fs/promises"; +import * as os from "os"; + +import * as path from "path"; +import { + NeedsInitialTalonUpdateError, + SpokenFormEntry, + TalonSpokenForms, +} from "./SpokenFormEntry"; + +interface TalonSpokenFormsPayload { + version: number; + entries: SpokenFormEntry[]; +} + +export class TalonSpokenFormsJsonReader implements TalonSpokenForms { + private disposer = new Disposer(); + private notifier = new Notifier(); + public readonly spokenFormsPath; + + constructor(private fileSystem: FileSystem) { + const cursorlessDir = isTesting() + ? path.join(os.tmpdir(), crypto.randomBytes(16).toString("hex")) + : path.join(os.homedir(), ".cursorless"); + + this.spokenFormsPath = path.join(cursorlessDir, "spokenForms.json"); + + this.init(); + } + + private async init() { + const parentDir = path.dirname(this.spokenFormsPath); + await mkdir(parentDir, { recursive: true }); + this.disposer.push( + this.fileSystem.watchDir(parentDir, () => this.notifier.notifyListeners()), + ); + } + + /** + * Registers a callback to be run when the spoken forms change. + * @param callback The callback to run when the scope ranges change + * @returns A {@link Disposable} which will stop the callback from running + */ + onDidChange = this.notifier.registerListener; + + async getSpokenFormEntries(): Promise { + let payload: TalonSpokenFormsPayload; + try { + payload = JSON.parse(await readFile(this.spokenFormsPath, "utf-8")); + } catch (err) { + if ((err as any)?.code === "ENOENT") { + throw new NeedsInitialTalonUpdateError( + `Custom spoken forms file not found at ${this.spokenFormsPath}. Using default spoken forms.`, + ); + } + + throw err; + } + + /** + * This assignment is to ensure that the compiler will error if we forget to + * handle spokenForms.json when we bump the command version. + */ + const latestCommandVersion: 6 = LATEST_VERSION; + + if (payload.version !== latestCommandVersion) { + // In the future, we'll need to handle migrations. Not sure exactly how yet. + throw new Error( + `Invalid spoken forms version. Expected ${LATEST_VERSION} but got ${payload.version}`, + ); + } + + return payload.entries; + } + + dispose() { + this.disposer.dispose(); + } +} diff --git a/packages/cursorless-vscode/src/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts index a68539edb05..4e3ec799a62 100644 --- a/packages/cursorless-vscode/src/constructTestHelpers.ts +++ b/packages/cursorless-vscode/src/constructTestHelpers.ts @@ -5,6 +5,7 @@ import { HatTokenMap, IDE, NormalizedIDE, + ScopeProvider, SerializedMarks, TargetPlainObject, TestCaseSnapshot, @@ -28,6 +29,8 @@ export function constructTestHelpers( hatTokenMap: HatTokenMap, vscodeIDE: VscodeIDE, normalizedIde: NormalizedIDE, + spokenFormsJsonPath: string, + scopeProvider: ScopeProvider, injectIde: (ide: IDE) => void, runIntegrationTests: () => Promise, ): TestHelpers | undefined { @@ -35,6 +38,7 @@ export function constructTestHelpers( commandServerApi: commandServerApi!, ide: normalizedIde, injectIde, + scopeProvider, toVscodeEditor, @@ -61,6 +65,8 @@ export function constructTestHelpers( ); }, + spokenFormsJsonPath, + setStoredTarget( editor: vscode.TextEditor, key: StoredTargetKey, diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index e24a9866820..b09ea1a86cf 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -6,12 +6,12 @@ import { isTesting, NormalizedIDE, Range, + ScopeProvider, ScopeType, TextDocument, } from "@cursorless/common"; import { createCursorlessEngine, - ScopeProvider, TreeSitter, } from "@cursorless/cursorless-engine"; import { diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts index b160f8631c6..da3dca3fefd 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts @@ -1,7 +1,11 @@ -import { Disposable, TextEditor, toCharacterRange } from "@cursorless/common"; +import { + Disposable, + ScopeSupport, + TextEditor, + toCharacterRange, +} from "@cursorless/common"; import { VscodeTextEditorImpl } from "../VscodeTextEditorImpl"; import { VscodeScopeVisualizer } from "./VscodeScopeVisualizer"; -import { ScopeSupport } from "@cursorless/cursorless-engine"; export class VscodeIterationScopeVisualizer extends VscodeScopeVisualizer { protected getScopeSupport(editor: TextEditor): ScopeSupport { diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts index 6303d6ee3cb..f52862a4007 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts @@ -1,10 +1,11 @@ import { Disposable, GeneralizedRange, + ScopeSupport, + TargetRanges, TextEditor, toCharacterRange, } from "@cursorless/common"; -import { ScopeSupport, TargetRanges } from "@cursorless/cursorless-engine"; import { VscodeScopeVisualizer } from "."; import { VscodeTextEditorImpl } from "../VscodeTextEditorImpl"; diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts index 862a75e41b0..a5e9c69e586 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts @@ -1,11 +1,12 @@ import { Disposable, IDE, + ScopeProvider, + ScopeSupport, ScopeType, TextEditor, showError, } from "@cursorless/common"; -import { ScopeProvider, ScopeSupport } from "@cursorless/cursorless-engine"; import { ScopeRangeType, ScopeVisualizerColorConfig, diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts index 4abd4b65150..aad6cd3ec90 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts @@ -1,5 +1,4 @@ -import { IDE, ScopeType } from "@cursorless/common"; -import { ScopeProvider } from "@cursorless/cursorless-engine"; +import { IDE, ScopeProvider, ScopeType } from "@cursorless/common"; import { VisualizationType } from "../../../ScopeVisualizerCommandApi"; import { VscodeIterationScopeVisualizer } from "./VscodeIterationScopeVisualizer"; import {