Skip to content

Commit fcf9e97

Browse files
committed
More sophisticated custom spoken forms
1 parent d7b3ba6 commit fcf9e97

18 files changed

+378
-94
lines changed

cursorless-talon/src/cursorless.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ def private_cursorless_show_settings_in_ide():
1414
"""Show Cursorless-specific settings in ide"""
1515

1616
def private_cursorless_show_sidebar():
17-
"""Show Cursorless sidebar"""
17+
"""Show Cursorless-specific settings in ide"""

packages/common/src/types/command/PartialTargetDescriptor.types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,15 @@ export function isSimpleScopeType(
175175
return (simpleScopeTypeTypes as readonly string[]).includes(scopeType.type);
176176
}
177177

178+
const SECRET_SCOPE_TYPES = [
179+
"string",
180+
"switchStatementSubject",
181+
] as const satisfies readonly SimpleScopeTypeType[];
182+
183+
export function isSecretScopeType(scopeType: ScopeType): boolean {
184+
return (SECRET_SCOPE_TYPES as readonly string[]).includes(scopeType.type);
185+
}
186+
178187
export type SimpleScopeTypeType = (typeof simpleScopeTypeTypes)[number];
179188

180189
export interface SimpleScopeType {

packages/cursorless-engine/src/CustomSpokenForms.ts

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@ import {
33
Disposer,
44
FileSystem,
55
Notifier,
6+
showError,
67
} from "@cursorless/common";
7-
import { homedir } from "os";
8-
import * as path from "path";
9-
import { getSpokenFormEntries } from "./scopeProviders/getSpokenFormEntries";
10-
import { SpokenFormMap } from "./SpokenFormMap";
11-
import { defaultSpokenFormMap } from "./DefaultSpokenFormMap";
12-
13-
export const spokenFormsPath = path.join(
14-
homedir(),
15-
".cursorless",
16-
"spokenForms.json",
17-
);
8+
import { isEqual } from "lodash";
9+
import {
10+
defaultSpokenFormInfo,
11+
defaultSpokenFormMap,
12+
} from "./DefaultSpokenFormMap";
13+
import {
14+
SpokenFormMap,
15+
SpokenFormMapEntry,
16+
SpokenFormType,
17+
} from "./SpokenFormMap";
18+
import {
19+
SpokenFormEntry,
20+
getSpokenFormEntries,
21+
spokenFormsPath,
22+
} from "./scopeProviders/getSpokenFormEntries";
23+
import { ide } from "./singletons/ide.singleton";
24+
import { dirname } from "node:path";
1825

1926
const ENTRY_TYPES = [
2027
"simpleScopeTypeType",
@@ -41,6 +48,15 @@ export class CustomSpokenForms implements SpokenFormMap {
4148
modifierExtra = defaultSpokenFormMap.modifierExtra;
4249

4350
private isInitialized_ = false;
51+
private needsInitialTalonUpdate_: boolean | undefined;
52+
53+
/**
54+
* If `true`, indicates they need to update their Talon files to get the
55+
* machinery used to share spoken forms from Talon to the VSCode extension.
56+
*/
57+
get needsInitialTalonUpdate() {
58+
return this.needsInitialTalonUpdate_;
59+
}
4460

4561
/**
4662
* Whether the custom spoken forms have been initialized. If `false`, the
@@ -53,7 +69,9 @@ export class CustomSpokenForms implements SpokenFormMap {
5369

5470
constructor(fileSystem: FileSystem) {
5571
this.disposer.push(
56-
fileSystem.watch(spokenFormsPath, () => this.updateSpokenFormMaps()),
72+
fileSystem.watch(dirname(spokenFormsPath), () =>
73+
this.updateSpokenFormMaps(),
74+
),
5775
);
5876

5977
this.updateSpokenFormMaps();
@@ -67,7 +85,30 @@ export class CustomSpokenForms implements SpokenFormMap {
6785
onDidChangeCustomSpokenForms = this.notifier.registerListener;
6886

6987
private async updateSpokenFormMaps(): Promise<void> {
70-
const entries = await getSpokenFormEntries();
88+
let entries: SpokenFormEntry[];
89+
try {
90+
entries = await getSpokenFormEntries();
91+
} catch (err) {
92+
if ((err as any)?.code === "ENOENT") {
93+
// Handle case where spokenForms.json doesn't exist yet
94+
console.log(
95+
`Custom spoken forms file not found at ${spokenFormsPath}. Using default spoken forms.`,
96+
);
97+
this.needsInitialTalonUpdate_ = true;
98+
this.notifier.notifyListeners();
99+
} else {
100+
console.error("Error loading custom spoken forms", err);
101+
showError(
102+
ide().messages,
103+
"CustomSpokenForms.updateSpokenFormMaps",
104+
`Error loading custom spoken forms: ${
105+
(err as Error).message
106+
}}}. Falling back to default spoken forms.`,
107+
);
108+
}
109+
110+
return;
111+
}
71112

72113
for (const entryType of ENTRY_TYPES) {
73114
// TODO: Handle case where we've added a new scope type but they haven't yet
@@ -76,10 +117,45 @@ export class CustomSpokenForms implements SpokenFormMap {
76117
// be able to speak it. We could just detect that there's no entry for it in
77118
// the spoken forms file, but that feels a bit brittle.
78119
// FIXME: How to avoid the type assertion?
79-
this[entryType] = Object.fromEntries(
120+
const entry = Object.fromEntries(
80121
entries
81122
.filter((entry) => entry.type === entryType)
82123
.map(({ id, spokenForms }) => [id, spokenForms]),
124+
);
125+
126+
this[entryType] = Object.fromEntries(
127+
Object.entries(defaultSpokenFormInfo[entryType]).map(
128+
([key, { defaultSpokenForms, isSecret }]): [
129+
SpokenFormType,
130+
SpokenFormMapEntry,
131+
] => {
132+
const customSpokenForms = entry[key];
133+
if (customSpokenForms != null) {
134+
return [
135+
key as SpokenFormType,
136+
{
137+
defaultSpokenForms,
138+
spokenForms: customSpokenForms,
139+
requiresTalonUpdate: false,
140+
isCustom: isEqual(defaultSpokenForms, customSpokenForms),
141+
isSecret,
142+
},
143+
];
144+
} else {
145+
return [
146+
key as SpokenFormType,
147+
{
148+
defaultSpokenForms,
149+
spokenForms: [],
150+
// If it's not a secret spoken form, then it's a new scope type
151+
requiresTalonUpdate: !isSecret,
152+
isCustom: false,
153+
isSecret,
154+
},
155+
];
156+
}
157+
},
158+
),
83159
) as any;
84160
}
85161

packages/cursorless-engine/src/DefaultSpokenFormMap.ts

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { mapValues } from "lodash";
2-
import { SpokenFormMap, SpokenFormMapKeyTypes } from "./SpokenFormMap";
2+
import {
3+
SpokenFormMap,
4+
SpokenFormMapEntry,
5+
SpokenFormMapKeyTypes,
6+
} from "./SpokenFormMap";
37

4-
type DefaultSpokenFormMap = {
8+
type DefaultSpokenFormMapDefinition = {
59
readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
6-
Record<SpokenFormMapKeyTypes[K], string | null>
10+
Record<SpokenFormMapKeyTypes[K], string | DefaultSpokenFormMapEntry>
711
>;
812
};
913

10-
const defaultSpokenFormMapCore: DefaultSpokenFormMap = {
14+
const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = {
1115
pairedDelimiter: {
1216
curlyBrackets: "curly",
1317
angleBrackets: "diamond",
@@ -45,15 +49,14 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = {
4549
name: "name",
4650
regularExpression: "regex",
4751
section: "section",
48-
sectionLevelOne: "one section",
49-
sectionLevelTwo: "two section",
50-
sectionLevelThree: "three section",
51-
sectionLevelFour: "four section",
52-
sectionLevelFive: "five section",
53-
sectionLevelSix: "six section",
52+
sectionLevelOne: disabledByDefault("one section"),
53+
sectionLevelTwo: disabledByDefault("two section"),
54+
sectionLevelThree: disabledByDefault("three section"),
55+
sectionLevelFour: disabledByDefault("four section"),
56+
sectionLevelFive: disabledByDefault("five section"),
57+
sectionLevelSix: disabledByDefault("six section"),
5458
selector: "selector",
5559
statement: "state",
56-
string: "string",
5760
branch: "branch",
5861
type: "type",
5962
value: "value",
@@ -88,7 +91,8 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = {
8891
url: "link",
8992
notebookCell: "cell",
9093

91-
switchStatementSubject: null,
94+
string: secret("parse tree string"),
95+
switchStatementSubject: secret("subject"),
9296
},
9397

9498
surroundingPairForceDirection: {
@@ -124,10 +128,67 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = {
124128
customRegex: {},
125129
};
126130

127-
// TODO: Don't cast here; need to make our own mapValues with stronger typing
131+
function disabledByDefault(
132+
...spokenForms: string[]
133+
): DefaultSpokenFormMapEntry {
134+
return {
135+
defaultSpokenForms: spokenForms,
136+
isDisabledByDefault: true,
137+
isSecret: false,
138+
};
139+
}
140+
141+
function secret(...spokenForms: string[]): DefaultSpokenFormMapEntry {
142+
return {
143+
defaultSpokenForms: spokenForms,
144+
isDisabledByDefault: true,
145+
isSecret: true,
146+
};
147+
}
148+
149+
interface DefaultSpokenFormMapEntry {
150+
defaultSpokenForms: string[];
151+
isDisabledByDefault: boolean;
152+
isSecret: boolean;
153+
}
154+
155+
export type DefaultSpokenFormMap = {
156+
readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
157+
Record<SpokenFormMapKeyTypes[K], DefaultSpokenFormMapEntry>
158+
>;
159+
};
160+
161+
// FIXME: Don't cast here; need to make our own mapValues with stronger typing
128162
// using tricks from our object.d.ts
129-
export const defaultSpokenFormMap = mapValues(
163+
export const defaultSpokenFormInfo = mapValues(
130164
defaultSpokenFormMapCore,
131165
(entry) =>
132-
mapValues(entry, (subEntry) => (subEntry == null ? [] : [subEntry])),
166+
mapValues(entry, (subEntry) =>
167+
typeof subEntry === "string"
168+
? {
169+
defaultSpokenForms: [subEntry],
170+
isDisabledByDefault: false,
171+
isSecret: false,
172+
}
173+
: subEntry,
174+
),
175+
) as DefaultSpokenFormMap;
176+
177+
// FIXME: Don't cast here; need to make our own mapValues with stronger typing
178+
// using tricks from our object.d.ts
179+
export const defaultSpokenFormMap = mapValues(defaultSpokenFormInfo, (entry) =>
180+
mapValues(
181+
entry,
182+
({
183+
defaultSpokenForms,
184+
isDisabledByDefault,
185+
isSecret,
186+
}): SpokenFormMapEntry => ({
187+
spokenForms: isDisabledByDefault ? [] : defaultSpokenForms,
188+
isCustom: false,
189+
defaultSpokenForms,
190+
requiresTalonUpdate: false,
191+
isSecret,
192+
}),
193+
),
133194
) as SpokenFormMap;

packages/cursorless-engine/src/SpokenFormMap.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,16 @@ export interface SpokenFormMapKeyTypes {
3737

3838
export type SpokenFormType = keyof SpokenFormMapKeyTypes;
3939

40+
export interface SpokenFormMapEntry {
41+
spokenForms: string[];
42+
isCustom: boolean;
43+
defaultSpokenForms: string[];
44+
requiresTalonUpdate: boolean;
45+
isSecret: boolean;
46+
}
47+
4048
export type SpokenFormMap = {
4149
readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
42-
Record<SpokenFormMapKeyTypes[K], string[]>
50+
Record<SpokenFormMapKeyTypes[K], SpokenFormMapEntry>
4351
>;
4452
};

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: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { HatTokenMapImpl } from "./core/HatTokenMapImpl";
1313
import { Snippets } from "./core/Snippets";
1414
import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape";
1515
import { RangeUpdater } from "./core/updateSelections/RangeUpdater";
16-
import { CustomSpokenFormGenerator } from "./generateSpokenForm/CustomSpokenFormGenerator";
16+
import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl";
1717
import { LanguageDefinitions } from "./languages/LanguageDefinitions";
1818
import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl";
1919
import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers";
@@ -56,7 +56,7 @@ export function createCursorlessEngine(
5656

5757
const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter);
5858

59-
const customSpokenFormGenerator = new CustomSpokenFormGenerator(fileSystem);
59+
const customSpokenFormGenerator = new CustomSpokenFormGeneratorImpl(fileSystem);
6060

6161
ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug);
6262

@@ -97,6 +97,7 @@ export function createCursorlessEngine(
9797
storedTargets,
9898
customSpokenFormGenerator,
9999
),
100+
customSpokenFormGenerator,
100101
testCaseRecorder,
101102
storedTargets,
102103
hatTokenMap,
@@ -110,7 +111,7 @@ export function createCursorlessEngine(
110111
function createScopeProvider(
111112
languageDefinitions: LanguageDefinitions,
112113
storedTargets: StoredTargetMap,
113-
customSpokenFormGenerator: CustomSpokenFormGenerator,
114+
customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
114115
): ScopeProvider {
115116
const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions);
116117

packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts renamed to packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import {
66
ScopeType,
77
} from "@cursorless/common";
88
import { SpokenFormGenerator } from ".";
9+
import { CustomSpokenFormGenerator } from "..";
910
import { CustomSpokenForms } from "../CustomSpokenForms";
1011

11-
export class CustomSpokenFormGenerator {
12+
export class CustomSpokenFormGeneratorImpl
13+
implements CustomSpokenFormGenerator
14+
{
1215
private customSpokenForms: CustomSpokenForms;
1316
private spokenFormGenerator: SpokenFormGenerator;
1417
private disposer = new Disposer();
@@ -41,5 +44,9 @@ export class CustomSpokenFormGenerator {
4144
return this.customSpokenForms.getCustomRegexScopeTypes();
4245
}
4346

47+
get needsInitialTalonUpdate() {
48+
return this.customSpokenForms.needsInitialTalonUpdate;
49+
}
50+
4451
dispose = this.disposer.dispose;
4552
}

0 commit comments

Comments
 (0)