Skip to content

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

Merged
merged 14 commits into from
Oct 30, 2023
11 changes: 11 additions & 0 deletions packages/common/src/ide/types/FileSystem.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 11 additions & 0 deletions packages/cursorless-engine/src/api/CursorlessEngineApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ScopeProvider } from "./ScopeProvider";
export interface CursorlessEngine {
commandApi: CommandApi;
scopeProvider: ScopeProvider;
customSpokenFormGenerator: CustomSpokenFormGenerator;
testCaseRecorder: TestCaseRecorder;
storedTargets: StoredTargetMap;
hatTokenMap: HatTokenMap;
Expand All @@ -15,6 +16,16 @@ export interface CursorlessEngine {
runIntegrationTests: () => Promise<void>;
}

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.
Expand Down
9 changes: 9 additions & 0 deletions packages/cursorless-engine/src/cursorlessEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -86,6 +94,7 @@ export function createCursorlessEngine(
},
},
scopeProvider: createScopeProvider(languageDefinitions, storedTargets),
customSpokenFormGenerator,
testCaseRecorder,
storedTargets,
hatTokenMap,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
3 changes: 3 additions & 0 deletions packages/cursorless-engine/src/nodeCommon/README.md
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.
Copy link
Member Author

Choose a reason for hiding this comment

The 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,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<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.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;
}
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";
}
}
Loading