Skip to content

Commit cc155ba

Browse files
authored
Get custom spoken forms from Talon (#1940)
- This PR is the VSCode side of #1939 ## Checklist - [-] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) (tests added in #1941) - [-] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [-] I have not broken the cheatsheet
1 parent a75e017 commit cc155ba

File tree

10 files changed

+423
-1
lines changed

10 files changed

+423
-1
lines changed

packages/common/src/ide/types/FileSystem.types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,15 @@ export interface FileSystem {
1010
* @returns A disposable to cancel the watcher
1111
*/
1212
watchDir(path: string, onDidChange: PathChangeListener): Disposable;
13+
14+
/**
15+
* The path to the directory that Cursorless talon uses to share its state
16+
* with the Cursorless engine.
17+
*/
18+
readonly cursorlessDir: string;
19+
20+
/**
21+
* The path to the Cursorless talon state JSON file.
22+
*/
23+
readonly cursorlessTalonStateJsonPath: string;
1324
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ 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;
@@ -15,6 +16,16 @@ export interface CursorlessEngine {
1516
runIntegrationTests: () => Promise<void>;
1617
}
1718

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

packages/cursorless-engine/src/cursorlessEngine.ts

Lines changed: 9 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,6 +94,7 @@ export function createCursorlessEngine(
8694
},
8795
},
8896
scopeProvider: createScopeProvider(languageDefinitions, storedTargets),
97+
customSpokenFormGenerator,
8998
testCaseRecorder,
9099
storedTargets,
91100
hatTokenMap,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {
2+
CommandComplete,
3+
Disposable,
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/TalonSpokenForms";
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 disposable: Disposable;
23+
24+
constructor(talonSpokenForms: TalonSpokenForms) {
25+
this.customSpokenForms = new CustomSpokenForms(talonSpokenForms);
26+
this.spokenFormGenerator = new SpokenFormGenerator(
27+
this.customSpokenForms.spokenFormMap,
28+
);
29+
this.disposable = this.customSpokenForms.onDidChangeCustomSpokenForms(
30+
() => {
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() {
59+
this.disposable.dispose();
60+
}
61+
}
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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Disposable, FileSystem, Notifier } from "@cursorless/common";
2+
import { readFile } from "fs/promises";
3+
4+
import * as path from "path";
5+
import {
6+
NeedsInitialTalonUpdateError,
7+
SpokenFormEntry,
8+
TalonSpokenForms,
9+
} from "../scopeProviders/TalonSpokenForms";
10+
11+
interface TalonSpokenFormsPayload {
12+
version: number;
13+
spokenForms: SpokenFormEntry[];
14+
}
15+
16+
const LATEST_SPOKEN_FORMS_JSON_VERSION = 0;
17+
18+
export class TalonSpokenFormsJsonReader implements TalonSpokenForms {
19+
private disposable: Disposable;
20+
private notifier = new Notifier();
21+
22+
constructor(private fileSystem: FileSystem) {
23+
this.disposable = this.fileSystem.watchDir(
24+
path.dirname(this.fileSystem.cursorlessTalonStateJsonPath),
25+
() => this.notifier.notifyListeners(),
26+
);
27+
}
28+
29+
/**
30+
* Registers a callback to be run when the spoken forms change.
31+
* @param callback The callback to run when the scope ranges change
32+
* @returns A {@link Disposable} which will stop the callback from running
33+
*/
34+
onDidChange = this.notifier.registerListener;
35+
36+
async getSpokenFormEntries(): Promise<SpokenFormEntry[]> {
37+
let payload: TalonSpokenFormsPayload;
38+
try {
39+
payload = JSON.parse(
40+
await readFile(this.fileSystem.cursorlessTalonStateJsonPath, "utf-8"),
41+
);
42+
} catch (err) {
43+
if (isErrnoException(err) && err.code === "ENOENT") {
44+
throw new NeedsInitialTalonUpdateError(
45+
`Custom spoken forms file not found at ${this.fileSystem.cursorlessTalonStateJsonPath}. Using default spoken forms.`,
46+
);
47+
}
48+
49+
throw err;
50+
}
51+
52+
if (payload.version !== LATEST_SPOKEN_FORMS_JSON_VERSION) {
53+
// In the future, we'll need to handle migrations. Not sure exactly how yet.
54+
throw new Error(
55+
`Invalid spoken forms version. Expected ${LATEST_SPOKEN_FORMS_JSON_VERSION} but got ${payload.version}`,
56+
);
57+
}
58+
59+
return payload.spokenForms;
60+
}
61+
62+
dispose() {
63+
this.disposable.dispose();
64+
}
65+
}
66+
67+
/**
68+
* A user-defined type guard function that checks if a given error is a
69+
* `NodeJS.ErrnoException`.
70+
*
71+
* @param {any} error - The error to check.
72+
* @returns {error is NodeJS.ErrnoException} - Returns `true` if the error is a
73+
* {@link NodeJS.ErrnoException}, otherwise `false`.
74+
*/
75+
function isErrnoException(error: any): error is NodeJS.ErrnoException {
76+
return error instanceof Error && "code" in error;
77+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Notifier } from "@cursorless/common";
2+
import {
3+
SpokenFormMapKeyTypes,
4+
SpokenFormType,
5+
} from "../spokenForms/SpokenFormType";
6+
7+
/**
8+
* Interface representing a communication mechanism whereby Talon can provide
9+
* the user's custom spoken forms to the Cursorless engine.
10+
*/
11+
export interface TalonSpokenForms {
12+
getSpokenFormEntries(): Promise<SpokenFormEntry[]>;
13+
onDidChange: Notifier["registerListener"];
14+
}
15+
16+
/**
17+
* The types of entries for which we currently support getting custom spoken
18+
* forms from Talon.
19+
*/
20+
export const SUPPORTED_ENTRY_TYPES = [
21+
"simpleScopeTypeType",
22+
"customRegex",
23+
"pairedDelimiter",
24+
] as const;
25+
26+
type SupportedEntryType = (typeof SUPPORTED_ENTRY_TYPES)[number];
27+
28+
export interface SpokenFormEntryForType<T extends SpokenFormType> {
29+
type: T;
30+
id: SpokenFormMapKeyTypes[T];
31+
spokenForms: string[];
32+
}
33+
34+
export type SpokenFormEntry = {
35+
[K in SpokenFormType]: SpokenFormEntryForType<K>;
36+
}[SupportedEntryType];
37+
38+
export class NeedsInitialTalonUpdateError extends Error {
39+
constructor(message: string) {
40+
super(message);
41+
this.name = "NeedsInitialTalonUpdateError";
42+
}
43+
}

0 commit comments

Comments
 (0)