From d92c0d2e462897679441e0805843ea5eb1086ad9 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 25 Oct 2023 14:04:35 +0100 Subject: [PATCH 01/14] Get custom spoken forms from Talon --- packages/common/src/index.ts | 1 + packages/common/src/util/Disposer.ts | 30 +++ .../src/api/CursorlessEngineApi.ts | 12 ++ .../cursorless-engine/src/cursorlessEngine.ts | 10 + .../CustomSpokenFormGeneratorImpl.ts | 59 ++++++ .../src/nodeCommon/README.md | 3 + .../nodeCommon/TalonSpokenFormsJsonReader.ts | 79 ++++++++ .../src/scopeProviders/SpokenFormEntry.ts | 41 ++++ .../src/spokenForms/CustomSpokenForms.ts | 182 ++++++++++++++++++ 9 files changed, 417 insertions(+) create mode 100644 packages/common/src/util/Disposer.ts create mode 100644 packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts create mode 100644 packages/cursorless-engine/src/nodeCommon/README.md create mode 100644 packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts create mode 100644 packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts create mode 100644 packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4f61638126..cc4884476b 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 0000000000..cf300b746d --- /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/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index 4dd323bf27..d7c400618a 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -7,14 +7,26 @@ import { ScopeProvider } from "./ScopeProvider"; 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 b45fbc993e..595ba21f99 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,10 +94,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 0000000000..37ea03f51a --- /dev/null +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -0,0 +1,59 @@ +import { + CommandComplete, + Disposer, + Listener, + ScopeType, +} from "@cursorless/common"; +import { SpokenFormGenerator } from "."; +import { CustomSpokenFormGenerator } from ".."; +import { CustomSpokenForms } from "../spokenForms/CustomSpokenForms"; +import { TalonSpokenForms } from "../scopeProviders/SpokenFormEntry"; + +/** + * 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 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.processCommand(command); + } + + scopeTypeToSpokenForm(scopeType: ScopeType) { + return this.spokenFormGenerator.processScopeType(scopeType); + } + + getCustomRegexScopeTypes() { + return this.customSpokenForms.getCustomRegexScopeTypes(); + } + + get needsInitialTalonUpdate() { + return this.customSpokenForms.needsInitialTalonUpdate; + } + + dispose = this.disposer.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..08d4117dec --- /dev/null +++ b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts @@ -0,0 +1,79 @@ +import { Disposer, FileSystem, 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 "../scopeProviders/SpokenFormEntry"; + +interface TalonSpokenFormsPayload { + version: number; + spokenForms: SpokenFormEntry[]; +} + +const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; + +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, "state.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; + } + + 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.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 0000000000..d5d0abffc8 --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts @@ -0,0 +1,41 @@ +import { Notifier, SimpleScopeTypeType } from "@cursorless/common"; +import { SpeakableSurroundingPairName } 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"]; +} + +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/spokenForms/CustomSpokenForms.ts b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts new file mode 100644 index 0000000000..a329293c5b --- /dev/null +++ b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts @@ -0,0 +1,182 @@ +import { + CustomRegexScopeType, + Disposer, + Notifier, + showError, +} from "@cursorless/common"; +import { isEqual } from "lodash"; +import { + NeedsInitialTalonUpdateError, + SpokenFormEntry, + TalonSpokenForms, +} from "../scopeProviders/SpokenFormEntry"; +import { ide } from "../singletons/ide.singleton"; +import { SpokenFormMap, SpokenFormMapEntry } from "./SpokenFormMap"; +import { SpokenFormType } from "./SpokenFormType"; +import { + defaultSpokenFormInfoMap, + defaultSpokenFormMap, +} from "./defaultSpokenFormMap"; +import { DefaultSpokenFormMapEntry } from "./defaultSpokenFormMap.types"; + +/** + * The types of entries for which we currently support getting custom spoken + * forms from Talon. + */ +const ENTRY_TYPES = [ + "simpleScopeTypeType", + "customRegex", + "pairedDelimiter", +] as const; + +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 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 allCustomEntries: SpokenFormEntry[]; + try { + allCustomEntries = 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) { + const customEntries = Object.fromEntries( + allCustomEntries + .filter((entry) => entry.type === entryType) + .map(({ id, spokenForms }) => [id, spokenForms]), + ); + + const defaultEntries: Partial> = + defaultSpokenFormInfoMap[entryType]; + + /** + * 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), + ]), + ); + // FIXME: How to avoid the type assertions here? + this.spokenFormMap_[entryType] = Object.fromEntries( + ids.map((id): [SpokenFormType, SpokenFormMapEntry] => { + const { defaultSpokenForms = [], isPrivate = false } = + defaultEntries[id] ?? {}; + const customSpokenForms = customEntries[id]; + if (customSpokenForms != null) { + return [ + id as SpokenFormType, + { + defaultSpokenForms, + spokenForms: customSpokenForms, + requiresTalonUpdate: false, + isCustom: isEqual(defaultSpokenForms, customSpokenForms), + isPrivate, + }, + ]; + } else { + return [ + id as SpokenFormType, + { + defaultSpokenForms, + spokenForms: [], + // If it's not a private spoken form, then it's a new scope type + requiresTalonUpdate: !isPrivate, + isCustom: false, + isPrivate, + }, + ]; + } + }), + ) 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; +} From eb9e4223b6abc273c388692ef11c570a9dda7b17 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:10:33 +0100 Subject: [PATCH 02/14] Improved types --- .../src/scopeProviders/SpokenFormEntry.ts | 40 +++--- .../src/spokenForms/CustomSpokenForms.ts | 127 +++++++++--------- 2 files changed, 83 insertions(+), 84 deletions(-) diff --git a/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts b/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts index d5d0abffc8..a6331be5d2 100644 --- a/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts +++ b/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts @@ -1,5 +1,8 @@ -import { Notifier, SimpleScopeTypeType } from "@cursorless/common"; -import { SpeakableSurroundingPairName } from "../spokenForms/SpokenFormType"; +import { Notifier } from "@cursorless/common"; +import { + SpokenFormMapKeyTypes, + SpokenFormType, +} from "../spokenForms/SpokenFormType"; /** * Interface representing a communication mechanism whereby Talon can provide @@ -10,28 +13,27 @@ export interface TalonSpokenForms { onDidChange: Notifier["registerListener"]; } -export interface CustomRegexSpokenFormEntry { - type: "customRegex"; - id: string; - spokenForms: string[]; -} +/** + * 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; -export interface PairedDelimiterSpokenFormEntry { - type: "pairedDelimiter"; - id: SpeakableSurroundingPairName; - spokenForms: string[]; -} +type SupportedEntryType = (typeof SUPPORTED_ENTRY_TYPES)[number]; -export interface SimpleScopeTypeTypeSpokenFormEntry { - type: "simpleScopeTypeType"; - id: SimpleScopeTypeType; +export interface SpokenFormEntryForType { + type: T; + id: SpokenFormMapKeyTypes[T]; spokenForms: string[]; } -export type SpokenFormEntry = - | CustomRegexSpokenFormEntry - | PairedDelimiterSpokenFormEntry - | SimpleScopeTypeTypeSpokenFormEntry; +export type SpokenFormEntry = { + [K in SpokenFormType]: SpokenFormEntryForType; +}[SupportedEntryType]; export class NeedsInitialTalonUpdateError extends Error { constructor(message: string) { diff --git a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts index a329293c5b..564a6a15e8 100644 --- a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts @@ -6,29 +6,20 @@ import { } from "@cursorless/common"; import { isEqual } from "lodash"; import { + SUPPORTED_ENTRY_TYPES, NeedsInitialTalonUpdateError, SpokenFormEntry, TalonSpokenForms, } from "../scopeProviders/SpokenFormEntry"; import { ide } from "../singletons/ide.singleton"; import { SpokenFormMap, SpokenFormMapEntry } from "./SpokenFormMap"; -import { SpokenFormType } from "./SpokenFormType"; +import { SpokenFormMapKeyTypes, SpokenFormType } from "./SpokenFormType"; import { defaultSpokenFormInfoMap, defaultSpokenFormMap, } from "./defaultSpokenFormMap"; import { DefaultSpokenFormMapEntry } from "./defaultSpokenFormMap.types"; -/** - * The types of entries for which we currently support getting custom spoken - * forms from Talon. - */ -const ENTRY_TYPES = [ - "simpleScopeTypeType", - "customRegex", - "pairedDelimiter", -] as const; - type Writable = { -readonly [K in keyof T]: T[K]; }; @@ -110,61 +101,17 @@ export class CustomSpokenForms { return; } - for (const entryType of ENTRY_TYPES) { - const customEntries = Object.fromEntries( - allCustomEntries - .filter((entry) => entry.type === entryType) - .map(({ id, spokenForms }) => [id, spokenForms]), + 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]), + ), ); - - const defaultEntries: Partial> = - defaultSpokenFormInfoMap[entryType]; - - /** - * 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), - ]), - ); - // FIXME: How to avoid the type assertions here? - this.spokenFormMap_[entryType] = Object.fromEntries( - ids.map((id): [SpokenFormType, SpokenFormMapEntry] => { - const { defaultSpokenForms = [], isPrivate = false } = - defaultEntries[id] ?? {}; - const customSpokenForms = customEntries[id]; - if (customSpokenForms != null) { - return [ - id as SpokenFormType, - { - defaultSpokenForms, - spokenForms: customSpokenForms, - requiresTalonUpdate: false, - isCustom: isEqual(defaultSpokenForms, customSpokenForms), - isPrivate, - }, - ]; - } else { - return [ - id as SpokenFormType, - { - defaultSpokenForms, - spokenForms: [], - // If it's not a private spoken form, then it's a new scope type - requiresTalonUpdate: !isPrivate, - isCustom: false, - isPrivate, - }, - ]; - } - }), - ) as any; } this.customSpokenFormsInitialized_ = true; @@ -180,3 +127,53 @@ export class CustomSpokenForms { dispose = this.disposer.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]; +} From fa94cc17da3b7aaee3984db720cec01b4a8f3a1e Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:13:02 +0100 Subject: [PATCH 03/14] Log errors and disposer --- packages/common/src/util/Disposer.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/common/src/util/Disposer.ts b/packages/common/src/util/Disposer.ts index cf300b746d..bdb24f7b56 100644 --- a/packages/common/src/util/Disposer.ts +++ b/packages/common/src/util/Disposer.ts @@ -22,8 +22,10 @@ export class Disposer implements Disposable { 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 + // just log, but don't throw; some of the VSCode disposables misbehave, + // and we don't want that to prevent us from disposing the rest of the + // disposables + console.error(e); } }); } From 9c93fab6a3e504a8445d734ce86f553cf06bbda3 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:45:07 +0100 Subject: [PATCH 04/14] More PR feedback --- .../common/src/ide/types/FileSystem.types.ts | 11 ++++++++++ .../src/api/CursorlessEngineApi.ts | 1 - .../cursorless-engine/src/cursorlessEngine.ts | 1 - .../CustomSpokenFormGeneratorImpl.ts | 4 +++- .../nodeCommon/TalonSpokenFormsJsonReader.ts | 20 ++++++++----------- .../src/spokenForms/CustomSpokenForms.ts | 4 +++- packages/cursorless-vscode/src/extension.ts | 10 +++++++++- .../src/ide/vscode/VscodeFileSystem.ts | 7 +++++++ 8 files changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/common/src/ide/types/FileSystem.types.ts b/packages/common/src/ide/types/FileSystem.types.ts index 15818b3633..e8972bc714 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 spoken forms JSON file. + */ + readonly cursorlessTalonStateJsonPath: string; } diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index d7c400618a..e4219a6897 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -12,7 +12,6 @@ export interface CursorlessEngine { storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; snippets: Snippets; - spokenFormsJsonPath: string; injectIde: (ide: IDE | undefined) => void; runIntegrationTests: () => Promise; } diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 595ba21f99..f5e933f599 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -99,7 +99,6 @@ export function createCursorlessEngine( 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 index 37ea03f51a..2d4b7b338d 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -55,5 +55,7 @@ export class CustomSpokenFormGeneratorImpl return this.customSpokenForms.needsInitialTalonUpdate; } - dispose = this.disposer.dispose; + dispose() { + this.disposer.dispose(); + } } diff --git a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts index 08d4117dec..50c98319ed 100644 --- a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts +++ b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts @@ -1,7 +1,5 @@ -import { Disposer, FileSystem, Notifier, isTesting } from "@cursorless/common"; -import * as crypto from "crypto"; +import { Disposer, FileSystem, Notifier } from "@cursorless/common"; import { mkdir, readFile } from "fs/promises"; -import * as os from "os"; import * as path from "path"; import { @@ -20,20 +18,16 @@ const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; export class TalonSpokenFormsJsonReader implements TalonSpokenForms { private disposer = new Disposer(); private notifier = new Notifier(); - public readonly spokenFormsPath; + private cursorlessTalonStateJsonPath; 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, "state.json"); + this.cursorlessTalonStateJsonPath = fileSystem.cursorlessTalonStateJsonPath; this.init(); } private async init() { - const parentDir = path.dirname(this.spokenFormsPath); + const parentDir = path.dirname(this.cursorlessTalonStateJsonPath); await mkdir(parentDir, { recursive: true }); this.disposer.push( this.fileSystem.watchDir(parentDir, () => @@ -52,11 +46,13 @@ export class TalonSpokenFormsJsonReader implements TalonSpokenForms { async getSpokenFormEntries(): Promise { let payload: TalonSpokenFormsPayload; try { - payload = JSON.parse(await readFile(this.spokenFormsPath, "utf-8")); + payload = JSON.parse( + await readFile(this.cursorlessTalonStateJsonPath, "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.`, + `Custom spoken forms file not found at ${this.cursorlessTalonStateJsonPath}. Using default spoken forms.`, ); } diff --git a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts index 564a6a15e8..a87cec20be 100644 --- a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts @@ -125,7 +125,9 @@ export class CustomSpokenForms { })); } - dispose = this.disposer.dispose; + dispose() { + this.disposer.dispose(); + } } function updateEntriesForType( diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 43c74a7134..5bcf0a007c 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -139,7 +139,15 @@ 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"); + + 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..598f1849fa 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts @@ -1,7 +1,14 @@ import { Disposable, FileSystem, PathChangeListener } from "@cursorless/common"; import { RelativePattern, workspace } from "vscode"; +import * as path from "path"; export class VscodeFileSystem implements FileSystem { + constructor(public cursorlessDir: string) {} + + public get cursorlessTalonStateJsonPath() { + return path.join(this.cursorlessDir, "state.json"); + } + watchDir(path: string, onDidChange: PathChangeListener): Disposable { // FIXME: Support globs? const watcher = workspace.createFileSystemWatcher( From c1bd390f54c05717b870e236b95b0277b6a520c4 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:56:31 +0100 Subject: [PATCH 05/14] improve error type safety --- .../src/nodeCommon/TalonSpokenFormsJsonReader.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts index 50c98319ed..4e6420ef2f 100644 --- a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts +++ b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts @@ -50,7 +50,7 @@ export class TalonSpokenFormsJsonReader implements TalonSpokenForms { await readFile(this.cursorlessTalonStateJsonPath, "utf-8"), ); } catch (err) { - if ((err as any)?.code === "ENOENT") { + if (isErrnoException(err) && err.code === "ENOENT") { throw new NeedsInitialTalonUpdateError( `Custom spoken forms file not found at ${this.cursorlessTalonStateJsonPath}. Using default spoken forms.`, ); @@ -73,3 +73,15 @@ export class TalonSpokenFormsJsonReader implements TalonSpokenForms { this.disposer.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; +} From 0adcc2e1f228c203e0dd054f184551009ca1c8f4 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:02:13 +0100 Subject: [PATCH 06/14] fix imports --- packages/cursorless-vscode/src/extension.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 5bcf0a007c..2a1f5aba2e 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"; From 285e6145da29d609805e7c8a4eccfbf19cdc02be Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:07:32 +0100 Subject: [PATCH 07/14] More PR feedback --- .../src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts | 2 +- .../src/nodeCommon/TalonSpokenFormsJsonReader.ts | 2 +- .../scopeProviders/{SpokenFormEntry.ts => TalonSpokenForms.ts} | 0 packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/cursorless-engine/src/scopeProviders/{SpokenFormEntry.ts => TalonSpokenForms.ts} (100%) diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts index 2d4b7b338d..55833937a4 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -7,7 +7,7 @@ import { import { SpokenFormGenerator } from "."; import { CustomSpokenFormGenerator } from ".."; import { CustomSpokenForms } from "../spokenForms/CustomSpokenForms"; -import { TalonSpokenForms } from "../scopeProviders/SpokenFormEntry"; +import { TalonSpokenForms } from "../scopeProviders/TalonSpokenForms"; /** * Simple facade that combines the {@link CustomSpokenForms} and diff --git a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts index 4e6420ef2f..21a2fdc965 100644 --- a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts +++ b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts @@ -6,7 +6,7 @@ import { NeedsInitialTalonUpdateError, SpokenFormEntry, TalonSpokenForms, -} from "../scopeProviders/SpokenFormEntry"; +} from "../scopeProviders/TalonSpokenForms"; interface TalonSpokenFormsPayload { version: number; diff --git a/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts b/packages/cursorless-engine/src/scopeProviders/TalonSpokenForms.ts similarity index 100% rename from packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts rename to packages/cursorless-engine/src/scopeProviders/TalonSpokenForms.ts diff --git a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts index a87cec20be..52a62fe03f 100644 --- a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts @@ -10,7 +10,7 @@ import { NeedsInitialTalonUpdateError, SpokenFormEntry, TalonSpokenForms, -} from "../scopeProviders/SpokenFormEntry"; +} from "../scopeProviders/TalonSpokenForms"; import { ide } from "../singletons/ide.singleton"; import { SpokenFormMap, SpokenFormMapEntry } from "./SpokenFormMap"; import { SpokenFormMapKeyTypes, SpokenFormType } from "./SpokenFormType"; From 4d2000485d27baeb4cae2c56ee23c937c20f6ab0 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:12:30 +0100 Subject: [PATCH 08/14] More tweaks --- packages/common/src/ide/types/FileSystem.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/ide/types/FileSystem.types.ts b/packages/common/src/ide/types/FileSystem.types.ts index e8972bc714..eef51b7730 100644 --- a/packages/common/src/ide/types/FileSystem.types.ts +++ b/packages/common/src/ide/types/FileSystem.types.ts @@ -18,7 +18,7 @@ export interface FileSystem { readonly cursorlessDir: string; /** - * The path to the spoken forms JSON file. + * The path to the Cursorless talon state JSON file. */ readonly cursorlessTalonStateJsonPath: string; } From 36d027ec703206a0126ad07b8d5bee55b850df08 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:14:43 +0100 Subject: [PATCH 09/14] More cleanup# --- .../src/nodeCommon/TalonSpokenFormsJsonReader.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts index 21a2fdc965..12b8175ab0 100644 --- a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts +++ b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts @@ -18,16 +18,15 @@ const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; export class TalonSpokenFormsJsonReader implements TalonSpokenForms { private disposer = new Disposer(); private notifier = new Notifier(); - private cursorlessTalonStateJsonPath; constructor(private fileSystem: FileSystem) { - this.cursorlessTalonStateJsonPath = fileSystem.cursorlessTalonStateJsonPath; - this.init(); } private async init() { - const parentDir = path.dirname(this.cursorlessTalonStateJsonPath); + const parentDir = path.dirname( + this.fileSystem.cursorlessTalonStateJsonPath, + ); await mkdir(parentDir, { recursive: true }); this.disposer.push( this.fileSystem.watchDir(parentDir, () => @@ -47,12 +46,12 @@ export class TalonSpokenFormsJsonReader implements TalonSpokenForms { let payload: TalonSpokenFormsPayload; try { payload = JSON.parse( - await readFile(this.cursorlessTalonStateJsonPath, "utf-8"), + 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.cursorlessTalonStateJsonPath}. Using default spoken forms.`, + `Custom spoken forms file not found at ${this.fileSystem.cursorlessTalonStateJsonPath}. Using default spoken forms.`, ); } From 3086cc404549893cbafbbb37162e832213dee129 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:19:25 +0100 Subject: [PATCH 10/14] Throw error --- .../cursorless-engine/src/spokenForms/CustomSpokenForms.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts index 52a62fe03f..fb8b2bfe90 100644 --- a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts @@ -79,6 +79,9 @@ export class CustomSpokenForms { 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 From a08bf210e60b10a917c016f3ab5e349bc924c4e1 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:46:00 +0100 Subject: [PATCH 11/14] More cleanup --- .../src/nodeCommon/TalonSpokenFormsJsonReader.ts | 15 ++++----------- packages/cursorless-vscode/src/extension.ts | 2 ++ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts index 12b8175ab0..268f47ebcb 100644 --- a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts +++ b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts @@ -1,5 +1,5 @@ import { Disposer, FileSystem, Notifier } from "@cursorless/common"; -import { mkdir, readFile } from "fs/promises"; +import { readFile } from "fs/promises"; import * as path from "path"; import { @@ -20,17 +20,10 @@ export class TalonSpokenFormsJsonReader implements TalonSpokenForms { private notifier = new Notifier(); constructor(private fileSystem: FileSystem) { - this.init(); - } - - private async init() { - const parentDir = path.dirname( - this.fileSystem.cursorlessTalonStateJsonPath, - ); - await mkdir(parentDir, { recursive: true }); this.disposer.push( - this.fileSystem.watchDir(parentDir, () => - this.notifier.notifyListeners(), + this.fileSystem.watchDir( + path.dirname(this.fileSystem.cursorlessTalonStateJsonPath), + () => this.notifier.notifyListeners(), ), ); } diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 2a1f5aba2e..8a5b89844e 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -45,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. @@ -149,6 +150,7 @@ async function createVscodeIde(context: vscode.ExtensionContext) { 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) }; } From 37e3481953ef2906c53a8d9550b4f4a507688767 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 17:04:09 +0100 Subject: [PATCH 12/14] More simplification --- packages/common/src/index.ts | 2 +- packages/common/src/util/Disposer.ts | 32 ------------------- .../CustomSpokenFormGeneratorImpl.ts | 12 +++---- .../nodeCommon/TalonSpokenFormsJsonReader.ts | 14 ++++---- .../src/spokenForms/CustomSpokenForms.ts | 12 +++---- 5 files changed, 19 insertions(+), 53 deletions(-) delete mode 100644 packages/common/src/util/Disposer.ts diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index cc4884476b..812a646551 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -11,7 +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/disposableFrom"; 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 deleted file mode 100644 index bdb24f7b56..0000000000 --- a/packages/common/src/util/Disposer.ts +++ /dev/null @@ -1,32 +0,0 @@ -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) { - // just log, but don't throw; some of the VSCode disposables misbehave, - // and we don't want that to prevent us from disposing the rest of the - // disposables - console.error(e); - } - }); - } -} diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts index 55833937a4..bfb146a87b 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -1,6 +1,6 @@ import { CommandComplete, - Disposer, + Disposable, Listener, ScopeType, } from "@cursorless/common"; @@ -19,19 +19,19 @@ export class CustomSpokenFormGeneratorImpl { private customSpokenForms: CustomSpokenForms; private spokenFormGenerator: SpokenFormGenerator; - private disposer = new Disposer(); + private disposable: Disposable; constructor(talonSpokenForms: TalonSpokenForms) { this.customSpokenForms = new CustomSpokenForms(talonSpokenForms); this.spokenFormGenerator = new SpokenFormGenerator( this.customSpokenForms.spokenFormMap, ); - this.disposer.push( - this.customSpokenForms.onDidChangeCustomSpokenForms(() => { + this.disposable = this.customSpokenForms.onDidChangeCustomSpokenForms( + () => { this.spokenFormGenerator = new SpokenFormGenerator( this.customSpokenForms.spokenFormMap, ); - }), + }, ); } @@ -56,6 +56,6 @@ export class CustomSpokenFormGeneratorImpl } dispose() { - this.disposer.dispose(); + this.disposable.dispose(); } } diff --git a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts index 268f47ebcb..26c6db93bb 100644 --- a/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts +++ b/packages/cursorless-engine/src/nodeCommon/TalonSpokenFormsJsonReader.ts @@ -1,4 +1,4 @@ -import { Disposer, FileSystem, Notifier } from "@cursorless/common"; +import { Disposable, FileSystem, Notifier } from "@cursorless/common"; import { readFile } from "fs/promises"; import * as path from "path"; @@ -16,15 +16,13 @@ interface TalonSpokenFormsPayload { const LATEST_SPOKEN_FORMS_JSON_VERSION = 0; export class TalonSpokenFormsJsonReader implements TalonSpokenForms { - private disposer = new Disposer(); + private disposable: Disposable; private notifier = new Notifier(); constructor(private fileSystem: FileSystem) { - this.disposer.push( - this.fileSystem.watchDir( - path.dirname(this.fileSystem.cursorlessTalonStateJsonPath), - () => this.notifier.notifyListeners(), - ), + this.disposable = this.fileSystem.watchDir( + path.dirname(this.fileSystem.cursorlessTalonStateJsonPath), + () => this.notifier.notifyListeners(), ); } @@ -62,7 +60,7 @@ export class TalonSpokenFormsJsonReader implements TalonSpokenForms { } dispose() { - this.disposer.dispose(); + this.disposable.dispose(); } } diff --git a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts index fb8b2bfe90..28ff5e3c8f 100644 --- a/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/spokenForms/CustomSpokenForms.ts @@ -1,13 +1,13 @@ import { CustomRegexScopeType, - Disposer, + Disposable, Notifier, showError, } from "@cursorless/common"; import { isEqual } from "lodash"; import { - SUPPORTED_ENTRY_TYPES, NeedsInitialTalonUpdateError, + SUPPORTED_ENTRY_TYPES, SpokenFormEntry, TalonSpokenForms, } from "../scopeProviders/TalonSpokenForms"; @@ -31,7 +31,7 @@ type Writable = { * forms for a subset of all customizable spoken forms. */ export class CustomSpokenForms { - private disposer = new Disposer(); + private disposable: Disposable; private notifier = new Notifier(); private spokenFormMap_: Writable = { ...defaultSpokenFormMap }; @@ -61,8 +61,8 @@ export class CustomSpokenForms { } constructor(private talonSpokenForms: TalonSpokenForms) { - this.disposer.push( - talonSpokenForms.onDidChange(() => this.updateSpokenFormMaps()), + this.disposable = talonSpokenForms.onDidChange(() => + this.updateSpokenFormMaps(), ); this.updateSpokenFormMaps(); @@ -129,7 +129,7 @@ export class CustomSpokenForms { } dispose() { - this.disposer.dispose(); + this.disposable.dispose(); } } From e2af29e3a99480f854089271aeaf7a9a8765ea06 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 26 Oct 2023 17:19:09 +0100 Subject: [PATCH 13/14] Remove export --- packages/common/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 812a646551..4f61638126 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -11,7 +11,6 @@ export { getKey, splitKey } from "./util/splitKey"; export { hrtimeBigintToSeconds } from "./util/timeUtils"; export * from "./util/walkSync"; export * from "./util/walkAsync"; -export * from "./util/disposableFrom"; export * from "./util/camelCaseToAllDown"; export { Notifier } from "./util/Notifier"; export type { Listener } from "./util/Notifier"; From bb8032684bc9d229641a39d229d99a09c001b4f1 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Sun, 29 Oct 2023 17:28:04 +0000 Subject: [PATCH 14/14] Pr cleanup --- .../cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts index 598f1849fa..fed9699b11 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts @@ -3,10 +3,13 @@ import { RelativePattern, workspace } from "vscode"; import * as path from "path"; export class VscodeFileSystem implements FileSystem { - constructor(public cursorlessDir: string) {} + public readonly cursorlessTalonStateJsonPath: string; - public get cursorlessTalonStateJsonPath() { - return path.join(this.cursorlessDir, "state.json"); + constructor(public readonly cursorlessDir: string) { + this.cursorlessTalonStateJsonPath = path.join( + this.cursorlessDir, + "state.json", + ); } watchDir(path: string, onDidChange: PathChangeListener): Disposable {