-
-
Notifications
You must be signed in to change notification settings - Fork 91
Get custom spoken forms from Talon #1940
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
d92c0d2
eb9e422
fa94cc1
9c93fab
c1bd390
0adcc2e
285e614
4d20004
36d027e
3086cc4
a08bf21
37e3481
e2af29e
bb80326
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| 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 { | ||
pokey marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { | ||
| CommandComplete, | ||
| Disposer, | ||
| 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 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(); | ||
| } | ||
| } |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will move this dir into its own package as part of #1023; for now just making a dir to start collecting these things |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # Node common | ||
|
|
||
| This directory contains utilities that are available in a node.js context. |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to keep this file totally focused on the transport layer (watching a json file). That way we can swap it out for Talon rpc, Rango rpc, etc depending on the context |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { Disposer, FileSystem, Notifier } from "@cursorless/common"; | ||
| import { mkdir, 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 disposer = new Disposer(); | ||
| 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 }); | ||
pokey marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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<SpokenFormEntry[]> { | ||
| 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.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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SpokenFormEntry[]>; | ||
| 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<T extends SpokenFormType> { | ||
| type: T; | ||
| id: SpokenFormMapKeyTypes[T]; | ||
| spokenForms: string[]; | ||
| } | ||
|
|
||
| export type SpokenFormEntry = { | ||
| [K in SpokenFormType]: SpokenFormEntryForType<K>; | ||
| }[SupportedEntryType]; | ||
|
|
||
| export class NeedsInitialTalonUpdateError extends Error { | ||
| constructor(message: string) { | ||
| super(message); | ||
| this.name = "NeedsInitialTalonUpdateError"; | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.