Skip to content

Commit b33f98f

Browse files
committed
Get custom spoken forms from Talon
1 parent 4e4b29a commit b33f98f

File tree

9 files changed

+417
-0
lines changed

9 files changed

+417
-0
lines changed

packages/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export { getKey, splitKey } from "./util/splitKey";
1111
export { hrtimeBigintToSeconds } from "./util/timeUtils";
1212
export * from "./util/walkSync";
1313
export * from "./util/walkAsync";
14+
export * from "./util/Disposer";
1415
export * from "./util/camelCaseToAllDown";
1516
export { Notifier } from "./util/Notifier";
1617
export type { Listener } from "./util/Notifier";

packages/common/src/util/Disposer.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Disposable } from "../ide/types/ide.types";
2+
3+
/**
4+
* A class that can be used to dispose of multiple disposables at once. This is
5+
* useful for managing the lifetime of multiple disposables that are created
6+
* together. It ensures that if one of the disposables throws an error during
7+
* disposal, the rest of the disposables will still be disposed.
8+
*/
9+
export class Disposer implements Disposable {
10+
private disposables: Disposable[] = [];
11+
12+
constructor(...disposables: Disposable[]) {
13+
this.push(...disposables);
14+
}
15+
16+
public push(...disposables: Disposable[]) {
17+
this.disposables.push(...disposables);
18+
}
19+
20+
dispose(): void {
21+
this.disposables.forEach(({ dispose }) => {
22+
try {
23+
dispose();
24+
} catch (e) {
25+
// do nothing; some of the VSCode disposables misbehave, and we don't
26+
// want that to prevent us from disposing the rest of the disposables
27+
}
28+
});
29+
}
30+
}

packages/cursorless-engine/src/api/CursorlessEngineApi.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,26 @@ import { ScopeProvider } from "./ScopeProvider";
77
export interface CursorlessEngine {
88
commandApi: CommandApi;
99
scopeProvider: ScopeProvider;
10+
customSpokenFormGenerator: CustomSpokenFormGenerator;
1011
testCaseRecorder: TestCaseRecorder;
1112
storedTargets: StoredTargetMap;
1213
hatTokenMap: HatTokenMap;
1314
snippets: Snippets;
15+
spokenFormsJsonPath: string;
1416
injectIde: (ide: IDE | undefined) => void;
1517
runIntegrationTests: () => Promise<void>;
1618
}
1719

