Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
32 changes: 32 additions & 0 deletions packages/common/src/util/Disposer.ts
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 {
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);
}
});
}
}
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,
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();
}
}
3 changes: 3 additions & 0 deletions packages/cursorless-engine/src/nodeCommon/README.md
Copy link
Member Author

Choose a reason for hiding this comment

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