Skip to content

Commit

Permalink
Get custom spoken forms from Talon
Browse files Browse the repository at this point in the history
  • Loading branch information
pokey committed Oct 16, 2023
1 parent 91b22f2 commit 6a6d06a
Show file tree
Hide file tree
Showing 14 changed files with 417 additions and 9 deletions.
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
}
});
}
}
167 changes: 167 additions & 0 deletions packages/cursorless-engine/src/CustomSpokenForms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import {
CustomRegexScopeType,
Disposer,
Notifier,
showError,
} from "@cursorless/common";
import { isEqual } from "lodash";
import {
DefaultSpokenFormMapEntry,
defaultSpokenFormInfo,
defaultSpokenFormMap,
} from "./DefaultSpokenFormMap";
import {
SpokenFormMap,
SpokenFormMapEntry,
SpokenFormType,
} from "./SpokenFormMap";
import {
NeedsInitialTalonUpdateError,
SpokenFormEntry,
TalonSpokenForms,
} from "./scopeProviders/SpokenFormEntry";
import { ide } from "./singletons/ide.singleton";

const ENTRY_TYPES = [
"simpleScopeTypeType",
"customRegex",
"pairedDelimiter",
] as const;

type Writable<T> = {
-readonly [K in keyof T]: T[K];
};

/**
* Maintains a list of all scope types and notifies listeners when it changes.
*/
export class CustomSpokenForms {
private disposer = new Disposer();
private notifier = new Notifier();

private spokenFormMap_: Writable<SpokenFormMap> = { ...defaultSpokenFormMap };

get spokenFormMap(): SpokenFormMap {
return this.spokenFormMap_;
}

private customSpokenFormsInitialized_ = false;
private needsInitialTalonUpdate_: boolean | undefined;

/**
* 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.
*/
get needsInitialTalonUpdate() {
return this.needsInitialTalonUpdate_;
}

/**
* Whether the custom spoken forms have been initialized. If `false`, the
* default spoken forms are currently being used while the custom spoken forms
* are being loaded.
*/
get customSpokenFormsInitialized() {
return this.customSpokenFormsInitialized_;
}

constructor(private talonSpokenForms: TalonSpokenForms) {
this.disposer.push(
talonSpokenForms.onDidChange(() => this.updateSpokenFormMaps()),
);

this.updateSpokenFormMaps();
}

/**
* Registers a callback to be run when the custom 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
*/
onDidChangeCustomSpokenForms = this.notifier.registerListener;

private async updateSpokenFormMaps(): Promise<void> {
let entries: SpokenFormEntry[];
try {
entries = await this.talonSpokenForms.getSpokenFormEntries();
} catch (err) {
if (err instanceof NeedsInitialTalonUpdateError) {
// Handle case where spokenForms.json doesn't exist yet
this.needsInitialTalonUpdate_ = true;
} else {
console.error("Error loading custom spoken forms", err);
showError(
ide().messages,
"CustomSpokenForms.updateSpokenFormMaps",
`Error loading custom spoken forms: ${
(err as Error).message
}}}. Falling back to default spoken forms.`,
);
}

this.spokenFormMap_ = { ...defaultSpokenFormMap };
this.customSpokenFormsInitialized_ = false;
this.notifier.notifyListeners();

return;
}

for (const entryType of ENTRY_TYPES) {
// FIXME: How to avoid the type assertion?
const entry = Object.fromEntries(
entries
.filter((entry) => entry.type === entryType)
.map(({ id, spokenForms }) => [id, spokenForms]),
);

const defaultEntry: Partial<Record<string, DefaultSpokenFormMapEntry>> =
defaultSpokenFormInfo[entryType];
const ids = Array.from(
new Set([...Object.keys(defaultEntry), ...Object.keys(entry)]),
);
this.spokenFormMap_[entryType] = Object.fromEntries(
ids.map((id): [SpokenFormType, SpokenFormMapEntry] => {
const { defaultSpokenForms = [], isSecret = false } =
defaultEntry[id] ?? {};
const customSpokenForms = entry[id];
if (customSpokenForms != null) {
return [
id as SpokenFormType,
{
defaultSpokenForms,
spokenForms: customSpokenForms,
requiresTalonUpdate: false,
isCustom: isEqual(defaultSpokenForms, customSpokenForms),
isSecret,
},
];
} else {
return [
id as SpokenFormType,
{
defaultSpokenForms,
spokenForms: [],
// If it's not a secret spoken form, then it's a new scope type
requiresTalonUpdate: !isSecret,
isCustom: false,
isSecret,
},
];
}
}),
) as any;
}

this.customSpokenFormsInitialized_ = true;
this.notifier.notifyListeners();
}

getCustomRegexScopeTypes(): CustomRegexScopeType[] {
return Object.keys(this.spokenFormMap_.customRegex).map((regex) => ({
type: "customRegex",
regex,
}));
}

dispose = this.disposer.dispose;
}
14 changes: 13 additions & 1 deletion packages/cursorless-engine/src/api/CursorlessEngineApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,31 @@ import { Command, HatTokenMap, IDE } from "@cursorless/common";
import { Snippets } from "../core/Snippets";
import { StoredTargetMap } from "../core/StoredTargets";
import { TestCaseRecorder } from "../testCaseRecorder/TestCaseRecorder";
import { ScopeProvider } from "./ScopeProvider";
import { ScopeProvider } from "@cursorless/common";