20+
export interface CustomSpokenFormGenerator {
21+
/**
22+
* If `true`, indicates they need to update their Talon files to get the
23+
* machinery used to share spoken forms from Talon to the VSCode extension.
24+
*/
25+
readonly needsInitialTalonUpdate: boolean | undefined;
26+
27+
onDidChangeCustomSpokenForms: (listener: () => void) => void;
28+
}
29+
1830
export interface CommandApi {
1931
/**
2032
* Runs a command. This is the core of the Cursorless engine.

packages/cursorless-engine/src/cursorlessEngine.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import { HatTokenMapImpl } from "./core/HatTokenMapImpl";
1515
import { Snippets } from "./core/Snippets";
1616
import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape";
1717
import { RangeUpdater } from "./core/updateSelections/RangeUpdater";
18+
import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl";
1819
import { LanguageDefinitions } from "./languages/LanguageDefinitions";
1920
import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl";
2021
import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers";
2122
import { runCommand } from "./runCommand";
2223
import { runIntegrationTests } from "./runIntegrationTests";
24+
import { TalonSpokenFormsJsonReader } from "./nodeCommon/TalonSpokenFormsJsonReader";
2325
import { injectIde } from "./singletons/ide.singleton";
2426
import { ScopeRangeWatcher } from "./ScopeVisualizer/ScopeRangeWatcher";
2527

@@ -53,6 +55,12 @@ export function createCursorlessEngine(
5355

5456
const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter);
5557

58+
const talonSpokenForms = new TalonSpokenFormsJsonReader(fileSystem);
59+
60+
const customSpokenFormGenerator = new CustomSpokenFormGeneratorImpl(
61+
talonSpokenForms,
62+
);
63+
5664
ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug);
5765

5866
return {
@@ -86,10 +94,12 @@ export function createCursorlessEngine(
8694
},
8795
},
8896
scopeProvider: createScopeProvider(languageDefinitions, storedTargets),
97+
customSpokenFormGenerator,
8998
testCaseRecorder,
9099
storedTargets,
91100
hatTokenMap,
92101
snippets,
102+
spokenFormsJsonPath: talonSpokenForms.spokenFormsPath,
93103
injectIde,
94104
runIntegrationTests: () =>
95105
runIntegrationTests(treeSitter, languageDefinitions),
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
CommandComplete,
3+
Disposer,
4+
Listener,
5+
ScopeType,
6+
} from "@cursorless/common";
7+
import { SpokenFormGenerator } from ".";
8+
import { CustomSpokenFormGenerator } from "..";
9+
import { CustomSpokenForms } from "../spokenForms/CustomSpokenForms";
10+
import { TalonSpokenForms } from "../scopeProviders/SpokenFormEntry";
11+
12+
/**
13+
* Simple facade that combines the {@link CustomSpokenForms} and
14+
* {@link SpokenFormGenerator} classes. Its main purpose is to reconstruct the
15+
* {@link SpokenFormGenerator} when the {@link CustomSpokenForms} change.
16+
*/
17+
export class CustomSpokenFormGeneratorImpl
18+
implements CustomSpokenFormGenerator
19+
{
20+
private customSpokenForms: CustomSpokenForms;
21+
private spokenFormGenerator: SpokenFormGenerator;
22+
private disposer = new Disposer();
23+
24+
constructor(talonSpokenForms: TalonSpokenForms) {
25+
this.customSpokenForms = new CustomSpokenForms(talonSpokenForms);
26+
this.spokenFormGenerator = new SpokenFormGenerator(
27+
this.customSpokenForms.spokenFormMap,
28+
);
29+
this.disposer.push(
30+
this.customSpokenForms.onDidChangeCustomSpokenForms(() => {
31+
this.spokenFormGenerator = new SpokenFormGenerator(
32+
this.customSpokenForms.spokenFormMap,
33+
);
34+
}),
35+
);
36+
}
37+
38+
onDidChangeCustomSpokenForms(listener: Listener<[]>) {
39+
return this.customSpokenForms.onDidChangeCustomSpokenForms(listener);
40+
}
41+
42+
commandToSpokenForm(command: CommandComplete) {
43+
return this.spokenFormGenerator.processCommand(command);
44+
}
45+
46+
scopeTypeToSpokenForm(scopeType: ScopeType) {
47+
return this.spokenFormGenerator.processScopeType(scopeType);
48+
}
49+
50+
getCustomRegexScopeTypes() {
51+
return this.customSpokenForms.getCustomRegexScopeTypes();
52+
}
53+
54+
get needsInitialTalonUpdate() {
55+
return this.customSpokenForms.needsInitialTalonUpdate;
56+
}
57+
58+
dispose = this.disposer.dispose;
59+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Node common
2+
3+
This directory contains utilities that are available in a node.js context.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Disposer, FileSystem, Notifier, isTesting } from "@cursorless/common";
2+
import * as crypto from "crypto";
3+
import { mkdir, readFile } from "fs/promises";
4+
import * as os from "os";
5+
6+
import * as path from "path";
7+
import {
8+
NeedsInitialTalonUpdateError,
9+
SpokenFormEntry,
10+
TalonSpokenForms,
11+
} from "../scopeProviders/SpokenFormEntry";
12+
13+
interface TalonSpokenFormsPayload {
14+
version: number;
15+
spokenForms: SpokenFormEntry[];
16+
}
17+
18+
const LATEST_SPOKEN_FORMS_JSON_VERSION = 0;
19+
20+
export class TalonSpokenFormsJsonReader implements TalonSpokenForms {
21+
private disposer = new Disposer();
22+
private notifier = new Notifier();
23+
public readonly spokenFormsPath;
24+
25+
constructor(private fileSystem: FileSystem) {
26+
const cursorlessDir = isTesting()
27+
? path.join(os.tmpdir(), crypto.randomBytes(16).toString("hex"))
28+
: path.join(os.homedir(), ".cursorless");
29+
30+
this.spokenFormsPath = path.join(cursorlessDir, "state.json");
31+
32+
this.init();
33+
}
34+
35+
private async init() {
36+
const parentDir = path.dirname(this.spokenFormsPath);
37+
await mkdir(parentDir, { recursive: true });
38+
this.disposer.push(
39+
this.fileSystem.watchDir(parentDir, () =>
40+
this.notifier.notifyListeners(),
41+
),
42+
);
43+
}
44+
45+
/**
46+
* Registers a callback to be run when the spoken forms change.
47+
* @param callback The callback to run when the scope ranges change
48+
* @returns A {@link Disposable} which will stop the callback from running
49+
*/
50+
onDidChange = this.notifier.registerListener;
51+
52+
async getSpokenFormEntries(): Promise<SpokenFormEntry[]> {
53+
let payload: TalonSpokenFormsPayload;
54+
try {
55+
payload = JSON.parse(await readFile(this.spokenFormsPath, "utf-8"));
56+
} catch (err) {
57+
if ((err as any)?.code === "ENOENT") {
58+
throw new NeedsInitialTalonUpdateError(
59+
`Custom spoken forms file not found at ${this.spokenFormsPath}. Using default spoken forms.`,
60+
);
61+
}
62+
63+
throw err;
64+
}
65+
66+
if (payload.version !== LATEST_SPOKEN_FORMS_JSON_VERSION) {
67+
// In the future, we'll need to handle migrations. Not sure exactly how yet.
68+
throw new Error(
69+
`Invalid spoken forms version. Expected ${LATEST_SPOKEN_FORMS_JSON_VERSION} but got ${payload.version}`,
70+
);
71+
}
72+
73+
return payload.spokenForms;
74+
}
75+
76+
dispose() {
77+
this.disposer.dispose();
78+
}
79+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Notifier, SimpleScopeTypeType } from "@cursorless/common";
2+
import { SpeakableSurroundingPairName } from "../spokenForms/SpokenFormType";
3+
4+
/**
5+
* Interface representing a communication mechanism whereby Talon can provide
6+
* the user's custom spoken forms to the Cursorless engine.
7+
*/
8+
export interface TalonSpokenForms {
9+
getSpokenFormEntries(): Promise<SpokenFormEntry[]>;
10+
onDidChange: Notifier["registerListener"];
11+
}
12+
13+
export interface CustomRegexSpokenFormEntry {
14+
type: "customRegex";
15+
id: string;
16+
spokenForms: string[];
17+
}
18+
19+
export interface PairedDelimiterSpokenFormEntry {
20+
type: "pairedDelimiter";
21+
id: SpeakableSurroundingPairName;
22+
spokenForms: string[];
23+
}
24+
25+
export interface SimpleScopeTypeTypeSpokenFormEntry {
26+
type: "simpleScopeTypeType";
27+
id: SimpleScopeTypeType;
28+
spokenForms: string[];
29+
}
30+
31+
export type SpokenFormEntry =
32+
| CustomRegexSpokenFormEntry
33+
| PairedDelimiterSpokenFormEntry
34+
| SimpleScopeTypeTypeSpokenFormEntry;
35+
36+
export class NeedsInitialTalonUpdateError extends Error {
37+
constructor(message: string) {
38+
super(message);
39+
this.name = "NeedsInitialTalonUpdateError";
40+
}
41+
}

0 commit comments

Comments
 (0)