Skip to content

Commit 6be3705

Browse files
committed
Get custom spoken forms from Talon
1 parent a39fb93 commit 6be3705

14 files changed

+417
-9
lines changed

packages/common/src/index.ts

+1
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

+30
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
CustomRegexScopeType,
3+
Disposer,
4+
Notifier,
5+
showError,
6+
} from "@cursorless/common";
7+
import { isEqual } from "lodash";
8+
import {
9+
DefaultSpokenFormMapEntry,
10+
defaultSpokenFormInfo,
11+
defaultSpokenFormMap,
12+
} from "./DefaultSpokenFormMap";
13+
import {
14+
SpokenFormMap,
15+
SpokenFormMapEntry,
16+
SpokenFormType,
17+
} from "./SpokenFormMap";
18+
import {
19+
NeedsInitialTalonUpdateError,
20+
SpokenFormEntry,
21+
TalonSpokenForms,
22+
} from "./scopeProviders/SpokenFormEntry";
23+
import { ide } from "./singletons/ide.singleton";
24+
25+
const ENTRY_TYPES = [
26+
"simpleScopeTypeType",
27+
"customRegex",
28+
"pairedDelimiter",
29+
] as const;
30+
31+
type Writable<T> = {
32+
-readonly [K in keyof T]: T[K];
33+
};
34+
35+
/**
36+
* Maintains a list of all scope types and notifies listeners when it changes.
37+
*/
38+
export class CustomSpokenForms {
39+
private disposer = new Disposer();
40+
private notifier = new Notifier();
41+
42+
private spokenFormMap_: Writable<SpokenFormMap> = { ...defaultSpokenFormMap };
43+
44+
get spokenFormMap(): SpokenFormMap {
45+
return this.spokenFormMap_;
46+
}
47+
48+
private customSpokenFormsInitialized_ = false;
49+
private needsInitialTalonUpdate_: boolean | undefined;
50+
51+
/**
52+
* If `true`, indicates they need to update their Talon files to get the
53+
* machinery used to share spoken forms from Talon to the VSCode extension.
54+
*/
55+
get needsInitialTalonUpdate() {
56+
return this.needsInitialTalonUpdate_;
57+
}
58+
59+
/**
60+
* Whether the custom spoken forms have been initialized. If `false`, the
61+
* default spoken forms are currently being used while the custom spoken forms
62+
* are being loaded.
63+
*/
64+
get customSpokenFormsInitialized() {
65+
return this.customSpokenFormsInitialized_;
66+
}
67+
68+
constructor(private talonSpokenForms: TalonSpokenForms) {
69+
this.disposer.push(
70+
talonSpokenForms.onDidChange(() => this.updateSpokenFormMaps()),
71+
);
72+
73+
this.updateSpokenFormMaps();
74+
}
75+
76+
/**
77+
* Registers a callback to be run when the custom spoken forms change.
78+
* @param callback The callback to run when the scope ranges change
79+
* @returns A {@link Disposable} which will stop the callback from running
80+
*/
81+
onDidChangeCustomSpokenForms = this.notifier.registerListener;
82+
83+
private async updateSpokenFormMaps(): Promise<void> {
84+
let entries: SpokenFormEntry[];
85+
try {
86+
entries = await this.talonSpokenForms.getSpokenFormEntries();
87+
} catch (err) {
88+
if (err instanceof NeedsInitialTalonUpdateError) {
89+
// Handle case where spokenForms.json doesn't exist yet
90+
this.needsInitialTalonUpdate_ = true;
91+
} else {
92+
console.error("Error loading custom spoken forms", err);
93+
showError(
94+
ide().messages,
95+
"CustomSpokenForms.updateSpokenFormMaps",
96+
`Error loading custom spoken forms: ${
97+
(err as Error).message
98+
}}}. Falling back to default spoken forms.`,
99+
);
100+
}
101+
102+
this.spokenFormMap_ = { ...defaultSpokenFormMap };
103+
this.customSpokenFormsInitialized_ = false;
104+
this.notifier.notifyListeners();
105+
106+
return;
107+
}
108+
109+
for (const entryType of ENTRY_TYPES) {
110+
// FIXME: How to avoid the type assertion?
111+
const entry = Object.fromEntries(
112+
entries
113+
.filter((entry) => entry.type === entryType)
114+
.map(({ id, spokenForms }) => [id, spokenForms]),
115+
);
116+
117+
const defaultEntry: Partial<Record<string, DefaultSpokenFormMapEntry>> =
118+
defaultSpokenFormInfo[entryType];
119+
const ids = Array.from(
120+
new Set([...Object.keys(defaultEntry), ...Object.keys(entry)]),
121+
);
122+
this.spokenFormMap_[entryType] = Object.fromEntries(
123+
ids.map((id): [SpokenFormType, SpokenFormMapEntry] => {
124+
const { defaultSpokenForms = [], isSecret = false } =
125+
defaultEntry[id] ?? {};
126+
const customSpokenForms = entry[id];
127+
if (customSpokenForms != null) {
128+
return [
129+
id as SpokenFormType,
130+
{
131+
defaultSpokenForms,
132+
spokenForms: customSpokenForms,
133+
requiresTalonUpdate: false,
134+
isCustom: isEqual(defaultSpokenForms, customSpokenForms),
135+
isSecret,
136+
},
137+
];
138+
} else {
139+
return [
140+
id as SpokenFormType,
141+
{
142+
defaultSpokenForms,
143+
spokenForms: [],
144+
// If it's not a secret spoken form, then it's a new scope type
145+
requiresTalonUpdate: !isSecret,
146+
isCustom: false,
147+
isSecret,
148+
},
149+
];
150+
}
151+
}),
152+
) as any;
153+
}
154+
155+
this.customSpokenFormsInitialized_ = true;
156+
this.notifier.notifyListeners();
157+
}
158+
159+
getCustomRegexScopeTypes(): CustomRegexScopeType[] {
160+
return Object.keys(this.spokenFormMap_.customRegex).map((regex) => ({
161+
type: "customRegex",
162+
regex,
163+
}));
164+
}
165+
166+
dispose = this.disposer.dispose;
167+
}

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