Check failure on line 5 in packages/cursorless-engine/src/api/CursorlessEngineApi.ts

View workflow job for this annotation

GitHub Actions / test-docs-build

Module '"@cursorless/common"' has no exported member 'ScopeProvider'.

Check failure on line 5 in packages/cursorless-engine/src/api/CursorlessEngineApi.ts

View workflow job for this annotation

GitHub Actions / Test (macos-latest, stable)

Module '"@cursorless/common"' has no exported member 'ScopeProvider'.

Check failure on line 5 in packages/cursorless-engine/src/api/CursorlessEngineApi.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest, stable)

Module '"@cursorless/common"' has no exported member 'ScopeProvider'.

Check failure on line 5 in packages/cursorless-engine/src/api/CursorlessEngineApi.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest, stable)

Module '"@cursorless/common"' has no exported member 'ScopeProvider'.

Check failure on line 5 in packages/cursorless-engine/src/api/CursorlessEngineApi.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest, legacy)

Module '"@cursorless/common"' has no exported member '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
11 changes: 10 additions & 1 deletion 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 "./scopeProviders/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 {

Check failure on line 66 in packages/cursorless-engine/src/cursorlessEngine.ts

View workflow job for this annotation

GitHub Actions / test-docs-build

Property 'scopeProvider' is missing in type '{ commandApi: { runCommand(command: Command): Promise<unknown>; runCommandSafe(...args: unknown[]): Promise<unknown>; }; customSpokenFormGenerator: CustomSpokenFormGeneratorImpl; ... 6 more ...; runIntegrationTests: () => Promise<...>; }' but required in type 'CursorlessEngine'.

Check failure on line 66 in packages/cursorless-engine/src/cursorlessEngine.ts

View workflow job for this annotation

GitHub Actions / Test (macos-latest, stable)

Property 'scopeProvider' is missing in type '{ commandApi: { runCommand(command: Command): Promise<unknown>; runCommandSafe(...args: unknown[]): Promise<unknown>; }; customSpokenFormGenerator: CustomSpokenFormGeneratorImpl; ... 6 more ...; runIntegrationTests: () => Promise<...>; }' but required in type 'CursorlessEngine'.

Check failure on line 66 in packages/cursorless-engine/src/cursorlessEngine.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest, stable)

Property 'scopeProvider' is missing in type '{ commandApi: { runCommand(command: Command): Promise<unknown>; runCommandSafe(...args: unknown[]): Promise<unknown>; }; customSpokenFormGenerator: CustomSpokenFormGeneratorImpl; ... 6 more ...; runIntegrationTests: () => Promise<...>; }' but required in type 'CursorlessEngine'.

Check failure on line 66 in packages/cursorless-engine/src/cursorlessEngine.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest, stable)

Property 'scopeProvider' is missing in type '{ commandApi: { runCommand(command: Command): Promise<unknown>; runCommandSafe(...args: unknown[]): Promise<unknown>; }; customSpokenFormGenerator: CustomSpokenFormGeneratorImpl; ... 6 more ...; runIntegrationTests: () => Promise<...>; }' but required in type 'CursorlessEngine'.

Check failure on line 66 in packages/cursorless-engine/src/cursorlessEngine.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest, legacy)

Property 'scopeProvider' is missing in type '{ commandApi: { runCommand(command: Command): Promise<unknown>; runCommandSafe(...args: unknown[]): Promise<unknown>; }; customSpokenFormGenerator: CustomSpokenFormGeneratorImpl; ... 6 more ...; runIntegrationTests: () => Promise<...>; }' but required in type 'CursorlessEngine'.
Expand Down Expand Up @@ -85,11 +93,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,54 @@
import {
CommandComplete,
Disposer,
Listener,
ScopeType,
} from "@cursorless/common";
import { SpokenFormGenerator } from ".";
import { CustomSpokenFormGenerator } from "..";
import { CustomSpokenForms } from "../CustomSpokenForms";
import { TalonSpokenForms } from "../scopeProviders/SpokenFormEntry";

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.command(command);
}

scopeTypeToSpokenForm(scopeType: ScopeType) {
return this.spokenFormGenerator.scopeType(scopeType);
}

getCustomRegexScopeTypes() {
return this.customSpokenForms.getCustomRegexScopeTypes();
}

get needsInitialTalonUpdate() {
return this.customSpokenForms.needsInitialTalonUpdate;
}

dispose = this.disposer.dispose;
}
37 changes: 37 additions & 0 deletions packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Notifier, SimpleScopeTypeType } from "@cursorless/common";
import { SpeakableSurroundingPairName } from "../SpokenFormMap";

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

0 comments on commit 6a6d06a

Please sign in to comment.