diff --git a/packages/common/src/ide/types/FileSystem.types.ts b/packages/common/src/ide/types/FileSystem.types.ts index 15818b3633..eef51b7730 100644 --- a/packages/common/src/ide/types/FileSystem.types.ts +++ b/packages/common/src/ide/types/FileSystem.types.ts @@ -10,4 +10,15 @@ export interface FileSystem { * @returns A disposable to cancel the watcher */ watchDir(path: string, onDidChange: PathChangeListener): Disposable; + + /** + * The path to the directory that Cursorless talon uses to share its state + * with the Cursorless engine. + */ + readonly cursorlessDir: string; + + /** + * The path to the Cursorless talon state JSON file. + */ + readonly cursorlessTalonStateJsonPath: string; } diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index 4dd323bf27..e4219a6897 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -7,6 +7,7 @@ import { ScopeProvider } from "./ScopeProvider"; export interface CursorlessEngine { commandApi: CommandApi; scopeProvider: ScopeProvider; + customSpokenFormGenerator: CustomSpokenFormGenerator; testCaseRecorder: TestCaseRecorder; storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; @@ -15,6 +16,16 @@ export interface CursorlessEngine { 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 b45fbc993e..f5e933f599 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 "./nodeCommon/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 { @@ -86,6 +94,7 @@ export function createCursorlessEngine( }, }, scopeProvider: createScopeProvider(languageDefinitions, storedTargets), + customSpokenFormGenerator, testCaseRecorder, storedTargets, hatTokenMap, diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts new file mode 100644 index 0000000000..bfb146a87b --- /dev/null +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -0,0 +1,61 @@ +import { + CommandComplete, + Disposable, + Listener, + ScopeType, +} from "@cursorless/common"; +import { SpokenFormGenerator } from "."; +import { CustomSpokenFormGenerator } from ".."; +import { CustomSpokenForms } from "../spokenForms/CustomSpokenForms"; +import { TalonSpokenForms } from "../scopeProviders/TalonSpokenForms"; + +/** + * Simple facade that combines the {@link CustomSpokenForms} and + * {@link SpokenFormGenerator} classes. Its main purpose is to reconstruct the + * {@link SpokenFormGenerator} when the {@link CustomSpokenForms} change. + */ +export class CustomSpokenFormGeneratorImpl + implements CustomSpokenFormGenerator +{ + private customSpokenForms: CustomSpokenForms; + private spokenFormGenerator: SpokenFormGenerator; + private disposable: Disposable; + + constructor(talonSpokenForms: TalonSpokenForms) { + this.customSpokenForms = new CustomSpokenForms(talonSpokenForms); + this.spokenFormGenerator = new SpokenFormGenerator( + this.customSpokenForms.spokenFormMap, + ); + this.disposable = this.customSpokenForms.onDidChangeCustomSpokenForms( + () => { + this.spokenFormGenerator = new SpokenFormGenerator( + this.customSpokenForms.spokenFormMap, + ); + }, + ); + } + + onDidChangeCustomSpokenForms(listener: Listener<[]>) { + return this.customSpokenForms.onDidChangeCustomSpokenForms(listener); + } + + commandToSpokenForm(command: CommandComplete) { + return this.spokenFormGenerator.processCommand(command); + } + + scopeTypeToSpokenForm(scopeType: ScopeType) { + return this.spokenFormGenerator.processScopeType(scopeType); + } + + getCustomRegexScopeTypes() { + return this.customSpokenForms.getCustomRegexScopeTypes(); + } + + get needsInitialTalonUpdate() { + return this.customSpokenForms.needsInitialTalonUpdate; + } + + dispose() { + this.disposable.dispose(); + } +} diff --git a/packages/cursorless-engine/src/nodeCommon/README.md b/packages/cursorless-engine/src/nodeCommon/README.md new file mode 100644 index 0000000000..7e20bf48c3 --- /dev/null +++ b/packages/cursorless-engine/src/nodeCommon/README.md @@ -0,0 +1,3 @@ +# Node common + +This directory contains utilities that are available in a node.js context. diff --git a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts new file mode 100644 index 0000000000..26c6db93bb --- /dev/null +++ b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts @@ -0,0 +1,77 @@ +import { Disposable, FileSystem, Notifier } from "@cursorless/common"; +import { readFile } from "fs/promises"; + +import * as path from "path"; +import { + NeedsInitialTalonUpdateError, + SpokenFormEntry, + TalonSpokenForms, +} from "../scopeProviders/TalonSpokenForms"; + +interface TalonSpokenFormsPayload { + version: number; + spokenForms: SpokenFormEntry[]; +} + +const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; + +export class TalonSpokenFormsJsonReader implements TalonSpokenForms { + private disposable: Disposable; + private notifier = new Notifier(); + + constructor(private fileSystem: FileSystem) { + this.disposable = this.fileSystem.watchDir( + path.dirname(this.fileSystem.cursorlessTalonStateJsonPath), + () => 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.fileSystem.cursorlessTalonStateJsonPath, "utf-8"), + ); + } catch (err) { + if (isErrnoException(err) && err.code === "ENOENT") { + throw new NeedsInitialTalonUpdateError( + `Custom spoken forms file not found at ${this.fileSystem.cursorlessTalonStateJsonPath}. Using default spoken forms.`, + ); + } + + throw err; + } + + if (payload.version !== LATEST_SPOKEN_FORMS_JSON_VERSION) { + // In the future, we'll need to handle migrations. Not sure exactly how yet. + throw new Error( + `Invalid spoken forms version. Expected ${LATEST_SPOKEN_FORMS_JSON_VERSION} but got ${payload.version}`, + ); + } + + return payload.spokenForms; + } + + dispose() { + this.disposable.dispose(); + } +} + +/** + * A user-defined type guard function that checks if a given error is a + * `NodeJS.ErrnoException`. + * + * @param {any} error - The error to check. + * @returns {error is NodeJS.ErrnoException} - Returns `true` if the error is a + * {@link NodeJS.ErrnoException}, otherwise `false`. + */ +function isErrnoException(error: any): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/packages/cursorless-engine/src/scopeProviders/TalonSpokenForms.ts b/packages/cursorless-engine/src/scopeProviders/TalonSpokenForms.ts new file mode 100644 index 0000000000..a6331be5d2 --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/TalonSpokenForms.ts @@ -0,0 +1,43 @@ +import { Notifier } from "@cursorless/common"; +import { + SpokenFormMapKeyTypes, + SpokenFormType, +} from "../spokenForms/SpokenFormType"; + +/** + * Interface representing a communication mechanism whereby Talon can provide + * the user's custom spoken forms to the Cursorless engine. + */ +export interface TalonSpokenForms { + getSpokenFormEntries(): Promise; + onDidChange: Notifier["registerListener"]; +} + +/** + * The types of entries for which we currently support getting custom spoken + * forms from Talon. + */ +export const SUPPORTED_ENTRY_TYPES = [ + "simpleScopeTypeType", + "customRegex", + "pairedDelimiter", +] as const; + +type SupportedEntryType = (typeof SUPPORTED_ENTRY_TYPES)[number]; + +export interface SpokenFormEntryForType { + type: T; + id: SpokenFormMapKeyTypes[T]; + spokenForms: string[]; +} + +export type SpokenFormEntry = { + [K in SpokenFormType]: SpokenFormEntryForType; +}[SupportedEntryType]; + +export class NeedsInitialTalonUpdateError extends Error { + constructor(message: string) { + super(message); + this.name = "NeedsInitialTalonUpdateError"; + } +} diff --git a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts new file mode 100644 index 0000000000..28ff5e3c8f --- /dev/null +++ b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts @@ -0,0 +1,184 @@ +import { + CustomRegexScopeType, + Disposable, + Notifier, + showError, +} from "@cursorless/common"; +import { isEqual } from "lodash"; +import { + NeedsInitialTalonUpdateError, + SUPPORTED_ENTRY_TYPES, + SpokenFormEntry, + TalonSpokenForms, +} from "../scopeProviders/TalonSpokenForms"; +import { ide } from "../singletons/ide.singleton"; +import { SpokenFormMap, SpokenFormMapEntry } from "./SpokenFormMap"; +import { SpokenFormMapKeyTypes, SpokenFormType } from "./SpokenFormType"; +import { + defaultSpokenFormInfoMap, + defaultSpokenFormMap, +} from "./defaultSpokenFormMap"; +import { DefaultSpokenFormMapEntry } from "./defaultSpokenFormMap.types"; + +type Writable = { + -readonly [K in keyof T]: T[K]; +}; + +/** + * Maintains a {@link SpokenFormMap} containing the users's custom spoken forms. If + * for some reason, the custom spoken forms cannot be loaded, the default spoken + * forms will be used instead. We currently only support getting custom spoken + * forms for a subset of all customizable spoken forms. + */ +export class CustomSpokenForms { + private disposable: Disposable; + 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.disposable = 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 allCustomEntries: SpokenFormEntry[]; + try { + allCustomEntries = await this.talonSpokenForms.getSpokenFormEntries(); + if (allCustomEntries.length === 0) { + throw new Error("Custom spoken forms list empty"); + } + } 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 SUPPORTED_ENTRY_TYPES) { + updateEntriesForType( + this.spokenFormMap_, + entryType, + defaultSpokenFormInfoMap[entryType], + Object.fromEntries( + allCustomEntries + .filter((entry) => entry.type === entryType) + .map(({ id, spokenForms }) => [id, spokenForms]), + ), + ); + } + + this.customSpokenFormsInitialized_ = true; + this.notifier.notifyListeners(); + } + + getCustomRegexScopeTypes(): CustomRegexScopeType[] { + return Object.keys(this.spokenFormMap_.customRegex).map((regex) => ({ + type: "customRegex", + regex, + })); + } + + dispose() { + this.disposable.dispose(); + } +} + +function updateEntriesForType( + spokenFormMapToUpdate: Writable, + key: T, + defaultEntries: Partial< + Record + >, + customEntries: Partial>, +) { + /** + * The ids of the entries to include in the spoken form map. We need a + * union of the ids from the default entry and the custom entry. The custom + * entry could be missing private entries, or it could be missing entries + * because the Talon side is old. The default entry could be missing entries + * like custom regexes, where the user can create arbitrary ids. + */ + const ids = Array.from( + new Set([...Object.keys(defaultEntries), ...Object.keys(customEntries)]), + ) as SpokenFormMapKeyTypes[T][]; + + const obj: Partial> = {}; + for (const id of ids) { + const { defaultSpokenForms = [], isPrivate = false } = + defaultEntries[id] ?? {}; + const customSpokenForms = customEntries[id]; + + obj[id] = + customSpokenForms == null + ? // No entry for the given id. This either means that the user needs to + // update Talon, or it's a private spoken form. + { + defaultSpokenForms, + spokenForms: [], + // If it's not a private spoken form, then it's a new scope type + requiresTalonUpdate: !isPrivate, + isCustom: false, + isPrivate, + } + : // We have an entry for the given id + { + defaultSpokenForms, + spokenForms: customSpokenForms, + requiresTalonUpdate: false, + isCustom: isEqual(defaultSpokenForms, customSpokenForms), + isPrivate, + }; + } + + spokenFormMapToUpdate[key] = obj as SpokenFormMap[T]; +} diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 43c74a7134..8a5b89844e 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -21,6 +21,9 @@ import { ParseTreeApi, toVscodeRange, } from "@cursorless/vscode-common"; +import * as crypto from "crypto"; +import * as os from "os"; +import * as path from "path"; import * as vscode from "vscode"; import { constructTestHelpers } from "./constructTestHelpers"; import { FakeFontMeasurements } from "./ide/vscode/hats/FakeFontMeasurements"; @@ -42,6 +45,7 @@ import { } from "./ScopeVisualizerCommandApi"; import { StatusBarItem } from "./StatusBarItem"; import { vscodeApi } from "./vscodeApi"; +import { mkdir } from "fs/promises"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -139,7 +143,16 @@ async function createVscodeIde(context: vscode.ExtensionContext) { ); await hats.init(); - return { vscodeIDE, hats, fileSystem: new VscodeFileSystem() }; + // FIXME: Inject this from test harness. Would need to arrange to delay + // extension initialization, probably by returning a function from extension + // init that has parameters consisting of test configuration, and have that + // function do the actual initialization. + const cursorlessDir = isTesting() + ? path.join(os.tmpdir(), crypto.randomBytes(16).toString("hex")) + : path.join(os.homedir(), ".cursorless"); + await mkdir(cursorlessDir, { recursive: true }); + + return { vscodeIDE, hats, fileSystem: new VscodeFileSystem(cursorlessDir) }; } function createTreeSitter(parseTreeApi: ParseTreeApi): TreeSitter { diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts index 740c0b9513..fed9699b11 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts @@ -1,7 +1,17 @@ import { Disposable, FileSystem, PathChangeListener } from "@cursorless/common"; import { RelativePattern, workspace } from "vscode"; +import * as path from "path"; export class VscodeFileSystem implements FileSystem { + public readonly cursorlessTalonStateJsonPath: string; + + constructor(public readonly cursorlessDir: string) { + this.cursorlessTalonStateJsonPath = path.join( + this.cursorlessDir, + "state.json", + ); + } + watchDir(path: string, onDidChange: PathChangeListener): Disposable { // FIXME: Support globs? const watcher = workspace.createFileSystemWatcher(