+13-1
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,31 @@ import { Command, HatTokenMap, IDE } from "@cursorless/common";
22
import { Snippets } from "../core/Snippets";
33
import { StoredTargetMap } from "../core/StoredTargets";
44
import { TestCaseRecorder } from "../testCaseRecorder/TestCaseRecorder";
5-
import { ScopeProvider } from "./ScopeProvider";
5+
import { ScopeProvider } from "@cursorless/common";
66

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

+10-1
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 "./scopeProviders/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 {
@@ -85,11 +93,12 @@ export function createCursorlessEngine(
8593
);
8694
},
8795
},
88-
scopeProvider: createScopeProvider(languageDefinitions, storedTargets),
96+
customSpokenFormGenerator,
8997
testCaseRecorder,
9098
storedTargets,
9199
hatTokenMap,
92100
snippets,
101+
spokenFormsJsonPath: talonSpokenForms.spokenFormsPath,
93102
injectIde,
94103
runIntegrationTests: () =>
95104
runIntegrationTests(treeSitter, languageDefinitions),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 "../CustomSpokenForms";
10+
import { TalonSpokenForms } from "../scopeProviders/SpokenFormEntry";
11+
12+
export class CustomSpokenFormGeneratorImpl
13+
implements CustomSpokenFormGenerator
14+
{
15+
private customSpokenForms: CustomSpokenForms;
16+
private spokenFormGenerator: SpokenFormGenerator;
17+
private disposer = new Disposer();
18+
19+
constructor(talonSpokenForms: TalonSpokenForms) {
20+
this.customSpokenForms = new CustomSpokenForms(talonSpokenForms);
21+
this.spokenFormGenerator = new SpokenFormGenerator(
22+
this.customSpokenForms.spokenFormMap,
23+
);
24+
this.disposer.push(
25+
this.customSpokenForms.onDidChangeCustomSpokenForms(() => {
26+
this.spokenFormGenerator = new SpokenFormGenerator(
27+
this.customSpokenForms.spokenFormMap,
28+
);
29+
}),
30+
);
31+
}
32+
33+
onDidChangeCustomSpokenForms(listener: Listener<[]>) {
34+
return this.customSpokenForms.onDidChangeCustomSpokenForms(listener);
35+
}
36+
37+
commandToSpokenForm(command: CommandComplete) {
38+
return this.spokenFormGenerator.command(command);
39+
}
40+
41+
scopeTypeToSpokenForm(scopeType: ScopeType) {
42+
return this.spokenFormGenerator.scopeType(scopeType);
43+
}
44+
45+
getCustomRegexScopeTypes() {
46+
return this.customSpokenForms.getCustomRegexScopeTypes();
47+
}
48+
49+
get needsInitialTalonUpdate() {
50+
return this.customSpokenForms.needsInitialTalonUpdate;
51+
}
52+
53+
dispose = this.disposer.dispose;
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Notifier, SimpleScopeTypeType } from "@cursorless/common";
2+
import { SpeakableSurroundingPairName } from "../SpokenFormMap";
3+
4+
export interface TalonSpokenForms {
5+
getSpokenFormEntries(): Promise<SpokenFormEntry[]>;
6+
onDidChange: Notifier["registerListener"];
7+
}
8+
9+
export interface CustomRegexSpokenFormEntry {
10+
type: "customRegex";
11+
id: string;
12+
spokenForms: string[];
13+
}
14+
15+
export interface PairedDelimiterSpokenFormEntry {
16+
type: "pairedDelimiter";
17+
id: SpeakableSurroundingPairName;
18+
spokenForms: string[];
19+
}
20+
21+
export interface SimpleScopeTypeTypeSpokenFormEntry {
22+
type: "simpleScopeTypeType";
23+
id: SimpleScopeTypeType;
24+
spokenForms: string[];
25+
}
26+
27+
export type SpokenFormEntry =
28+
| CustomRegexSpokenFormEntry
29+
| PairedDelimiterSpokenFormEntry
30+
| SimpleScopeTypeTypeSpokenFormEntry;
31+
32+
export class NeedsInitialTalonUpdateError extends Error {
33+
constructor(message: string) {
34+
super(message);
35+
this.name = "NeedsInitialTalonUpdateError";
36+
}
37+
}

0 commit comments

Comments
 (0)