Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
30 changes: 30 additions & 0 deletions packages/common/src/util/Disposer.ts
Original file line number Diff line number Diff line change
@@ -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
}
});
}
}
12 changes: 12 additions & 0 deletions packages/cursorless-engine/src/api/CursorlessEngineApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
10 changes: 10 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,10 +94,12 @@ export function createCursorlessEngine(
},
},
scopeProvider: createScopeProvider(languageDefinitions, storedTargets),
customSpokenFormGenerator,
testCaseRecorder,
storedTargets,
hatTokenMap,
snippets,
spokenFormsJsonPath: talonSpokenForms.spokenFormsPath,
injectIde,
runIntegrationTests: () =>
runIntegrationTests(treeSitter, languageDefinitions),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
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,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<SpokenFormEntry[]> {
let payload: TalonSpokenFormsPayload;
try {
payload = JSON.parse(await readFile(this.spokenFormsPath, "utf-8"));
} catch (err) {
if ((err as any)?.code === "ENOENT") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could type annotate the catch instead:

Suggested change
} catch (err) {
if ((err as any)?.code === "ENOENT") {
} catch (err: any) {
if (err?.code === "ENOENT") {

but the idiomatic thing would be to do an instanceof check

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c1bd390 contains an alternative approach, courtesy of chatgpt. wdyt?

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();
}
}
41 changes: 41 additions & 0 deletions packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts
Original file line number Diff line number Diff line change
@@ -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<SpokenFormEntry[]>;
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";
}
}
Loading