diff --git a/.vscode/launch.json b/.vscode/launch.json
index 0be1e310e7..59b9b6e82e 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -156,6 +156,23 @@
"!**/node_modules/**"
]
},
+ {
+ "name": "Update fixtures, unit tests only",
+ "type": "node",
+ "request": "launch",
+ "program": "${workspaceFolder}/packages/test-harness/out/scripts/runUnitTestsOnly",
+ "env": {
+ "CURSORLESS_TEST": "true",
+ "CURSORLESS_TEST_UPDATE_FIXTURES": "true",
+ "CURSORLESS_REPO_ROOT": "${workspaceFolder}"
+ },
+ "outFiles": ["${workspaceFolder}/**/out/**/*.js"],
+ "preLaunchTask": "${defaultBuildTask}",
+ "resolveSourceMapLocations": [
+ "${workspaceFolder}/**",
+ "!**/node_modules/**"
+ ]
+ },
{
"name": "Docusaurus start",
"type": "node",
diff --git a/cursorless-talon/src/apps/cursorless_vscode.py b/cursorless-talon/src/apps/cursorless_vscode.py
index 7fda2ac797..01451bb5e5 100644
--- a/cursorless-talon/src/apps/cursorless_vscode.py
+++ b/cursorless-talon/src/apps/cursorless_vscode.py
@@ -32,3 +32,9 @@ def private_cursorless_show_settings_in_ide():
)
actions.sleep("250ms")
actions.insert("cursorless")
+
+ def private_cursorless_show_sidebar():
+ """Show Cursorless sidebar"""
+ actions.user.private_cursorless_run_rpc_command_and_wait(
+ "workbench.view.extension.cursorless"
+ )
diff --git a/cursorless-talon/src/csv_overrides.py b/cursorless-talon/src/csv_overrides.py
index 4c51b7c3a4..aebb9cf762 100644
--- a/cursorless-talon/src/csv_overrides.py
+++ b/cursorless-talon/src/csv_overrides.py
@@ -1,8 +1,9 @@
import csv
from collections.abc import Container
+from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
-from typing import Optional
+from typing import Callable, Optional, TypedDict
from talon import Context, Module, actions, app, fs
@@ -25,49 +26,73 @@
desc="The directory to use for cursorless settings csvs relative to talon user directory",
)
-default_ctx = Context()
-default_ctx.matches = r"""
+# The global context we use for our lists
+ctx = Context()
+
+# A context that contains default vocabulary, for use in testing
+normalized_ctx = Context()
+normalized_ctx.matches = r"""
tag: user.cursorless_default_vocabulary
"""
+# Maps from Talon list name to a map from spoken form to value
+ListToSpokenForms = dict[str, dict[str, str]]
+
+
+@dataclass
+class SpokenFormEntry:
+ list_name: str
+ id: str
+ spoken_forms: list[str]
+
+
def init_csv_and_watch_changes(
filename: str,
- default_values: dict[str, dict[str, str]],
+ default_values: ListToSpokenForms,
+ handle_new_values: Optional[Callable[[list[SpokenFormEntry]], None]] = None,
extra_ignored_values: Optional[list[str]] = None,
allow_unknown_values: bool = False,
default_list_name: Optional[str] = None,
headers: list[str] = [SPOKEN_FORM_HEADER, CURSORLESS_IDENTIFIER_HEADER],
- ctx: Context = Context(),
no_update_file: bool = False,
- pluralize_lists: Optional[list[str]] = [],
+ pluralize_lists: list[str] = [],
):
"""
Initialize a cursorless settings csv, creating it if necessary, and watch
for changes to the csv. Talon lists will be generated based on the keys of
`default_values`. For example, if there is a key `foo`, there will be a
- list created called `user.cursorless_foo` that will contain entries from
- the original dict at the key `foo`, updated according to customization in
- the csv at
+ list created called `user.cursorless_foo` that will contain entries from the
+ original dict at the key `foo`, updated according to customization in the
+ csv at
- actions.path.talon_user() / "cursorless-settings" / filename
+ ```
+ actions.path.talon_user() / "cursorless-settings" / filename
+ ```
Note that the settings directory location can be customized using the
`user.cursorless_settings_directory` setting.
Args:
filename (str): The name of the csv file to be placed in
- `cursorles-settings` dir
- default_values (dict[str, dict]): The default values for the lists to
- be customized in the given csv
- extra_ignored_values list[str]: Don't throw an exception if any of
- these appear as values; just ignore them and don't add them to any list
- allow_unknown_values bool: If unknown values appear, just put them in the list
- default_list_name Optional[str]: If unknown values are allowed, put any
- unknown values in this list
- no_update_file Optional[bool]: Set this to `TRUE` to indicate that we should
- not update the csv. This is used generally in case there was an issue coming up with the default set of values so we don't want to persist those to disk
- pluralize_lists: Create plural version of given lists
+ `cursorles-settings` dir
+ default_values (ListToSpokenForms): The default values for the lists to
+ be customized in the given csv
+ handle_new_values (Optional[Callable[[list[SpokenFormEntry]], None]]): A
+ callback to be called when the lists are updated
+ extra_ignored_values (Optional[list[str]]): Don't throw an exception if
+ any of these appear as values; just ignore them and don't add them
+ to any list
+ allow_unknown_values (bool): If unknown values appear, just put them in
+ the list
+ default_list_name (Optional[str]): If unknown values are
+ allowed, put any unknown values in this list
+ headers (list[str]): The headers to use for the csv
+ no_update_file (bool): Set this to `True` to indicate that we should not
+ update the csv. This is used generally in case there was an issue
+ coming up with the default set of values so we don't want to persist
+ those to disk
+ pluralize_lists (list[str]): Create plural version of given lists
"""
if extra_ignored_values is None:
extra_ignored_values = []
@@ -96,7 +121,7 @@ def on_watch(path, flags):
allow_unknown_values,
default_list_name,
pluralize_lists,
- ctx,
+ handle_new_values,
)
fs.watch(str(file_path.parent), on_watch)
@@ -117,7 +142,7 @@ def on_watch(path, flags):
allow_unknown_values,
default_list_name,
pluralize_lists,
- ctx,
+ handle_new_values,
)
else:
if not no_update_file:
@@ -129,7 +154,7 @@ def on_watch(path, flags):
allow_unknown_values,
default_list_name,
pluralize_lists,
- ctx,
+ handle_new_values,
)
def unsubscribe():
@@ -165,22 +190,22 @@ def create_default_vocabulary_dicts(
if active_key:
updated_dict[active_key] = value2
default_values_updated[key] = updated_dict
- assign_lists_to_context(default_ctx, default_values_updated, pluralize_lists)
+ assign_lists_to_context(normalized_ctx, default_values_updated, pluralize_lists)
def update_dicts(
- default_values: dict[str, dict],
- current_values: dict,
+ default_values: ListToSpokenForms,
+ current_values: dict[str, str],
extra_ignored_values: list[str],
allow_unknown_values: bool,
default_list_name: Optional[str],
pluralize_lists: list[str],
- ctx: Context,
+ handle_new_values: Optional[Callable[[list[SpokenFormEntry]], None]],
):
# Create map with all default values
- results_map = {}
- for list_name, dict in default_values.items():
- for key, value in dict.items():
+ results_map: dict[str, ResultsListEntry] = {}
+ for list_name, obj in default_values.items():
+ for key, value in obj.items():
results_map[value] = {"key": key, "value": value, "list": list_name}
# Update result with current values
@@ -190,7 +215,7 @@ def update_dicts(
except KeyError:
if value in extra_ignored_values:
pass
- elif allow_unknown_values:
+ elif allow_unknown_values and default_list_name is not None:
results_map[value] = {
"key": key,
"value": value,
@@ -201,9 +226,35 @@ def update_dicts(
# Convert result map back to result list
results = {res["list"]: {} for res in results_map.values()}
- for obj in results_map.values():
+ values: list[SpokenFormEntry] = []
+ for list_name, id, spoken_forms in generate_spoken_forms(
+ list(results_map.values())
+ ):
+ for spoken_form in spoken_forms:
+ results[list_name][spoken_form] = id
+ values.append(
+ SpokenFormEntry(list_name=list_name, id=id, spoken_forms=spoken_forms)
+ )
+
+ # Assign result to talon context list
+ assign_lists_to_context(ctx, results, pluralize_lists)
+
+ if handle_new_values is not None:
+ handle_new_values(values)
+
+
+class ResultsListEntry(TypedDict):
+ key: str
+ value: str
+ list: str
+
+
+def generate_spoken_forms(results_list: list[ResultsListEntry]):
+ for obj in results_list:
value = obj["value"]
key = obj["key"]
+
+ spoken = []
if not is_removed(key):
for k in key.split("|"):
if value == "pasteFromClipboard" and k.endswith(" to"):
@@ -214,10 +265,13 @@ def update_dicts(
# cursorless before this change would have "paste to" as
# their spoken form and so would need to say "paste to to".
k = k[:-3]
- results[obj["list"]][k.strip()] = value
+ spoken.append(k.strip())
- # Assign result to talon context list
- assign_lists_to_context(ctx, results, pluralize_lists)
+ yield (
+ obj["list"],
+ value,
+ spoken,
+ )
def assign_lists_to_context(
@@ -386,7 +440,7 @@ def get_full_path(filename: str):
return (settings_directory / filename).resolve()
-def get_super_values(values: dict[str, dict[str, str]]):
+def get_super_values(values: ListToSpokenForms):
result: dict[str, str] = {}
for value_dict in values.values():
result.update(value_dict)
diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py
index 86147fb1eb..d64791219d 100644
--- a/cursorless-talon/src/cursorless.py
+++ b/cursorless-talon/src/cursorless.py
@@ -12,3 +12,6 @@
class Actions:
def private_cursorless_show_settings_in_ide():
"""Show Cursorless-specific settings in ide"""
+
+ def private_cursorless_show_sidebar():
+ """Show Cursorless-specific settings in ide"""
diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon
index 9b784f684c..d2fa383a64 100644
--- a/cursorless-talon/src/cursorless.talon
+++ b/cursorless-talon/src/cursorless.talon
@@ -40,3 +40,6 @@ tag: user.cursorless
{user.cursorless_homophone} settings:
user.private_cursorless_show_settings_in_ide()
+
+bar {user.cursorless_homophone}:
+ user.private_cursorless_show_sidebar()
diff --git a/cursorless-talon/src/marks/decorated_mark.py b/cursorless-talon/src/marks/decorated_mark.py
index 75675ee895..2eaa338f52 100644
--- a/cursorless-talon/src/marks/decorated_mark.py
+++ b/cursorless-talon/src/marks/decorated_mark.py
@@ -138,7 +138,7 @@ def setup_hat_styles_csv(hat_colors: dict[str, str], hat_shapes: dict[str, str])
"hat_color": active_hat_colors,
"hat_shape": active_hat_shapes,
},
- [*hat_colors.values(), *hat_shapes.values()],
+ extra_ignored_values=[*hat_colors.values(), *hat_shapes.values()],
no_update_file=is_shape_error or is_color_error,
)
diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py
index 16d53205f5..be71e2ae72 100644
--- a/cursorless-talon/src/spoken_forms.py
+++ b/cursorless-talon/src/spoken_forms.py
@@ -4,27 +4,30 @@
from talon import app, fs
-from .csv_overrides import SPOKEN_FORM_HEADER, init_csv_and_watch_changes
+from .csv_overrides import (
+ SPOKEN_FORM_HEADER,
+ ListToSpokenForms,
+ SpokenFormEntry,
+ init_csv_and_watch_changes,
+)
from .marks.decorated_mark import init_hats
+from .spoken_forms_output import SpokenFormsOutput
JSON_FILE = Path(__file__).parent / "spoken_forms.json"
disposables: list[Callable] = []
-def watch_file(spoken_forms: dict, filename: str) -> Callable:
- return init_csv_and_watch_changes(
- filename,
- spoken_forms[filename],
- )
-
-
P = ParamSpec("P")
R = TypeVar("R")
def auto_construct_defaults(
- spoken_forms: dict[str, dict[str, dict[str, str]]],
- f: Callable[Concatenate[str, dict[str, dict[str, str]], P], R],
+ spoken_forms: dict[str, ListToSpokenForms],
+ handle_new_values: Callable[[str, list[SpokenFormEntry]], None],
+ f: Callable[
+ Concatenate[str, ListToSpokenForms, Callable[[list[SpokenFormEntry]], None], P],
+ R,
+ ],
):
"""
Decorator that automatically constructs the default values for the
@@ -37,17 +40,38 @@ def auto_construct_defaults(
of `init_csv_and_watch_changes` to remove the `default_values` parameter.
Args:
- spoken_forms (dict[str, dict[str, dict[str, str]]]): The spoken forms
- f (Callable[Concatenate[str, dict[str, dict[str, str]], P], R]): Will always be `init_csv_and_watch_changes`
+ spoken_forms (dict[str, ListToSpokenForms]): The spoken forms
+ handle_new_values (Callable[[ListToSpokenForms], None]): A callback to be called when the lists are updated
+ f (Callable[Concatenate[str, ListToSpokenForms, P], R]): Will always be `init_csv_and_watch_changes`
"""
def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R:
default_values = spoken_forms[filename]
- return f(filename, default_values, *args, **kwargs)
+ return f(
+ filename,
+ default_values,
+ lambda new_values: handle_new_values(filename, new_values),
+ *args,
+ **kwargs,
+ )
return ret
+# Maps from Talon list name to the type of the value in that list, e.g.
+# `pairedDelimiter` or `simpleScopeTypeType`
+# FIXME: This is a hack until we generate spoken_forms.json from Typescript side
+# At that point we can just include its type as part of that file
+LIST_TO_TYPE_MAP = {
+ "wrapper_selectable_paired_delimiter": "pairedDelimiter",
+ "selectable_only_paired_delimiter": "pairedDelimiter",
+ "wrapper_only_paired_delimiter": "pairedDelimiter",
+ "surrounding_pair_scope_type": "pairedDelimiter",
+ "scope_type": "simpleScopeTypeType",
+ "custom_regex_scope_type": "customRegex",
+}
+
+
def update():
global disposables
@@ -57,7 +81,35 @@ def update():
with open(JSON_FILE, encoding="utf-8") as file:
spoken_forms = json.load(file)
- handle_csv = auto_construct_defaults(spoken_forms, init_csv_and_watch_changes)
+ initialized = False
+ custom_spoken_forms: dict[str, list[SpokenFormEntry]] = {}
+ spoken_forms_output = SpokenFormsOutput()
+ spoken_forms_output.init()
+
+ def update_spoken_forms_output():
+ spoken_forms_output.write(
+ [
+ {
+ "type": LIST_TO_TYPE_MAP[entry.list_name],
+ "id": entry.id,
+ "spokenForms": entry.spoken_forms,
+ }
+ for spoken_form_list in custom_spoken_forms.values()
+ for entry in spoken_form_list
+ if entry.list_name in LIST_TO_TYPE_MAP
+ ]
+ )
+
+ def handle_new_values(csv_name: str, values: list[SpokenFormEntry]):
+ custom_spoken_forms[csv_name] = values
+ if initialized:
+ # On first run, we just do one update at the end, so we suppress
+ # writing until we get there
+ update_spoken_forms_output()
+
+ handle_csv = auto_construct_defaults(
+ spoken_forms, handle_new_values, init_csv_and_watch_changes
+ )
disposables = [
handle_csv("actions.csv"),
@@ -107,6 +159,9 @@ def update():
),
]
+ update_spoken_forms_output()
+ initialized = True
+
def on_watch(path, flags):
if JSON_FILE.match(path):
diff --git a/cursorless-talon/src/spoken_forms_output.py b/cursorless-talon/src/spoken_forms_output.py
new file mode 100644
index 0000000000..3eaa97cf1e
--- /dev/null
+++ b/cursorless-talon/src/spoken_forms_output.py
@@ -0,0 +1,46 @@
+import json
+from pathlib import Path
+from typing import TypedDict
+
+from talon import app
+
+from .command import CursorlessCommand
+
+SPOKEN_FORMS_OUTPUT_PATH = Path.home() / ".cursorless" / "spokenForms.json"
+
+
+class SpokenFormEntry(TypedDict):
+ type: str
+ id: str
+ spokenForms: list[str]
+
+
+class SpokenFormsOutput:
+ """
+ Writes spoken forms to a json file for use by the Cursorless vscode extension
+ """
+
+ def init(self):
+ try:
+ SPOKEN_FORMS_OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
+ except Exception:
+ error_message = (
+ f"Error creating spoken form dir {SPOKEN_FORMS_OUTPUT_PATH.parent}"
+ )
+ print(error_message)
+ app.notify(error_message)
+
+ def write(self, spoken_forms: list[SpokenFormEntry]):
+ with open(SPOKEN_FORMS_OUTPUT_PATH, "w") as out:
+ try:
+ out.write(
+ json.dumps(
+ {"version": CursorlessCommand.version, "entries": spoken_forms}
+ )
+ )
+ except Exception:
+ error_message = (
+ f"Error writing spoken form json {SPOKEN_FORMS_OUTPUT_PATH}"
+ )
+ print(error_message)
+ app.notify(error_message)
diff --git a/images/icon.svg b/images/icon.svg
new file mode 100644
index 0000000000..e7fb1c351d
--- /dev/null
+++ b/images/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts
index f1c69de9f5..fb9b0fc7de 100644
--- a/packages/common/src/cursorlessCommandIds.ts
+++ b/packages/common/src/cursorlessCommandIds.ts
@@ -69,6 +69,12 @@ export const cursorlessCommandDescriptions: Record<
"Resume test case recording",
),
["cursorless.showDocumentation"]: new VisibleCommand("Show documentation"),
+ ["cursorless.showScopeVisualizer"]: new VisibleCommand(
+ "Show the scope visualizer",
+ ),
+ ["cursorless.hideScopeVisualizer"]: new VisibleCommand(
+ "Hide the scope visualizer",
+ ),
["cursorless.command"]: new HiddenCommand("The core cursorless command"),
["cursorless.showQuickPick"]: new HiddenCommand(
@@ -110,10 +116,4 @@ export const cursorlessCommandDescriptions: Record<
["cursorless.keyboard.modal.modeToggle"]: new HiddenCommand(
"Toggle the cursorless modal mode",
),
- ["cursorless.showScopeVisualizer"]: new HiddenCommand(
- "Show the scope visualizer",
- ),
- ["cursorless.hideScopeVisualizer"]: new HiddenCommand(
- "Hide the scope visualizer",
- ),
};
diff --git a/packages/common/src/ide/normalized/NormalizedIDE.ts b/packages/common/src/ide/normalized/NormalizedIDE.ts
index 30b1dccd58..a1961416bb 100644
--- a/packages/common/src/ide/normalized/NormalizedIDE.ts
+++ b/packages/common/src/ide/normalized/NormalizedIDE.ts
@@ -52,6 +52,7 @@ export class NormalizedIDE extends PassthroughIDEBase {
),
snippetsDir: getFixturePath("cursorless-snippets"),
});
+ this.configuration.mockConfiguration("decorationDebounceDelayMs", 0);
}
flashRanges(flashDescriptors: FlashDescriptor[]): Promise {
diff --git a/packages/common/src/ide/types/FileSystem.types.ts b/packages/common/src/ide/types/FileSystem.types.ts
index 15818b3633..5d4c382c09 100644
--- a/packages/common/src/ide/types/FileSystem.types.ts
+++ b/packages/common/src/ide/types/FileSystem.types.ts
@@ -9,5 +9,5 @@ export interface FileSystem {
* @param onDidChange A function to call on changes
* @returns A disposable to cancel the watcher
*/
- watchDir(path: string, onDidChange: PathChangeListener): Disposable;
+ watch(path: string, onDidChange: PathChangeListener): Disposable;
}
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index f42a47ee7e..caf991772f 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -11,6 +11,8 @@ 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";
export type { TokenHatSplittingMode } from "./ide/types/Configuration";
@@ -42,6 +44,8 @@ export * from "./types/TextEditorOptions";
export * from "./types/TextLine";
export * from "./types/Token";
export * from "./types/HatTokenMap";
+export * from "./types/ScopeProvider";
+export * from "./types/SpokenForm";
export * from "./util/textFormatters";
export * from "./types/snippet.types";
export * from "./testUtil/fromPlainObject";
diff --git a/packages/cursorless-engine/src/api/ScopeProvider.ts b/packages/common/src/types/ScopeProvider.ts
similarity index 72%
rename from packages/cursorless-engine/src/api/ScopeProvider.ts
rename to packages/common/src/types/ScopeProvider.ts
index 40d4bd6916..71ebb99613 100644
--- a/packages/cursorless-engine/src/api/ScopeProvider.ts
+++ b/packages/common/src/types/ScopeProvider.ts
@@ -3,8 +3,9 @@ import {
GeneralizedRange,
Range,
ScopeType,
+ SpokenForm,
TextEditor,
-} from "@cursorless/common";
+} from "..";
export interface ScopeProvider {
/**
@@ -75,6 +76,34 @@ export interface ScopeProvider {
editor: TextEditor,
scopeType: ScopeType,
) => ScopeSupport;
+
+ /**
+ * Registers a callback to be run when the scope support changes for the active
+ * editor. The callback will be run immediately once with the current support
+ * levels for the active editor.
+ * @param callback The callback to run when the scope support changes
+ * @returns A {@link Disposable} which will stop the callback from running
+ */
+ onDidChangeScopeSupport: (callback: ScopeSupportEventCallback) => Disposable;
+
+ /**
+ * Registers a callback to be run when the scope support changes for the active
+ * editor. The callback will be run immediately once with the current support
+ * levels for the active editor.
+ * @param callback The callback to run when the scope support changes
+ * @returns A {@link Disposable} which will stop the callback from running
+ */
+ onDidChangeScopeInfo(callback: ScopeTypeInfoEventCallback): Disposable;
+
+ /**
+ * Determine the level of support for the iteration scope of {@link scopeType}
+ * in {@link editor}, as determined by its language id.
+ * @param editor The editor to check
+ * @param scopeType The scope type to check
+ * @returns The level of support for the iteration scope of {@link scopeType}
+ * in {@link editor}
+ */
+ getScopeInfo: (scopeType: ScopeType) => ScopeTypeInfo;
}
interface ScopeRangeConfigBase {
@@ -108,6 +137,26 @@ export type IterationScopeChangeEventCallback = (
scopeRanges: IterationScopeRanges[],
) => void;
+export interface ScopeSupportInfo extends ScopeTypeInfo {
+ support: ScopeSupport;
+ iterationScopeSupport: ScopeSupport;
+}
+
+export type ScopeSupportLevels = ScopeSupportInfo[];
+
+export type ScopeSupportEventCallback = (
+ supportLevels: ScopeSupportLevels,
+) => void;
+
+export interface ScopeTypeInfo {
+ scopeType: ScopeType;
+ spokenForm: SpokenForm;
+ humanReadableName: string;
+ isLanguageSpecific: boolean;
+}
+
+export type ScopeTypeInfoEventCallback = (scopeInfos: ScopeTypeInfo[]) => void;
+
/**
* Contains the ranges that define a given scope, eg its {@link domain} and the
* ranges for its {@link targets}.
diff --git a/packages/common/src/types/SpokenForm.ts b/packages/common/src/types/SpokenForm.ts
new file mode 100644
index 0000000000..5170af44e1
--- /dev/null
+++ b/packages/common/src/types/SpokenForm.ts
@@ -0,0 +1,14 @@
+export interface SpokenFormSuccess {
+ type: "success";
+ preferred: string;
+ alternatives: string[];
+}
+
+export interface SpokenFormError {
+ type: "error";
+ reason: string;
+ requiresTalonUpdate: boolean;
+ isSecret: boolean;
+}
+
+export type SpokenForm = SpokenFormSuccess | SpokenFormError;
diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts
index 799aa47e85..ff4cccceff 100644
--- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts
+++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts
@@ -75,88 +75,116 @@ export type PartialMark =
| RangeMark
| ExplicitMark;
+export const simpleSurroundingPairNames = [
+ "angleBrackets",
+ "backtickQuotes",
+ "curlyBrackets",
+ "doubleQuotes",
+ "escapedDoubleQuotes",
+ "escapedParentheses",
+ "escapedSquareBrackets",
+ "escapedSingleQuotes",
+ "parentheses",
+ "singleQuotes",
+ "squareBrackets",
+] as const;
+export const complexSurroundingPairNames = [
+ "string",
+ "any",
+ "collectionBoundary",
+] as const;
+export const surroundingPairNames = [
+ ...simpleSurroundingPairNames,
+ ...complexSurroundingPairNames,
+];
export type SimpleSurroundingPairName =
- | "angleBrackets"
- | "backtickQuotes"
- | "curlyBrackets"
- | "doubleQuotes"
- | "escapedDoubleQuotes"
- | "escapedParentheses"
- | "escapedSquareBrackets"
- | "escapedSingleQuotes"
- | "parentheses"
- | "singleQuotes"
- | "squareBrackets";
+ (typeof simpleSurroundingPairNames)[number];
export type ComplexSurroundingPairName =
- | "string"
- | "any"
- | "collectionBoundary";
+ (typeof complexSurroundingPairNames)[number];
export type SurroundingPairName =
| SimpleSurroundingPairName
| ComplexSurroundingPairName;
-export type SimpleScopeTypeType =
- | "argumentOrParameter"
- | "anonymousFunction"
- | "attribute"
- | "branch"
- | "class"
- | "className"
- | "collectionItem"
- | "collectionKey"
- | "comment"
- | "functionCall"
- | "functionCallee"
- | "functionName"
- | "ifStatement"
- | "instance"
- | "list"
- | "map"
- | "name"
- | "namedFunction"
- | "regularExpression"
- | "statement"
- | "string"
- | "type"
- | "value"
- | "condition"
- | "section"
- | "sectionLevelOne"
- | "sectionLevelTwo"
- | "sectionLevelThree"
- | "sectionLevelFour"
- | "sectionLevelFive"
- | "sectionLevelSix"
- | "selector"
- | "switchStatementSubject"
- | "unit"
- | "xmlBothTags"
- | "xmlElement"
- | "xmlEndTag"
- | "xmlStartTag"
- | "notebookCell"
+export const simpleScopeTypeTypes = [
+ "argumentOrParameter",
+ "anonymousFunction",
+ "attribute",
+ "branch",
+ "class",
+ "className",
+ "collectionItem",
+ "collectionKey",
+ "comment",
+ "functionCall",
+ "functionCallee",
+ "functionName",
+ "ifStatement",
+ "instance",
+ "list",
+ "map",
+ "name",
+ "namedFunction",
+ "regularExpression",
+ "statement",
+ "string",
+ "type",
+ "value",
+ "condition",
+ "section",
+ "sectionLevelOne",
+ "sectionLevelTwo",
+ "sectionLevelThree",
+ "sectionLevelFour",
+ "sectionLevelFive",
+ "sectionLevelSix",
+ "selector",
+ "switchStatementSubject",
+ "unit",
+ "xmlBothTags",
+ "xmlElement",
+ "xmlEndTag",
+ "xmlStartTag",
// Latex scope types
- | "part"
- | "chapter"
- | "subSection"
- | "subSubSection"
- | "namedParagraph"
- | "subParagraph"
- | "environment"
+ "part",
+ "chapter",
+ "subSection",
+ "subSubSection",
+ "namedParagraph",
+ "subParagraph",
+ "environment",
// Text based scopes
- | "character"
- | "word"
- | "token"
- | "identifier"
- | "line"
- | "sentence"
- | "paragraph"
- | "document"
- | "nonWhitespaceSequence"
- | "boundedNonWhitespaceSequence"
- | "url"
+ "character",
+ "word",
+ "token",
+ "identifier",
+ "line",
+ "sentence",
+ "paragraph",
+ "document",
+ "nonWhitespaceSequence",
+ "boundedNonWhitespaceSequence",
+ "url",
+ "notebookCell",
// Talon
- | "command";
+ "command",
+] as const;
+
+export function isSimpleScopeType(
+ scopeType: ScopeType,
+): scopeType is SimpleScopeType {
+ return (simpleScopeTypeTypes as readonly string[]).includes(scopeType.type);
+}
+
+const SECRET_SCOPE_TYPES = [
+ "string",
+ "switchStatementSubject",
+] as const satisfies readonly SimpleScopeTypeType[];
+
+export function isSecretScopeType(scopeType: ScopeType): boolean {
+ return (SECRET_SCOPE_TYPES as readonly string[]).includes(scopeType.type);
+}
+
+export type SimpleScopeTypeType = (typeof simpleScopeTypeTypes)[number];
export interface SimpleScopeType {
type: SimpleScopeTypeType;
diff --git a/packages/common/src/util/Disposer.ts b/packages/common/src/util/Disposer.ts
new file mode 100644
index 0000000000..cf300b746d
--- /dev/null
+++ b/packages/common/src/util/Disposer.ts
@@ -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
+ }
+ });
+ }
+}
diff --git a/packages/common/src/util/camelCaseToAllDown.ts b/packages/common/src/util/camelCaseToAllDown.ts
new file mode 100644
index 0000000000..bb21c5e8d6
--- /dev/null
+++ b/packages/common/src/util/camelCaseToAllDown.ts
@@ -0,0 +1,7 @@
+export function camelCaseToAllDown(input: string): string {
+ return input
+ .replace(/([A-Z])/g, " $1")
+ .split(" ")
+ .map((word) => word.toLowerCase())
+ .join(" ");
+}
diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts
new file mode 100644
index 0000000000..00ca71953f
--- /dev/null
+++ b/packages/cursorless-engine/src/CustomSpokenForms.ts
@@ -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 = {
+ -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 = { ...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 {
+ 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> =
+ 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;
+}
diff --git a/packages/cursorless-engine/src/DefaultSpokenFormMap.ts b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts
new file mode 100644
index 0000000000..340abbd0cc
--- /dev/null
+++ b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts
@@ -0,0 +1,194 @@
+import { mapValues } from "lodash";
+import {
+ SpokenFormMap,
+ SpokenFormMapEntry,
+ SpokenFormMapKeyTypes,
+} from "./SpokenFormMap";
+
+type DefaultSpokenFormMapDefinition = {
+ readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
+ Record
+ >;
+};
+
+const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = {
+ pairedDelimiter: {
+ curlyBrackets: "curly",
+ angleBrackets: "diamond",
+ escapedDoubleQuotes: "escaped quad",
+ escapedSingleQuotes: "escaped twin",
+ escapedParentheses: "escaped round",
+ escapedSquareBrackets: "escaped box",
+ doubleQuotes: "quad",
+ parentheses: "round",
+ backtickQuotes: "skis",
+ squareBrackets: "box",
+ singleQuotes: "twin",
+ any: "pair",
+ string: "string",
+ whitespace: "void",
+ },
+
+ simpleScopeTypeType: {
+ argumentOrParameter: "arg",
+ attribute: "attribute",
+ functionCall: "call",
+ functionCallee: "callee",
+ className: "class name",
+ class: "class",
+ comment: "comment",
+ functionName: "funk name",
+ namedFunction: "funk",
+ ifStatement: "if state",
+ instance: "instance",
+ collectionItem: "item",
+ collectionKey: "key",
+ anonymousFunction: "lambda",
+ list: "list",
+ map: "map",
+ name: "name",
+ regularExpression: "regex",
+ section: "section",
+ sectionLevelOne: disabledByDefault("one section"),
+ sectionLevelTwo: disabledByDefault("two section"),
+ sectionLevelThree: disabledByDefault("three section"),
+ sectionLevelFour: disabledByDefault("four section"),
+ sectionLevelFive: disabledByDefault("five section"),
+ sectionLevelSix: disabledByDefault("six section"),
+ selector: "selector",
+ statement: "state",
+ branch: "branch",
+ type: "type",
+ value: "value",
+ condition: "condition",
+ unit: "unit",
+ // XML, JSX
+ xmlElement: "element",
+ xmlBothTags: "tags",
+ xmlStartTag: "start tag",
+ xmlEndTag: "end tag",
+ // LaTeX
+ part: "part",
+ chapter: "chapter",
+ subSection: "subsection",
+ subSubSection: "subsubsection",
+ namedParagraph: "paragraph",
+ subParagraph: "subparagraph",
+ environment: "environment",
+ // Talon
+ command: "command",
+ // Text-based scope types
+ character: "char",
+ word: "word",
+ token: "token",
+ identifier: "identifier",
+ line: "line",
+ sentence: "sentence",
+ paragraph: "block",
+ document: "file",
+ nonWhitespaceSequence: "paint",
+ boundedNonWhitespaceSequence: "short paint",
+ url: "link",
+ notebookCell: "cell",
+
+ string: secret("parse tree string"),
+ switchStatementSubject: secret("subject"),
+ },
+
+ surroundingPairForceDirection: {
+ left: "left",
+ right: "right",
+ },
+
+ simpleModifier: {
+ excludeInterior: "bounds",
+ toRawSelection: "just",
+ leading: "leading",
+ trailing: "trailing",
+ keepContentFilter: "content",
+ keepEmptyFilter: "empty",
+ inferPreviousMark: "its",
+ startOf: "start of",
+ endOf: "end of",
+ interiorOnly: "inside",
+ extendThroughStartOf: "head",
+ extendThroughEndOf: "tail",
+ everyScope: "every",
+ },
+
+ modifierExtra: {
+ first: "first",
+ last: "last",
+ previous: "previous",
+ next: "next",
+ forward: "forward",
+ backward: "backward",
+ },
+
+ customRegex: {},
+};
+
+function disabledByDefault(
+ ...spokenForms: string[]
+): DefaultSpokenFormMapEntry {
+ return {
+ defaultSpokenForms: spokenForms,
+ isDisabledByDefault: true,
+ isSecret: false,
+ };
+}
+
+function secret(...spokenForms: string[]): DefaultSpokenFormMapEntry {
+ return {
+ defaultSpokenForms: spokenForms,
+ isDisabledByDefault: true,
+ isSecret: true,
+ };
+}
+
+export interface DefaultSpokenFormMapEntry {
+ defaultSpokenForms: string[];
+ isDisabledByDefault: boolean;
+ isSecret: boolean;
+}
+
+export type DefaultSpokenFormMap = {
+ readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
+ Record
+ >;
+};
+
+// FIXME: Don't cast here; need to make our own mapValues with stronger typing
+// using tricks from our object.d.ts
+export const defaultSpokenFormInfo = mapValues(
+ defaultSpokenFormMapCore,
+ (entry) =>
+ mapValues(entry, (subEntry) =>
+ typeof subEntry === "string"
+ ? {
+ defaultSpokenForms: [subEntry],
+ isDisabledByDefault: false,
+ isSecret: false,
+ }
+ : subEntry,
+ ),
+) as DefaultSpokenFormMap;
+
+// FIXME: Don't cast here; need to make our own mapValues with stronger typing
+// using tricks from our object.d.ts
+export const defaultSpokenFormMap = mapValues(defaultSpokenFormInfo, (entry) =>
+ mapValues(
+ entry,
+ ({
+ defaultSpokenForms,
+ isDisabledByDefault,
+ isSecret,
+ }): SpokenFormMapEntry => ({
+ spokenForms: isDisabledByDefault ? [] : defaultSpokenForms,
+ isCustom: false,
+ defaultSpokenForms,
+ requiresTalonUpdate: false,
+ isSecret,
+ }),
+ ),
+) as SpokenFormMap;
diff --git a/packages/cursorless-engine/src/SpokenFormMap.ts b/packages/cursorless-engine/src/SpokenFormMap.ts
new file mode 100755
index 0000000000..4d4a4e691c
--- /dev/null
+++ b/packages/cursorless-engine/src/SpokenFormMap.ts
@@ -0,0 +1,52 @@
+import {
+ ModifierType,
+ SimpleScopeTypeType,
+ SurroundingPairName,
+} from "@cursorless/common";
+
+export type SpeakableSurroundingPairName =
+ | Exclude
+ | "whitespace";
+
+export type SimpleModifierType = Exclude<
+ ModifierType,
+ | "containingScope"
+ | "ordinalScope"
+ | "relativeScope"
+ | "modifyIfUntyped"
+ | "cascading"
+ | "range"
+>;
+
+export type ModifierExtra =
+ | "first"
+ | "last"
+ | "previous"
+ | "next"
+ | "forward"
+ | "backward";
+
+export interface SpokenFormMapKeyTypes {
+ pairedDelimiter: SpeakableSurroundingPairName;
+ simpleScopeTypeType: SimpleScopeTypeType;
+ surroundingPairForceDirection: "left" | "right";
+ simpleModifier: SimpleModifierType;
+ modifierExtra: ModifierExtra;
+ customRegex: string;
+}
+
+export type SpokenFormType = keyof SpokenFormMapKeyTypes;
+
+export interface SpokenFormMapEntry {
+ spokenForms: string[];
+ isCustom: boolean;
+ defaultSpokenForms: string[];
+ requiresTalonUpdate: boolean;
+ isSecret: boolean;
+}
+
+export type SpokenFormMap = {
+ readonly [K in keyof SpokenFormMapKeyTypes]: Readonly<
+ Record
+ >;
+};
diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts
index 4dd323bf27..a25e4dfea7 100644
--- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts
+++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts
@@ -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";
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;
}
+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.
diff --git a/packages/cursorless-engine/src/core/Debouncer.ts b/packages/cursorless-engine/src/core/Debouncer.ts
index 48c498839c..2510ef315c 100644
--- a/packages/cursorless-engine/src/core/Debouncer.ts
+++ b/packages/cursorless-engine/src/core/Debouncer.ts
@@ -10,6 +10,7 @@ export class Debouncer {
constructor(
/** The callback to debounce */
private callback: () => void,
+ private debounceDelayMs?: number,
) {
this.run = this.run.bind(this);
}
@@ -19,9 +20,9 @@ export class Debouncer {
clearTimeout(this.timeoutHandle);
}
- const decorationDebounceDelayMs = ide().configuration.getOwnConfiguration(
- "decorationDebounceDelayMs",
- );
+ const decorationDebounceDelayMs =
+ this.debounceDelayMs ??
+ ide().configuration.getOwnConfiguration("decorationDebounceDelayMs");
this.timeoutHandle = setTimeout(() => {
this.callback();
diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts
index b45fbc993e..2b5a86b294 100644
--- a/packages/cursorless-engine/src/cursorlessEngine.ts
+++ b/packages/cursorless-engine/src/cursorlessEngine.ts
@@ -4,24 +4,28 @@ import {
FileSystem,
Hats,
IDE,
+ ScopeProvider,
} from "@cursorless/common";
import { StoredTargetMap, TestCaseRecorder, TreeSitter } from ".";
import { CursorlessEngine } from "./api/CursorlessEngineApi";
-import { ScopeProvider } from "./api/ScopeProvider";
-import { ScopeRangeProvider } from "./ScopeVisualizer/ScopeRangeProvider";
-import { ScopeSupportChecker } from "./ScopeVisualizer/ScopeSupportChecker";
import { Debug } from "./core/Debug";
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 { ScopeInfoProvider } from "./scopeProviders/ScopeInfoProvider";
+import { ScopeRangeProvider } from "./scopeProviders/ScopeRangeProvider";
+import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher";
+import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker";
+import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher";
+import { TalonSpokenFormsJsonReader } from "./scopeProviders/TalonSpokenFormsJsonReader";
import { injectIde } from "./singletons/ide.singleton";
-import { ScopeRangeWatcher } from "./ScopeVisualizer/ScopeRangeWatcher";
export function createCursorlessEngine(
treeSitter: TreeSitter,
@@ -53,8 +57,16 @@ 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);
+ console.log("createCursorlessEngine");
+
return {
commandApi: {
runCommand(command: Command) {
@@ -85,11 +97,17 @@ export function createCursorlessEngine(
);
},
},
- scopeProvider: createScopeProvider(languageDefinitions, storedTargets),
+ scopeProvider: createScopeProvider(
+ languageDefinitions,
+ storedTargets,
+ customSpokenFormGenerator,
+ ),
+ customSpokenFormGenerator,
testCaseRecorder,
storedTargets,
hatTokenMap,
snippets,
+ spokenFormsJsonPath: talonSpokenForms.spokenFormsPath,
injectIde,
runIntegrationTests: () =>
runIntegrationTests(treeSitter, languageDefinitions),
@@ -99,6 +117,7 @@ export function createCursorlessEngine(
function createScopeProvider(
languageDefinitions: LanguageDefinitions,
storedTargets: StoredTargetMap,
+ customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
): ScopeProvider {
const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions);
@@ -116,6 +135,12 @@ function createScopeProvider(
rangeProvider,
);
const supportChecker = new ScopeSupportChecker(scopeHandlerFactory);
+ const infoProvider = new ScopeInfoProvider(customSpokenFormGenerator);
+ const supportWatcher = new ScopeSupportWatcher(
+ languageDefinitions,
+ supportChecker,
+ infoProvider,
+ );
return {
provideScopeRanges: rangeProvider.provideScopeRanges,
@@ -125,5 +150,8 @@ function createScopeProvider(
rangeWatcher.onDidChangeIterationScopeRanges,
getScopeSupport: supportChecker.getScopeSupport,
getIterationScopeSupport: supportChecker.getIterationScopeSupport,
+ onDidChangeScopeSupport: supportWatcher.onDidChangeScopeSupport,
+ getScopeInfo: infoProvider.getScopeTypeInfo,
+ onDidChangeScopeInfo: infoProvider.onDidChangeScopeInfo,
};
}
diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts
new file mode 100644
index 0000000000..5aa0aeb339
--- /dev/null
+++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts
@@ -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;
+}
diff --git a/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts b/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts
new file mode 100644
index 0000000000..a6b85117ef
--- /dev/null
+++ b/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts
@@ -0,0 +1,48 @@
+import {
+ SpokenFormMap,
+ SpokenFormMapEntry,
+ SpokenFormMapKeyTypes,
+ SpokenFormType,
+} from "../SpokenFormMap";
+
+export type GeneratorSpokenFormMap = {
+ readonly [K in keyof SpokenFormMapKeyTypes]: Record<
+ SpokenFormMapKeyTypes[K],
+ SingleTermSpokenForm
+ >;
+};
+
+export interface SingleTermSpokenForm {
+ type: "singleTerm";
+ spokenForms: SpokenFormMapEntry;
+ spokenFormType: SpokenFormType;
+ id: string;
+}
+
+export type SpokenFormComponent =
+ | SingleTermSpokenForm
+ | string
+ | SpokenFormComponent[];
+
+export function getGeneratorSpokenForms(
+ spokenFormMap: SpokenFormMap,
+): GeneratorSpokenFormMap {
+ // FIXME: Don't cast here; need to make our own mapValues with stronger typing
+ // using tricks from our object.d.ts
+ return Object.fromEntries(
+ Object.entries(spokenFormMap).map(([spokenFormType, map]) => [
+ spokenFormType,
+ Object.fromEntries(
+ Object.entries(map).map(([id, spokenForms]) => [
+ id,
+ {
+ type: "singleTerm",
+ spokenForms,
+ spokenFormType,
+ id,
+ },
+ ]),
+ ),
+ ]),
+ ) as GeneratorSpokenFormMap;
+}
diff --git a/packages/cursorless-engine/src/generateSpokenForm/NoSpokenFormError.ts b/packages/cursorless-engine/src/generateSpokenForm/NoSpokenFormError.ts
index d7d00eaad8..30e055026d 100644
--- a/packages/cursorless-engine/src/generateSpokenForm/NoSpokenFormError.ts
+++ b/packages/cursorless-engine/src/generateSpokenForm/NoSpokenFormError.ts
@@ -1,5 +1,9 @@
export class NoSpokenFormError extends Error {
- constructor(public reason: string) {
+ constructor(
+ public reason: string,
+ public requiresTalonUpdate: boolean = false,
+ public isSecret: boolean = false,
+ ) {
super(`No spoken form for: ${reason}`);
}
}
diff --git a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts
index 88852cc14c..2b632ce328 100644
--- a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts
+++ b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts
@@ -1,135 +1,12 @@
+import { CompositeKeyMap } from "@cursorless/common";
+import { SpeakableSurroundingPairName } from "../../SpokenFormMap";
import {
- ModifierType,
- SimpleScopeTypeType,
- SurroundingPairName,
- CompositeKeyMap,
-} from "@cursorless/common";
-
-export const modifiers = {
- excludeInterior: "bounds",
- toRawSelection: "just",
- leading: "leading",
- trailing: "trailing",
- keepContentFilter: "content",
- keepEmptyFilter: "empty",
- inferPreviousMark: "its",
- startOf: "start of",
- endOf: "end of",
- interiorOnly: "inside",
- extendThroughStartOf: "head",
- extendThroughEndOf: "tail",
- everyScope: "every",
-
- containingScope: null,
- ordinalScope: null,
- relativeScope: null,
- modifyIfUntyped: null,
- cascading: null,
- range: null,
-} as const satisfies Record;
-
-export const modifiersExtra = {
- first: "first",
- last: "last",
- previous: "previous",
- next: "next",
- forward: "forward",
- backward: "backward",
-};
-
-export const scopeSpokenForms = {
- argumentOrParameter: "arg",
- attribute: "attribute",
- functionCall: "call",
- functionCallee: "callee",
- className: "class name",
- class: "class",
- comment: "comment",
- functionName: "funk name",
- namedFunction: "funk",
- ifStatement: "if state",
- instance: "instance",
- collectionItem: "item",
- collectionKey: "key",
- anonymousFunction: "lambda",
- list: "list",
- map: "map",
- name: "name",
- regularExpression: "regex",
- section: "section",
- sectionLevelOne: "one section",
- sectionLevelTwo: "two section",
- sectionLevelThree: "three section",
- sectionLevelFour: "four section",
- sectionLevelFive: "five section",
- sectionLevelSix: "six section",
- selector: "selector",
- statement: "state",
- string: "string",
- branch: "branch",
- type: "type",
- value: "value",
- condition: "condition",
- unit: "unit",
- // XML, JSX
- xmlElement: "element",
- xmlBothTags: "tags",
- xmlStartTag: "start tag",
- xmlEndTag: "end tag",
- // LaTeX
- part: "part",
- chapter: "chapter",
- subSection: "subsection",
- subSubSection: "subsubsection",
- namedParagraph: "paragraph",
- subParagraph: "subparagraph",
- environment: "environment",
- // Talon
- command: "command",
- // Text-based scope types
- character: "char",
- word: "word",
- token: "token",
- identifier: "identifier",
- line: "line",
- sentence: "sentence",
- paragraph: "block",
- document: "file",
- nonWhitespaceSequence: "paint",
- boundedNonWhitespaceSequence: "short paint",
- url: "link",
- notebookCell: "cell",
-
- switchStatementSubject: null,
-} as const satisfies Record;
-
-type ExtendedSurroundingPairName = SurroundingPairName | "whitespace";
-
-const surroundingPairsSpoken: Record<
- ExtendedSurroundingPairName,
- string | null
-> = {
- curlyBrackets: "curly",
- angleBrackets: "diamond",
- escapedDoubleQuotes: "escaped quad",
- escapedSingleQuotes: "escaped twin",
- escapedParentheses: "escaped round",
- escapedSquareBrackets: "escaped box",
- doubleQuotes: "quad",
- parentheses: "round",
- backtickQuotes: "skis",
- squareBrackets: "box",
- singleQuotes: "twin",
- any: "pair",
- string: "string",
- whitespace: "void",
-
- // Used internally by the "item" scope type
- collectionBoundary: null,
-};
+ GeneratorSpokenFormMap,
+ SingleTermSpokenForm,
+} from "../GeneratorSpokenFormMap";
const surroundingPairsDelimiters: Record<
- ExtendedSurroundingPairName,
+ SpeakableSurroundingPairName,
[string, string] | null
> = {
curlyBrackets: ["{", "}"],
@@ -147,38 +24,20 @@ const surroundingPairsDelimiters: Record<
any: null,
string: null,
- collectionBoundary: null,
};
+
const surroundingPairDelimiterToName = new CompositeKeyMap<
[string, string],
- SurroundingPairName
+ SpeakableSurroundingPairName
>((pair) => pair);
for (const [name, pair] of Object.entries(surroundingPairsDelimiters)) {
if (pair != null) {
- surroundingPairDelimiterToName.set(pair, name as SurroundingPairName);
- }
-}
-
-export const surroundingPairForceDirections = {
- left: "left",
- right: "right",
-};
-
-/**
- * Given a pair name (eg `parentheses`), returns the spoken form of the
- * surrounding pair.
- * @param surroundingPair The name of the surrounding pair
- * @returns The spoken form of the surrounding pair
- */
-export function surroundingPairNameToSpokenForm(
- surroundingPair: SurroundingPairName,
-): string {
- const result = surroundingPairsSpoken[surroundingPair];
- if (result == null) {
- throw Error(`Unknown surrounding pair '${surroundingPair}'`);
+ surroundingPairDelimiterToName.set(
+ pair,
+ name as SpeakableSurroundingPairName,
+ );
}
- return result;
}
/**
@@ -190,12 +49,13 @@ export function surroundingPairNameToSpokenForm(
* @returns The spoken form of the surrounding pair
*/
export function surroundingPairDelimitersToSpokenForm(
+ spokenFormMap: GeneratorSpokenFormMap,
left: string,
right: string,
-): string {
+): SingleTermSpokenForm {
const pairName = surroundingPairDelimiterToName.get([left, right]);
if (pairName == null) {
throw Error(`Unknown surrounding pair delimiters '${left} ${right}'`);
}
- return surroundingPairNameToSpokenForm(pairName);
+ return spokenFormMap.pairedDelimiter[pairName];
}
diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts
index 6e8e2d31f4..fc063c51ad 100644
--- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts
+++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts
@@ -8,36 +8,77 @@ import * as yaml from "js-yaml";
import * as assert from "node:assert";
import { promises as fsp } from "node:fs";
import { canonicalizeAndValidateCommand } from "../core/commandVersionUpgrades/canonicalizeAndValidateCommand";
-import { generateSpokenForm } from "./generateSpokenForm";
import { getHatMapCommand } from "./getHatMapCommand";
+import { SpokenFormGenerator } from ".";
+import { defaultSpokenFormInfo } from "../DefaultSpokenFormMap";
+import { mapValues } from "lodash";
+import { SpokenFormMap, SpokenFormMapEntry } from "../SpokenFormMap";
+
+const spokenFormMap = mapValues(defaultSpokenFormInfo, (entry) =>
+ mapValues(
+ entry,
+ ({ defaultSpokenForms }): SpokenFormMapEntry => ({
+ spokenForms: defaultSpokenForms,
+ isCustom: false,
+ defaultSpokenForms,
+ requiresTalonUpdate: false,
+ isSecret: false,
+ }),
+ ),
+) as SpokenFormMap;
suite("Generate spoken forms", () => {
getRecordedTestPaths().forEach(({ name, path }) =>
test(name, () => runTest(path)),
);
+
+ test("generate spoken form for custom regex", () => {
+ const generator = new SpokenFormGenerator({
+ ...spokenFormMap,
+ customRegex: {
+ foo: {
+ spokenForms: ["bar"],
+ isCustom: false,
+ defaultSpokenForms: ["bar"],
+ requiresTalonUpdate: false,
+ isSecret: false,
+ },
+ },
+ });
+
+ const spokenForm = generator.scopeType({
+ type: "customRegex",
+ regex: "foo",
+ });
+
+ assert(spokenForm.type === "success");
+ assert.equal(spokenForm.preferred, "bar");
+ });
});
async function runTest(file: string) {
const buffer = await fsp.readFile(file);
const fixture = yaml.load(buffer.toString()) as TestCaseFixtureLegacy;
- const generatedSpokenForm = generateSpokenForm(
+ const generator = new SpokenFormGenerator(spokenFormMap);
+
+ const generatedSpokenForm = generator.command(
canonicalizeAndValidateCommand(fixture.command),
);
if (fixture.marksToCheck != null && generatedSpokenForm.type === "success") {
// If the test has marks to check (eg a hat token map test), it will end in
// "take " as a way to indicate which mark to check
- const hatMapSpokenForm = generateSpokenForm(
+ const hatMapSpokenForm = generator.command(
getHatMapCommand(fixture.marksToCheck),
);
assert(hatMapSpokenForm.type === "success");
- generatedSpokenForm.value += " " + hatMapSpokenForm.value;
+ generatedSpokenForm.preferred += " " + hatMapSpokenForm.preferred;
}
if (shouldUpdateFixtures()) {
if (generatedSpokenForm.type === "success") {
- fixture.command.spokenForm = generatedSpokenForm.value;
+ fixture.command.spokenForm = generatedSpokenForm.preferred;
fixture.spokenFormError = undefined;
} else {
fixture.spokenFormError = generatedSpokenForm.reason;
@@ -47,7 +88,7 @@ async function runTest(file: string) {
await fsp.writeFile(file, serializeTestFixture(fixture));
} else {
if (generatedSpokenForm.type === "success") {
- assert.equal(fixture.command.spokenForm, generatedSpokenForm.value);
+ assert.equal(fixture.command.spokenForm, generatedSpokenForm.preferred);
assert.equal(fixture.spokenFormError, undefined);
} else {
assert.equal(fixture.spokenFormError, generatedSpokenForm.reason);
diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts
index 34f5dda028..9d5bea37b8 100644
--- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts
+++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts
@@ -4,8 +4,9 @@ import {
DestinationDescriptor,
InsertionMode,
PartialTargetDescriptor,
+ ScopeType,
+ camelCaseToAllDown,
} from "@cursorless/common";
-import { RecursiveArray, flattenDeep } from "lodash";
import { NoSpokenFormError } from "./NoSpokenFormError";
import { actions } from "./defaultSpokenForms/actions";
import { connectives } from "./defaultSpokenForms/connectives";
@@ -15,191 +16,274 @@ import {
wrapperSnippetToSpokenForm,
} from "./defaultSpokenForms/snippets";
import { getRangeConnective } from "./getRangeConnective";
-import { primitiveTargetToSpokenForm } from "./primitiveTargetToSpokenForm";
+import { SpokenFormMap } from "../SpokenFormMap";
+import { PrimitiveTargetSpokenFormGenerator } from "./primitiveTargetToSpokenForm";
+import {
+ GeneratorSpokenFormMap,
+ SpokenFormComponent,
+ getGeneratorSpokenForms,
+} from "./GeneratorSpokenFormMap";
+import { SpokenForm } from "@cursorless/common";
-export interface SpokenFormSuccess {
- type: "success";
- value: string;
-}
+export class SpokenFormGenerator {
+ private primitiveGenerator: PrimitiveTargetSpokenFormGenerator;
+ private spokenFormMap: GeneratorSpokenFormMap;
-export interface SpokenFormError {
- type: "error";
- reason: string;
-}
+ constructor(spokenFormMap: SpokenFormMap) {
+ this.spokenFormMap = getGeneratorSpokenForms(spokenFormMap);
-export type SpokenForm = SpokenFormSuccess | SpokenFormError;
+ this.primitiveGenerator = new PrimitiveTargetSpokenFormGenerator(
+ this.spokenFormMap,
+ );
+ }
-/**
- * Given a command, generates its spoken form.
- * @param command The command to generate a spoken form for
- * @returns The spoken form of the command, or null if the command has no spoken
- * form
- */
-export function generateSpokenForm(command: CommandComplete): SpokenForm {
- try {
- const components = generateSpokenFormComponents(command.action);
- return { type: "success", value: flattenDeep(components).join(" ") };
- } catch (e) {
- if (e instanceof NoSpokenFormError) {
- return { type: "error", reason: e.reason };
- }
+ /**
+ * Given a command, generates its spoken form.
+ * @param command The command to generate a spoken form for
+ * @returns The spoken form of the command, or null if the command has no spoken
+ * form
+ */
+ command(command: CommandComplete): SpokenForm {
+ return this.componentsToSpokenForm(() => this.handleAction(command.action));
+ }
- throw e;
+ /**
+ * Given a command, generates its spoken form.
+ * @param command The command to generate a spoken form for
+ * @returns The spoken form of the command, or null if the command has no spoken
+ * form
+ */
+ scopeType(scopeType: ScopeType): SpokenForm {
+ return this.componentsToSpokenForm(() => [
+ this.primitiveGenerator.handleScopeType(scopeType),
+ ]);
}
-}
-function generateSpokenFormComponents(
- action: ActionDescriptor,
-): RecursiveArray {
- switch (action.name) {
- case "editNew":
- case "getText":
- case "replace":
- case "executeCommand":
- case "private.getTargets":
- throw new NoSpokenFormError(`Action '${action.name}'`);
-
- case "replaceWithTarget":
- case "moveToTarget":
- return [
- actions[action.name],
- targetToSpokenForm(action.source),
- destinationToSpokenForm(action.destination),
- ];
-
- case "swapTargets":
- return [
- actions[action.name],
- targetToSpokenForm(action.target1),
- connectives.swapConnective,
- targetToSpokenForm(action.target2),
- ];
-
- case "callAsFunction":
- if (action.argument.type === "implicit") {
- return [actions[action.name], targetToSpokenForm(action.callee)];
+ private componentsToSpokenForm(
+ getComponents: () => SpokenFormComponent,
+ ): SpokenForm {
+ try {
+ const components = getComponents();
+ const [preferred, ...alternatives] = constructSpokenForms(components);
+ return { type: "success", preferred, alternatives };
+ } catch (e) {
+ if (e instanceof NoSpokenFormError) {
+ return {
+ type: "error",
+ reason: e.reason,
+ requiresTalonUpdate: e.requiresTalonUpdate,
+ isSecret: e.isSecret,
+ };
}
- return [
- actions[action.name],
- targetToSpokenForm(action.callee),
- "on",
- targetToSpokenForm(action.argument),
- ];
-
- case "wrapWithPairedDelimiter":
- case "rewrapWithPairedDelimiter":
- return [
- surroundingPairDelimitersToSpokenForm(action.left, action.right),
- actions[action.name],
- targetToSpokenForm(action.target),
- ];
-
- case "pasteFromClipboard":
- return [
- actions[action.name],
- destinationToSpokenForm(action.destination),
- ];
-
- case "insertSnippet":
- return [
- actions[action.name],
- insertionSnippetToSpokenForm(action.snippetDescription),
- destinationToSpokenForm(action.destination),
- ];
-
- case "generateSnippet":
- if (action.snippetName != null) {
- throw new NoSpokenFormError(`${action.name}.snippetName`);
- }
- return [actions[action.name], targetToSpokenForm(action.target)];
-
- case "wrapWithSnippet":
- return [
- wrapperSnippetToSpokenForm(action.snippetDescription),
- actions[action.name],
- targetToSpokenForm(action.target),
- ];
-
- case "highlight": {
- if (action.highlightId != null) {
- throw new NoSpokenFormError(`${action.name}.highlightId`);
- }
- return [actions[action.name], targetToSpokenForm(action.target)];
+
+ throw e;
}
+ }
+
+ private handleAction(action: ActionDescriptor): SpokenFormComponent {
+ switch (action.name) {
+ case "editNew":
+ case "getText":
+ case "replace":
+ case "executeCommand":
+ case "private.getTargets":
+ throw new NoSpokenFormError(`Action '${action.name}'`);
+
+ case "replaceWithTarget":
+ case "moveToTarget":
+ return [
+ actions[action.name],
+ this.handleTarget(action.source),
+ this.handleDestination(action.destination),
+ ];
+
+ case "swapTargets":
+ return [
+ actions[action.name],
+ this.handleTarget(action.target1),
+ connectives.swapConnective,
+ this.handleTarget(action.target2),
+ ];
+
+ case "callAsFunction":
+ if (action.argument.type === "implicit") {
+ return [actions[action.name], this.handleTarget(action.callee)];
+ }
+ return [
+ actions[action.name],
+ this.handleTarget(action.callee),
+ "on",
+ this.handleTarget(action.argument),
+ ];
+
+ case "wrapWithPairedDelimiter":
+ case "rewrapWithPairedDelimiter":
+ return [
+ surroundingPairDelimitersToSpokenForm(
+ this.spokenFormMap,
+ action.left,
+ action.right,
+ ),
+ actions[action.name],
+ this.handleTarget(action.target),
+ ];
+
+ case "pasteFromClipboard":
+ return [
+ actions[action.name],
+ this.handleDestination(action.destination),
+ ];
+
+ case "insertSnippet":
+ return [
+ actions[action.name],
+ insertionSnippetToSpokenForm(action.snippetDescription),
+ this.handleDestination(action.destination),
+ ];
+
+ case "generateSnippet":
+ if (action.snippetName != null) {
+ throw new NoSpokenFormError(`${action.name}.snippetName`);
+ }
+ return [actions[action.name], this.handleTarget(action.target)];
+
+ case "wrapWithSnippet":
+ return [
+ wrapperSnippetToSpokenForm(action.snippetDescription),
+ actions[action.name],
+ this.handleTarget(action.target),
+ ];
+
+ case "highlight": {
+ if (action.highlightId != null) {
+ throw new NoSpokenFormError(`${action.name}.highlightId`);
+ }
+ return [actions[action.name], this.handleTarget(action.target)];
+ }
- default: {
- return [actions[action.name], targetToSpokenForm(action.target)];
+ default: {
+ return [actions[action.name], this.handleTarget(action.target)];
+ }
}
}
-}
-function targetToSpokenForm(
- target: PartialTargetDescriptor,
-): RecursiveArray {
- switch (target.type) {
- case "list":
- if (target.elements.length < 2) {
- throw new NoSpokenFormError("List target with < 2 elements");
+ private handleTarget(target: PartialTargetDescriptor): SpokenFormComponent {
+ switch (target.type) {
+ case "list":
+ if (target.elements.length < 2) {
+ throw new NoSpokenFormError("List target with < 2 elements");
+ }
+
+ return target.elements.map((element, i) =>
+ i === 0
+ ? this.handleTarget(element)
+ : [connectives.listConnective, this.handleTarget(element)],
+ );
+
+ case "range": {
+ const anchor = this.handleTarget(target.anchor);
+ const active = this.handleTarget(target.active);
+ const connective = getRangeConnective(
+ target.excludeAnchor,
+ target.excludeActive,
+ target.rangeType,
+ );
+ return [anchor, connective, active];
}
- return target.elements.map((element, i) =>
- i === 0
- ? targetToSpokenForm(element)
- : [connectives.listConnective, targetToSpokenForm(element)],
- );
-
- case "range": {
- const anchor = targetToSpokenForm(target.anchor);
- const active = targetToSpokenForm(target.active);
- const connective = getRangeConnective(
- target.excludeAnchor,
- target.excludeActive,
- target.rangeType,
- );
- return [anchor, connective, active];
+ case "primitive":
+ return this.primitiveGenerator.handlePrimitiveTarget(target);
+
+ case "implicit":
+ return [];
}
+ }
+
+ private handleDestination(
+ destination: DestinationDescriptor,
+ ): SpokenFormComponent {
+ switch (destination.type) {
+ case "list":
+ if (destination.destinations.length < 2) {
+ throw new NoSpokenFormError("List destination with < 2 elements");
+ }
+
+ return destination.destinations.map((destination, i) =>
+ i === 0
+ ? this.handleDestination(destination)
+ : [connectives.listConnective, this.handleDestination(destination)],
+ );
- case "primitive":
- return primitiveTargetToSpokenForm(target);
+ case "primitive":
+ return [
+ this.handleInsertionMode(destination.insertionMode),
+ this.handleTarget(destination.target),
+ ];
- case "implicit":
- return [];
+ case "implicit":
+ return [];
+ }
+ }
+
+ private handleInsertionMode(insertionMode: InsertionMode): string {
+ switch (insertionMode) {
+ case "to":
+ return connectives.sourceDestinationConnective;
+ case "before":
+ return connectives.before;
+ case "after":
+ return connectives.after;
+ }
}
}
-function destinationToSpokenForm(
- destination: DestinationDescriptor,
-): RecursiveArray {
- switch (destination.type) {
- case "list":
- if (destination.destinations.length < 2) {
- throw new NoSpokenFormError("List destination with < 2 elements");
- }
+function constructSpokenForms(component: SpokenFormComponent): string[] {
+ if (typeof component === "string") {
+ return [component];
+ }
- return destination.destinations.map((destination, i) =>
- i === 0
- ? destinationToSpokenForm(destination)
- : [connectives.listConnective, destinationToSpokenForm(destination)],
- );
+ if (Array.isArray(component)) {
+ if (component.length === 0) {
+ return [""];
+ }
- case "primitive":
- return [
- insertionModeToSpokenForm(destination.insertionMode),
- targetToSpokenForm(destination.target),
- ];
+ return cartesianProduct(component.map(constructSpokenForms)).map((words) =>
+ words.filter((word) => word.length !== 0).join(" "),
+ );
+ }
- case "implicit":
- return [];
+ if (component.spokenForms.spokenForms.length === 0) {
+ throw new NoSpokenFormError(
+ `${camelCaseToAllDown(component.spokenFormType)} with id ${
+ component.id
+ }; please see https://www.cursorless.org/docs/user/customization/ for more information`,
+ component.spokenForms.requiresTalonUpdate,
+ component.spokenForms.isSecret,
+ );
}
+
+ return component.spokenForms.spokenForms;
}
-function insertionModeToSpokenForm(insertionMode: InsertionMode): string {
- switch (insertionMode) {
- case "to":
- return connectives.sourceDestinationConnective;
- case "before":
- return connectives.before;
- case "after":
- return connectives.after;
+/**
+ * Given an array of arrays, constructs all possible combinations of the
+ * elements of the arrays. For example, given [[1, 2], [3, 4]], returns [[1, 3],
+ * [1, 4], [2, 3], [2, 4]]. If any of the arrays are empty, returns an empty
+ * array.
+ * @param arrays The arrays to take the cartesian product of
+ */
+function cartesianProduct(arrays: T[][]): T[][] {
+ if (arrays.length === 0) {
+ return [];
}
+
+ if (arrays.length === 1) {
+ return arrays[0].map((element) => [element]);
+ }
+
+ const [first, ...rest] = arrays;
+ const restCartesianProduct = cartesianProduct(rest);
+ return first.flatMap((element) =>
+ restCartesianProduct.map((restElement) => [element, ...restElement]),
+ );
}
diff --git a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts
index 59452aa775..79523126ed 100644
--- a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts
+++ b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts
@@ -6,9 +6,7 @@ import {
RelativeScopeModifier,
ScopeType,
} from "@cursorless/common";
-import { RecursiveArray } from "lodash";
import { NoSpokenFormError } from "./NoSpokenFormError";
-import { characterToSpokenForm } from "./defaultSpokenForms/characters";
import { connectives } from "./defaultSpokenForms/connectives";
import {
hatColorToSpokenForm,
@@ -16,275 +14,335 @@ import {
lineDirections,
marks,
} from "./defaultSpokenForms/marks";
-import {
- modifiers,
- modifiersExtra,
- scopeSpokenForms,
- surroundingPairForceDirections,
- surroundingPairNameToSpokenForm,
-} from "./defaultSpokenForms/modifiers";
+
+import { getRangeConnective } from "./getRangeConnective";
import {
numberToSpokenForm,
ordinalToSpokenForm,
} from "./defaultSpokenForms/numbers";
-import { getRangeConnective } from "./getRangeConnective";
+import { characterToSpokenForm } from "./defaultSpokenForms/characters";
+import {
+ GeneratorSpokenFormMap,
+ SpokenFormComponent,
+} from "./GeneratorSpokenFormMap";
-export function primitiveTargetToSpokenForm(
- target: PartialPrimitiveTargetDescriptor,
-): RecursiveArray {
- const components: RecursiveArray = [];
- if (target.modifiers != null) {
- components.push(target.modifiers.map(modifierToSpokenForm));
- }
- if (target.mark != null) {
- components.push(markToSpokenForm(target.mark));
+export class PrimitiveTargetSpokenFormGenerator {
+ constructor(private spokenFormMap: GeneratorSpokenFormMap) {
+ this.handleModifier = this.handleModifier.bind(this);
}
- return components;
-}
-
-function modifierToSpokenForm(modifier: Modifier): RecursiveArray {
- switch (modifier.type) {
- case "cascading":
- case "modifyIfUntyped":
- throw new NoSpokenFormError(`Modifier '${modifier.type}'`);
-
- case "containingScope":
- return [scopeTypeToSpokenForm(modifier.scopeType)];
- case "everyScope":
- return [modifiers.everyScope, scopeTypeToSpokenForm(modifier.scopeType)];
-
- case "extendThroughStartOf":
- case "extendThroughEndOf": {
- const type = modifiers[modifier.type];
- return modifier.modifiers != null
- ? [type, modifier.modifiers.map(modifierToSpokenForm)]
- : [type];
+ handlePrimitiveTarget(
+ target: PartialPrimitiveTargetDescriptor,
+ ): SpokenFormComponent {
+ const components: SpokenFormComponent[] = [];
+ if (target.modifiers != null) {
+ components.push(target.modifiers.map(this.handleModifier));
}
+ if (target.mark != null) {
+ components.push(this.handleMark(target.mark));
+ }
+ return components;
+ }
- case "relativeScope":
- return modifier.offset === 0
- ? relativeScopeInclusiveToSpokenForm(modifier)
- : relativeScopeExclusiveToSpokenForm(modifier);
-
- case "ordinalScope": {
- const scope = scopeTypeToSpokenForm(modifier.scopeType);
+ private handleModifier(modifier: Modifier): SpokenFormComponent {
+ switch (modifier.type) {
+ case "cascading":
+ case "modifyIfUntyped":
+ throw new NoSpokenFormError(`Modifier '${modifier.type}'`);
+
+ case "containingScope":
+ return [this.handleScopeType(modifier.scopeType)];
+
+ case "everyScope":
+ return [
+ this.spokenFormMap.simpleModifier.everyScope,
+ this.handleScopeType(modifier.scopeType),
+ ];
+
+ case "extendThroughStartOf":
+ case "extendThroughEndOf": {
+ const type = this.spokenFormMap.simpleModifier[modifier.type];
+ return modifier.modifiers != null
+ ? [type, modifier.modifiers.map(this.handleModifier)]
+ : [type];
+ }
- if (modifier.length === 1) {
- if (modifier.start === -1) {
- return [modifiersExtra.last, scope];
+ case "relativeScope":
+ return modifier.offset === 0
+ ? this.handleRelativeScopeInclusive(modifier)
+ : this.handleRelativeScopeExclusive(modifier);
+
+ case "ordinalScope": {
+ const scope = this.handleScopeType(modifier.scopeType);
+
+ if (modifier.length === 1) {
+ if (modifier.start === -1) {
+ return [this.spokenFormMap.modifierExtra.last, scope];
+ }
+ if (modifier.start === 0) {
+ return [this.spokenFormMap.modifierExtra.first, scope];
+ }
+ if (modifier.start < 0) {
+ return [
+ ordinalToSpokenForm(Math.abs(modifier.start)),
+ this.spokenFormMap.modifierExtra.last,
+ scope,
+ ];
+ }
+ return [ordinalToSpokenForm(modifier.start + 1), scope];
}
+
+ const number = numberToSpokenForm(modifier.length);
+
if (modifier.start === 0) {
- return [modifiersExtra.first, scope];
+ return [
+ this.spokenFormMap.modifierExtra.first,
+ number,
+ pluralize(scope),
+ ];
}
- if (modifier.start < 0) {
+ if (modifier.start === -modifier.length) {
return [
- ordinalToSpokenForm(Math.abs(modifier.start)),
- modifiersExtra.last,
- scope,
+ this.spokenFormMap.modifierExtra.last,
+ number,
+ pluralize(scope),
];
}
- return [ordinalToSpokenForm(modifier.start + 1), scope];
+
+ throw new NoSpokenFormError(
+ `'${modifier.type}' with count > 1 and offset away from start / end`,
+ );
}
- const number = numberToSpokenForm(modifier.length);
+ case "range": {
+ if (
+ modifier.anchor.type === "ordinalScope" &&
+ modifier.active.type === "ordinalScope" &&
+ modifier.anchor.length === 1 &&
+ modifier.active.length === 1 &&
+ modifier.anchor.scopeType.type === modifier.active.scopeType.type
+ ) {
+ const anchor =
+ modifier.anchor.start === -1
+ ? this.spokenFormMap.modifierExtra.last
+ : ordinalToSpokenForm(modifier.anchor.start + 1);
+ const active = this.handleModifier(modifier.active);
+ const connective = getRangeConnective(
+ modifier.excludeAnchor,
+ modifier.excludeActive,
+ );
+ return [anchor, connective, active];
+ }
- if (modifier.start === 0) {
- return [modifiersExtra.first, number, pluralize(scope)];
- }
- if (modifier.start === -modifier.length) {
- return [modifiersExtra.last, number, pluralize(scope)];
+ // Throw actual Error here because we're not sure we ever want to support
+ // a spoken form for these; we may deprecate this construct entirely
+ throw Error(`Modifier '${modifier.type}' is not fully implemented`);
}
- throw new NoSpokenFormError(
- `'${modifier.type}' with count > 1 and offset away from start / end`,
- );
+ default:
+ return [this.spokenFormMap.simpleModifier[modifier.type]];
}
+ }
- case "range": {
- if (
- modifier.anchor.type === "ordinalScope" &&
- modifier.active.type === "ordinalScope" &&
- modifier.anchor.length === 1 &&
- modifier.active.length === 1 &&
- modifier.anchor.scopeType.type === modifier.active.scopeType.type
- ) {
- const anchor =
- modifier.anchor.start === -1
- ? modifiersExtra.last
- : ordinalToSpokenForm(modifier.anchor.start + 1);
- const active = modifierToSpokenForm(modifier.active);
- const connective = getRangeConnective(
- modifier.excludeAnchor,
- modifier.excludeActive,
- );
- return [anchor, connective, active];
- }
+ private handleRelativeScopeInclusive(
+ modifier: RelativeScopeModifier,
+ ): SpokenFormComponent {
+ const scope = this.handleScopeType(modifier.scopeType);
- // Throw actual Error here because we're not sure we ever want to support
- // a spoken form for these; we may deprecate this construct entirely
- throw Error(`Modifier '${modifier.type}' is not fully implemented`);
- }
+ if (modifier.length === 1) {
+ const direction =
+ modifier.direction === "forward"
+ ? connectives.forward
+ : connectives.backward;
- default:
- return [modifiers[modifier.type]];
- }
-}
+ // token forward/backward
+ return [scope, direction];
+ }
-function relativeScopeInclusiveToSpokenForm(
- modifier: RelativeScopeModifier,
-): RecursiveArray {
- const scope = scopeTypeToSpokenForm(modifier.scopeType);
+ const length = numberToSpokenForm(modifier.length);
+ const scopePlural = pluralize(scope);
- if (modifier.length === 1) {
- const direction =
- modifier.direction === "forward"
- ? connectives.forward
- : connectives.backward;
+ // two tokens
+ // This could also have been "two tokens forward"; there is no way to disambiguate.
+ if (modifier.direction === "forward") {
+ return [length, scopePlural];
+ }
- // token forward/backward
- return [scope, direction];
+ // two tokens backward
+ return [length, scopePlural, connectives.backward];
}
- const length = numberToSpokenForm(modifier.length);
- const scopePlural = pluralize(scope);
+ private handleRelativeScopeExclusive(
+ modifier: RelativeScopeModifier,
+ ): SpokenFormComponent {
+ const scope = this.handleScopeType(modifier.scopeType);
+ const direction =
+ modifier.direction === "forward"
+ ? connectives.next
+ : connectives.previous;
- // two tokens
- // This could also have been "two tokens forward"; there is no way to disambiguate.
- if (modifier.direction === "forward") {
- return [length, scopePlural];
- }
+ if (modifier.offset === 1) {
+ const number = numberToSpokenForm(modifier.length);
- // two tokens backward
- return [length, scopePlural, connectives.backward];
-}
+ if (modifier.length === 1) {
+ // next/previous token
+ return [direction, scope];
+ }
-function relativeScopeExclusiveToSpokenForm(
- modifier: RelativeScopeModifier,
-): RecursiveArray {
- const scope = scopeTypeToSpokenForm(modifier.scopeType);
- const direction =
- modifier.direction === "forward" ? connectives.next : connectives.previous;
+ const scopePlural = pluralize(scope);
- if (modifier.offset === 1) {
- const number = numberToSpokenForm(modifier.length);
+ // next/previous two tokens
+ return [direction, number, scopePlural];
+ }
if (modifier.length === 1) {
- // next/previous token
- return [direction, scope];
+ const ordinal = ordinalToSpokenForm(modifier.offset);
+ // second next/previous token
+ return [ordinal, direction, scope];
}
- const scopePlural = pluralize(scope);
-
- // next/previous two tokens
- return [direction, number, scopePlural];
+ throw new NoSpokenFormError(
+ `${modifier.type} modifier with offset > 1 and length > 1`,
+ );
}
- if (modifier.length === 1) {
- const ordinal = ordinalToSpokenForm(modifier.offset);
- // second next/previous token
- return [ordinal, direction, scope];
- }
+ handleScopeType(scopeType: ScopeType): SpokenFormComponent {
+ switch (scopeType.type) {
+ case "oneOf":
+ throw new NoSpokenFormError(`Scope type '${scopeType.type}'`);
+ case "surroundingPair": {
+ if (scopeType.delimiter === "collectionBoundary") {
+ throw new NoSpokenFormError(
+ `Scope type '${scopeType.type}' with delimiter 'collectionBoundary'`,
+ );
+ }
+ const pair = this.spokenFormMap.pairedDelimiter[scopeType.delimiter];
+ if (scopeType.forceDirection != null) {
+ return [
+ this.spokenFormMap.surroundingPairForceDirection[
+ scopeType.forceDirection
+ ],
+ pair,
+ ];
+ }
+ return pair;
+ }
- throw new NoSpokenFormError(
- `${modifier.type} modifier with offset > 1 and length > 1`,
- );
-}
+ case "customRegex":
+ return (
+ this.spokenFormMap.customRegex[scopeType.regex] ?? {
+ type: "singleTerm",
+ spokenForms: [],
+ spokenFormType: "customRegex",
+ id: scopeType.regex,
+ }
+ );
-function scopeTypeToSpokenForm(scopeType: ScopeType): string {
- switch (scopeType.type) {
- case "oneOf":
- case "customRegex":
- case "switchStatementSubject":
- case "string":
- throw new NoSpokenFormError(`Scope type '${scopeType.type}'`);
- case "surroundingPair": {
- const pair = surroundingPairNameToSpokenForm(scopeType.delimiter);
- if (scopeType.forceDirection != null) {
- const direction =
- scopeType.forceDirection === "left"
- ? surroundingPairForceDirections.left
- : surroundingPairForceDirections.right;
- return `${direction} ${pair}`;
- }
- return pair;
+ default:
+ return this.spokenFormMap.simpleScopeTypeType[scopeType.type];
}
-
- default:
- return scopeSpokenForms[scopeType.type];
}
-}
-function markToSpokenForm(mark: PartialMark): RecursiveArray {
- switch (mark.type) {
- case "decoratedSymbol": {
- const [color, shape] = mark.symbolColor.split("-");
- const components: string[] = [];
- if (color !== "default") {
- components.push(hatColorToSpokenForm(color));
- }
- if (shape != null) {
- components.push(hatShapeToSpokenForm(shape));
+ private handleMark(mark: PartialMark): SpokenFormComponent {
+ switch (mark.type) {
+ case "decoratedSymbol": {
+ const [color, shape] = mark.symbolColor.split("-");
+ const components: string[] = [];
+ if (color !== "default") {
+ components.push(hatColorToSpokenForm(color));
+ }
+ if (shape != null) {
+ components.push(hatShapeToSpokenForm(shape));
+ }
+ components.push(characterToSpokenForm(mark.character));
+ return components;
}
- components.push(characterToSpokenForm(mark.character));
- return components;
- }
- case "lineNumber": {
- return lineNumberToParts(mark);
- }
+ case "lineNumber": {
+ return this.handleLineNumberMark(mark);
+ }
- case "range": {
- if (
- mark.anchor.type === "lineNumber" &&
- mark.active.type === "lineNumber"
- ) {
- const [typeAnchor, numberAnchor] = lineNumberToParts(mark.anchor);
- const [typeActive, numberActive] = lineNumberToParts(mark.active);
- if (typeAnchor === typeActive) {
- const connective = getRangeConnective(
- mark.excludeAnchor,
- mark.excludeActive,
+ case "range": {
+ if (
+ mark.anchor.type === "lineNumber" &&
+ mark.active.type === "lineNumber"
+ ) {
+ const [typeAnchor, numberAnchor] = this.handleLineNumberMark(
+ mark.anchor,
);
- // Row five past seven
- return [typeAnchor, numberAnchor, connective, numberActive];
+ const [typeActive, numberActive] = this.handleLineNumberMark(
+ mark.active,
+ );
+ if (typeAnchor === typeActive) {
+ const connective = getRangeConnective(
+ mark.excludeAnchor,
+ mark.excludeActive,
+ );
+ // Row five past seven
+ return [typeAnchor, numberAnchor, connective, numberActive];
+ }
}
+ // Throw actual Error here because we're not sure we ever want to support
+ // a spoken form for these; we may deprecate this construct entirely
+ throw Error(`Mark '${mark.type}' is not fully implemented`);
}
- // Throw actual Error here because we're not sure we ever want to support
- // a spoken form for these; we may deprecate this construct entirely
- throw Error(`Mark '${mark.type}' is not fully implemented`);
+ case "explicit":
+ throw new NoSpokenFormError(`Mark '${mark.type}'`);
+
+ default:
+ return [marks[mark.type]];
}
- case "explicit":
- throw new NoSpokenFormError(`Mark '${mark.type}'`);
+ }
- default:
- return [marks[mark.type]];
+ private handleLineNumberMark(mark: LineNumberMark): [string, string] {
+ switch (mark.lineNumberType) {
+ case "absolute":
+ throw new NoSpokenFormError("Absolute line numbers");
+ case "modulo100": {
+ // row/ five
+ return [
+ lineDirections.modulo100,
+ numberToSpokenForm(mark.lineNumber + 1),
+ ];
+ }
+ case "relative": {
+ // up/down five
+ return [
+ mark.lineNumber < 0
+ ? lineDirections.relativeUp
+ : lineDirections.relativeDown,
+ numberToSpokenForm(Math.abs(mark.lineNumber)),
+ ];
+ }
+ }
}
}
-function lineNumberToParts(mark: LineNumberMark): [string, string] {
- switch (mark.lineNumberType) {
- case "absolute":
- throw new NoSpokenFormError("Absolute line numbers");
- case "modulo100": {
- // row/ five
- return [
- lineDirections.modulo100,
- numberToSpokenForm(mark.lineNumber + 1),
- ];
- }
- case "relative": {
- // up/down five
- return [
- mark.lineNumber < 0
- ? lineDirections.relativeUp
- : lineDirections.relativeDown,
- numberToSpokenForm(Math.abs(mark.lineNumber)),
- ];
+function pluralize(name: SpokenFormComponent): SpokenFormComponent {
+ if (typeof name === "string") {
+ return pluralizeString(name);
+ }
+
+ if (Array.isArray(name)) {
+ if (name.length === 0) {
+ return name;
}
+
+ const last = name[name.length - 1];
+
+ return [...name.slice(0, -1), pluralize(last)];
}
+
+ return {
+ ...name,
+ spokenForms: {
+ ...name.spokenForms,
+ spokenForms: name.spokenForms.spokenForms.map(pluralizeString),
+ },
+ };
}
-function pluralize(name: string): string {
+// FIXME: Properly pluralize
+function pluralizeString(name: string): string {
return `${name}s`;
}
diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts
index 45b5881824..9348e847e2 100644
--- a/packages/cursorless-engine/src/index.ts
+++ b/packages/cursorless-engine/src/index.ts
@@ -6,4 +6,3 @@ export * from "./core/StoredTargets";
export * from "./typings/TreeSitter";
export * from "./cursorlessEngine";
export * from "./api/CursorlessEngineApi";
-export * from "./api/ScopeProvider";
diff --git a/packages/cursorless-engine/src/languages/LanguageDefinitions.ts b/packages/cursorless-engine/src/languages/LanguageDefinitions.ts
index 4632f3473c..6012e89d6b 100644
--- a/packages/cursorless-engine/src/languages/LanguageDefinitions.ts
+++ b/packages/cursorless-engine/src/languages/LanguageDefinitions.ts
@@ -57,7 +57,7 @@ export class LanguageDefinitions {
if (ide().runMode === "development") {
this.disposables.push(
- fileSystem.watchDir(this.queryDir, () => {
+ fileSystem.watch(this.queryDir, () => {
this.languageDefinitions.clear();
this.notifier.notifyListeners();
}),
diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts
new file mode 100644
index 0000000000..15c7d28515
--- /dev/null
+++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts
@@ -0,0 +1,193 @@
+import {
+ Disposable,
+ Disposer,
+ ScopeType,
+ ScopeTypeInfo,
+ ScopeTypeInfoEventCallback,
+ SurroundingPairScopeType,
+ simpleScopeTypeTypes,
+ surroundingPairNames,
+} from "@cursorless/common";
+import { pull } from "lodash";
+
+import { SpeakableSurroundingPairName } from "../SpokenFormMap";
+import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl";
+import { scopeTypeToString } from "./scopeTypeToString";
+
+/**
+ * Maintains a list of all scope types and notifies listeners when it changes.
+ */
+export class ScopeInfoProvider {
+ private disposer = new Disposer();
+ private listeners: ScopeTypeInfoEventCallback[] = [];
+ private scopeInfos!: ScopeTypeInfo[];
+
+ constructor(
+ private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
+ ) {
+ this.disposer.push(
+ customSpokenFormGenerator.onDidChangeCustomSpokenForms(() =>
+ this.onChange(),
+ ),
+ );
+
+ this.onDidChangeScopeInfo = this.onDidChangeScopeInfo.bind(this);
+ this.getScopeTypeInfo = this.getScopeTypeInfo.bind(this);
+ this.updateScopeTypeInfos();
+ }
+
+ /**
+ * Registers a callback to be run when the scope ranges change for any visible
+ * editor. The callback will be run immediately once for each visible editor
+ * with the current scope ranges.
+ * @param callback The callback to run when the scope ranges change
+ * @param config The configuration for the scope ranges
+ * @returns A {@link Disposable} which will stop the callback from running
+ */
+ onDidChangeScopeInfo(callback: ScopeTypeInfoEventCallback): Disposable {
+ callback(this.getScopeTypeInfos());
+
+ this.listeners.push(callback);
+
+ return {
+ dispose: () => {
+ pull(this.listeners, callback);
+ },
+ };
+ }
+
+ private async onChange() {
+ this.updateScopeTypeInfos();
+
+ this.listeners.forEach((listener) => listener(this.scopeInfos));
+ }
+
+ private updateScopeTypeInfos(): void {
+ const scopeTypes: ScopeType[] = [
+ ...simpleScopeTypeTypes
+ // Ignore instance pseudo-scope because it's not really a scope
+ .filter((scopeTypeType) => scopeTypeType !== "instance")
+ .map((scopeTypeType) => ({
+ type: scopeTypeType,
+ })),
+
+ ...surroundingPairNames
+ .filter(
+ (
+ surroundingPairName,
+ ): surroundingPairName is Exclude<
+ SpeakableSurroundingPairName,
+ "whitespace"
+ > => surroundingPairName !== "collectionBoundary",
+ )
+ .map(
+ (surroundingPairName): SurroundingPairScopeType => ({
+ type: "surroundingPair",
+ delimiter: surroundingPairName,
+ }),
+ ),
+
+ ...this.customSpokenFormGenerator.getCustomRegexScopeTypes(),
+ ];
+
+ this.scopeInfos = scopeTypes.map((scopeType) =>
+ this.getScopeTypeInfo(scopeType),
+ );
+ }
+
+ getScopeTypeInfos(): ScopeTypeInfo[] {
+ return this.scopeInfos;
+ }
+
+ getScopeTypeInfo(scopeType: ScopeType): ScopeTypeInfo {
+ return {
+ scopeType,
+ spokenForm:
+ this.customSpokenFormGenerator.scopeTypeToSpokenForm(scopeType),
+ humanReadableName: scopeTypeToString(scopeType),
+ isLanguageSpecific: isLanguageSpecific(scopeType),
+ };
+ }
+}
+
+/**
+ * @param scopeType The scope type to check
+ * @returns A boolean indicating whether the given scope type is defined on a
+ * per-language basis.
+ */
+function isLanguageSpecific(scopeType: ScopeType): boolean {
+ switch (scopeType.type) {
+ case "string":
+ case "argumentOrParameter":
+ case "anonymousFunction":
+ case "attribute":
+ case "branch":
+ case "class":
+ case "className":
+ case "collectionItem":
+ case "collectionKey":
+ case "command":
+ case "comment":
+ case "functionCall":
+ case "functionCallee":
+ case "functionName":
+ case "ifStatement":
+ case "instance":
+ case "list":
+ case "map":
+ case "name":
+ case "namedFunction":
+ case "regularExpression":
+ case "statement":
+ case "type":
+ case "value":
+ case "condition":
+ case "section":
+ case "sectionLevelOne":
+ case "sectionLevelTwo":
+ case "sectionLevelThree":
+ case "sectionLevelFour":
+ case "sectionLevelFive":
+ case "sectionLevelSix":
+ case "selector":
+ case "switchStatementSubject":
+ case "unit":
+ case "xmlBothTags":
+ case "xmlElement":
+ case "xmlEndTag":
+ case "xmlStartTag":
+ case "part":
+ case "chapter":
+ case "subSection":
+ case "subSubSection":
+ case "namedParagraph":
+ case "subParagraph":
+ case "environment":
+ return true;
+
+ case "character":
+ case "word":
+ case "token":
+ case "identifier":
+ case "line":
+ case "sentence":
+ case "paragraph":
+ case "document":
+ case "nonWhitespaceSequence":
+ case "boundedNonWhitespaceSequence":
+ case "url":
+ case "notebookCell":
+ case "surroundingPair":
+ case "customRegex":
+ return false;
+
+ case "oneOf":
+ throw Error(
+ `Can't decide whether scope type ${JSON.stringify(
+ scopeType,
+ undefined,
+ 3,
+ )} is language-specific`,
+ );
+ }
+}
diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts
similarity index 97%
rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts
rename to packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts
index 62f39039f9..0e1ae1d0b8 100644
--- a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts
+++ b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts
@@ -1,10 +1,11 @@
-import { TextEditor } from "@cursorless/common";
import {
IterationScopeRangeConfig,
IterationScopeRanges,
ScopeRangeConfig,
ScopeRanges,
-} from "..";
+ TextEditor,
+} from "@cursorless/common";
+
import { ModifierStageFactory } from "../processTargets/ModifierStageFactory";
import { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory";
import { getIterationRange } from "./getIterationRange";
diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts
similarity index 96%
rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts
rename to packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts
index 6a4a7971b5..fdc5790f32 100644
--- a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts
+++ b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts
@@ -1,12 +1,14 @@
-import { Disposable, showError } from "@cursorless/common";
-import { pull } from "lodash";
import {
+ Disposable,
IterationScopeChangeEventCallback,
IterationScopeRangeConfig,
ScopeChangeEventCallback,
ScopeRangeConfig,
ScopeRanges,
-} from "..";
+ showError,
+} from "@cursorless/common";
+import { pull } from "lodash";
+
import { Debouncer } from "../core/Debouncer";
import { LanguageDefinitions } from "../languages/LanguageDefinitions";
import { ide } from "../singletons/ide.singleton";
@@ -25,6 +27,11 @@ export class ScopeRangeWatcher {
languageDefinitions: LanguageDefinitions,
private scopeRangeProvider: ScopeRangeProvider,
) {
+ this.onChange = this.onChange.bind(this);
+ this.onDidChangeScopeRanges = this.onDidChangeScopeRanges.bind(this);
+ this.onDidChangeIterationScopeRanges =
+ this.onDidChangeIterationScopeRanges.bind(this);
+
this.disposables.push(
// An Event which fires when the array of visible editors has changed.
ide().onDidChangeVisibleTextEditors(this.debouncer.run),
@@ -37,13 +44,9 @@ export class ScopeRangeWatcher {
// dirty-state changes.
ide().onDidChangeTextDocument(this.debouncer.run),
ide().onDidChangeTextEditorVisibleRanges(this.debouncer.run),
- languageDefinitions.onDidChangeDefinition(this.debouncer.run),
+ languageDefinitions.onDidChangeDefinition(this.onChange),
this.debouncer,
);
-
- this.onDidChangeScopeRanges = this.onDidChangeScopeRanges.bind(this);
- this.onDidChangeIterationScopeRanges =
- this.onDidChangeIterationScopeRanges.bind(this);
}
/**
diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts
similarity index 98%
rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts
rename to packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts
index d9ee8f1664..894d3b5262 100644
--- a/packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts
+++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts
@@ -1,5 +1,6 @@
import {
Position,
+ ScopeSupport,
ScopeType,
SimpleScopeTypeType,
TextEditor,
@@ -9,7 +10,6 @@ import { LegacyLanguageId } from "../languages/LegacyLanguageId";
import { languageMatchers } from "../languages/getNodeMatcher";
import { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory";
import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types";
-import { ScopeSupport } from "../api/ScopeProvider";
/**
* Determines the level of support for a given scope type in a given editor.
diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts
new file mode 100644
index 0000000000..3c6f435495
--- /dev/null
+++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts
@@ -0,0 +1,123 @@
+import {
+ Disposable,
+ ScopeSupport,
+ ScopeSupportEventCallback,
+ ScopeSupportInfo,
+ ScopeType,
+} from "@cursorless/common";
+import { pull } from "lodash";
+
+import { Debouncer } from "../core/Debouncer";
+import { LanguageDefinitions } from "../languages/LanguageDefinitions";
+import { ide } from "../singletons/ide.singleton";
+import { ScopeInfoProvider } from "./ScopeInfoProvider";
+import { ScopeSupportChecker } from "./ScopeSupportChecker";
+
+/**
+ * Watches for changes to the scope support of the active editor and notifies
+ * listeners when it changes. Watches support for all scopes at the same time.
+ */
+export class ScopeSupportWatcher {
+ private disposables: Disposable[] = [];
+ private debouncer = new Debouncer(() => this.onChange());
+ private listeners: ScopeSupportEventCallback[] = [];
+
+ constructor(
+ languageDefinitions: LanguageDefinitions,
+ private scopeSupportChecker: ScopeSupportChecker,
+ private scopeInfoProvider: ScopeInfoProvider,
+ ) {
+ this.onChange = this.onChange.bind(this);
+ this.onDidChangeScopeSupport = this.onDidChangeScopeSupport.bind(this);
+
+ this.disposables.push(
+ // An event that fires when a text document opens
+ ide().onDidOpenTextDocument(this.debouncer.run),
+ // An Event that fires when a text document closes
+ ide().onDidCloseTextDocument(this.debouncer.run),
+ // An Event which fires when the active editor has changed. Note that the event also fires when the active editor changes to undefined.
+ ide().onDidChangeActiveTextEditor(this.debouncer.run),
+ // An event that is emitted when a text document is changed. This usually
+ // happens when the contents changes but also when other things like the
+ // dirty-state changes.
+ ide().onDidChangeTextDocument(this.debouncer.run),
+ languageDefinitions.onDidChangeDefinition(this.debouncer.run),
+ this.scopeInfoProvider.onDidChangeScopeInfo(this.onChange),
+ this.debouncer,
+ );
+ }
+
+ /**
+ * Registers a callback to be run when the scope ranges change for any visible
+ * editor. The callback will be run immediately once for each visible editor
+ * with the current scope ranges.
+ * @param callback The callback to run when the scope ranges change
+ * @param config The configuration for the scope ranges
+ * @returns A {@link Disposable} which will stop the callback from running
+ */
+ onDidChangeScopeSupport(callback: ScopeSupportEventCallback): Disposable {
+ callback(this.getSupportLevels());
+
+ this.listeners.push(callback);
+
+ return {
+ dispose: () => {
+ pull(this.listeners, callback);
+ },
+ };
+ }
+
+ private onChange() {
+ if (this.listeners.length === 0) {
+ // Don't bother if no one is listening
+ return;
+ }
+
+ const supportLevels = this.getSupportLevels();
+
+ this.listeners.forEach((listener) => listener(supportLevels));
+ }
+
+ private getSupportLevels(): ScopeSupportInfo[] {
+ const activeTextEditor = ide().activeTextEditor;
+
+ const getScopeTypeSupport =
+ activeTextEditor == null
+ ? () => ScopeSupport.unsupported
+ : (scopeType: ScopeType) =>
+ this.scopeSupportChecker.getScopeSupport(
+ activeTextEditor,
+ scopeType,
+ );
+
+ const getIterationScopeTypeSupport =
+ activeTextEditor == null
+ ? () => ScopeSupport.unsupported
+ : (scopeType: ScopeType) =>
+ this.scopeSupportChecker.getIterationScopeSupport(
+ activeTextEditor,
+ scopeType,
+ );
+
+ const scopeTypeInfos = this.scopeInfoProvider.getScopeTypeInfos();
+
+ return scopeTypeInfos.map((scopeTypeInfo) => ({
+ ...scopeTypeInfo,
+ support: getScopeTypeSupport(scopeTypeInfo.scopeType),
+ iterationScopeSupport: getIterationScopeTypeSupport(
+ scopeTypeInfo.scopeType,
+ ),
+ }));
+ }
+
+ 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
+ }
+ });
+ }
+}
diff --git a/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts b/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts
new file mode 100644
index 0000000000..b0ecf250d0
--- /dev/null
+++ b/packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts
@@ -0,0 +1,37 @@
+import { Notifier, SimpleScopeTypeType } from "@cursorless/common";
+import { SpeakableSurroundingPairName } from "../SpokenFormMap";
+
+export interface TalonSpokenForms {
+ getSpokenFormEntries(): Promise;
+ 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";
+ }
+}
diff --git a/packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts b/packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts
new file mode 100644
index 0000000000..4ffcfc1ae9
--- /dev/null
+++ b/packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts
@@ -0,0 +1,87 @@
+import {
+ Disposer,
+ FileSystem,
+ LATEST_VERSION,
+ Notifier,
+ isTesting,
+} from "@cursorless/common";
+import * as crypto from "crypto";
+import { mkdir, readFile } from "fs/promises";
+import * as os from "os";
+
+import * as path from "path";
+import {
+ NeedsInitialTalonUpdateError,
+ SpokenFormEntry,
+ TalonSpokenForms,
+} from "./SpokenFormEntry";
+
+interface TalonSpokenFormsPayload {
+ version: number;
+ entries: SpokenFormEntry[];
+}
+
+export class TalonSpokenFormsJsonReader implements TalonSpokenForms {
+ private disposer = new Disposer();
+ private notifier = new Notifier();
+ public readonly spokenFormsPath;
+
+ constructor(private fileSystem: FileSystem) {
+ const cursorlessDir = isTesting()
+ ? path.join(os.tmpdir(), crypto.randomBytes(16).toString("hex"))
+ : path.join(os.homedir(), ".cursorless");
+
+ this.spokenFormsPath = path.join(cursorlessDir, "spokenForms.json");
+
+ this.init();
+ }
+
+ private async init() {
+ const parentDir = path.dirname(this.spokenFormsPath);
+ await mkdir(parentDir, { recursive: true });
+ this.disposer.push(
+ this.fileSystem.watch(parentDir, () => this.notifier.notifyListeners()),
+ );
+ }
+
+ /**
+ * Registers a callback to be run when the 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
+ */
+ onDidChange = this.notifier.registerListener;
+
+ async getSpokenFormEntries(): Promise {
+ let payload: TalonSpokenFormsPayload;
+ try {
+ payload = JSON.parse(await readFile(this.spokenFormsPath, "utf-8"));
+ } catch (err) {
+ if ((err as any)?.code === "ENOENT") {
+ throw new NeedsInitialTalonUpdateError(
+ `Custom spoken forms file not found at ${this.spokenFormsPath}. Using default spoken forms.`,
+ );
+ }
+
+ throw err;
+ }
+
+ /**
+ * This assignment is to ensure that the compiler will error if we forget to
+ * handle spokenForms.json when we bump the command version.
+ */
+ const latestCommandVersion: 6 = LATEST_VERSION;
+
+ if (payload.version !== latestCommandVersion) {
+ // In the future, we'll need to handle migrations. Not sure exactly how yet.
+ throw new Error(
+ `Invalid spoken forms version. Expected ${LATEST_VERSION} but got ${payload.version}`,
+ );
+ }
+
+ return payload.entries;
+ }
+
+ dispose() {
+ this.disposer.dispose();
+ }
+}
diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts b/packages/cursorless-engine/src/scopeProviders/getIterationRange.ts
similarity index 100%
rename from packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts
rename to packages/cursorless-engine/src/scopeProviders/getIterationRange.ts
diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts b/packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts
similarity index 94%
rename from packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts
rename to packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts
index feb42bffe9..a28b6eb8b8 100644
--- a/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts
+++ b/packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts
@@ -1,6 +1,5 @@
-import { Range, TextEditor } from "@cursorless/common";
+import { IterationScopeRanges, Range, TextEditor } from "@cursorless/common";
import { map } from "itertools";
-import { IterationScopeRanges } from "..";
import { ModifierStage } from "../processTargets/PipelineStages.types";
import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types";
import { Target } from "../typings/target.types";
diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts b/packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts
similarity index 91%
rename from packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts
rename to packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts
index 56e47dde50..3e36c4f768 100644
--- a/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts
+++ b/packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts
@@ -1,6 +1,6 @@
-import { Range, TextEditor } from "@cursorless/common";
+import { Range, ScopeRanges, TextEditor } from "@cursorless/common";
import { map } from "itertools";
-import { ScopeRanges } from "..";
+
import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types";
import { getTargetRanges } from "./getTargetRanges";
diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts b/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts
similarity index 73%
rename from packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts
rename to packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts
index 5fd843310c..8be5e52e72 100644
--- a/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts
+++ b/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts
@@ -1,6 +1,9 @@
-import { toCharacterRange, toLineRange } from "@cursorless/common";
+import {
+ TargetRanges,
+ toCharacterRange,
+ toLineRange,
+} from "@cursorless/common";
import { Target } from "../typings/target.types";
-import { TargetRanges } from "../api/ScopeProvider";
export function getTargetRanges(target: Target): TargetRanges {
return {
diff --git a/packages/cursorless-engine/src/scopeProviders/scopeTypeToString.ts b/packages/cursorless-engine/src/scopeProviders/scopeTypeToString.ts
new file mode 100644
index 0000000000..5a0136ef62
--- /dev/null
+++ b/packages/cursorless-engine/src/scopeProviders/scopeTypeToString.ts
@@ -0,0 +1,21 @@
+import {
+ ScopeType,
+ camelCaseToAllDown,
+ isSimpleScopeType,
+} from "@cursorless/common";
+
+export function scopeTypeToString(scopeType: ScopeType): string {
+ if (isSimpleScopeType(scopeType)) {
+ return camelCaseToAllDown(scopeType.type);
+ }
+
+ if (scopeType.type === "surroundingPair") {
+ return `Matching pair of ${camelCaseToAllDown(scopeType.delimiter)}`;
+ }
+
+ if (scopeType.type === "customRegex") {
+ return `Regex \`${scopeType.regex}\``;
+ }
+
+ return "Unknown scope type";
+}
diff --git a/packages/cursorless-engine/src/testCaseRecorder/TestCaseRecorder.ts b/packages/cursorless-engine/src/testCaseRecorder/TestCaseRecorder.ts
index 6cf7d22ecc..3035275ec1 100644
--- a/packages/cursorless-engine/src/testCaseRecorder/TestCaseRecorder.ts
+++ b/packages/cursorless-engine/src/testCaseRecorder/TestCaseRecorder.ts
@@ -31,8 +31,9 @@ import { takeSnapshot } from "../testUtil/takeSnapshot";
import { TestCase } from "./TestCase";
import { StoredTargetMap } from "../core/StoredTargets";
import { CommandRunner } from "../CommandRunner";
-import { generateSpokenForm } from "../generateSpokenForm";
import { RecordTestCaseCommandOptions } from "./RecordTestCaseCommandOptions";
+import { SpokenFormGenerator } from "../generateSpokenForm";
+import { defaultSpokenFormMap } from "../DefaultSpokenFormMap";
const CALIBRATION_DISPLAY_DURATION_MS = 50;
@@ -59,6 +60,7 @@ export class TestCaseRecorder {
private captureFinalThatMark: boolean = false;
private spyIde: SpyIDE | undefined;
private originalIde: IDE | undefined;
+ private spokenFormGenerator = new SpokenFormGenerator(defaultSpokenFormMap);
constructor(
private hatTokenMap: HatTokenMap,
@@ -275,14 +277,14 @@ export class TestCaseRecorder {
this.spyIde = new SpyIDE(this.originalIde);
injectIde(this.spyIde!);
- const spokenForm = generateSpokenForm(command);
+ const spokenForm = this.spokenFormGenerator.command(command);
this.testCase = new TestCase(
{
...command,
spokenForm:
spokenForm.type === "success"
- ? spokenForm.value
+ ? spokenForm.preferred
: command.spokenForm,
},
hatTokenMap,
diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/customRegex/clearWhite.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/customRegex/clearWhite.yml
index e383561f60..14cb2f4936 100644
--- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/customRegex/clearWhite.yml
+++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/customRegex/clearWhite.yml
@@ -9,7 +9,9 @@ command:
scopeType: {type: customRegex, regex: '\p{Zs}+'}
usePrePhraseSnapshot: true
action: {name: clearAndSetSelection}
-spokenFormError: Scope type 'customRegex'
+spokenFormError: >-
+ custom regex with id \p{Zs}+; please see
+ https://www.cursorless.org/docs/user/customization/ for more information
initialState:
documentContents: "\" \""
selections:
diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint.yml
index 0b74d1458c..d8a196cda6 100644
--- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint.yml
+++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint.yml
@@ -11,7 +11,9 @@ command:
length: 1
usePrePhraseSnapshot: true
action: {name: clearAndSetSelection}
-spokenFormError: Scope type 'customRegex'
+spokenFormError: >-
+ custom regex with id [^\s"'`]+; please see
+ https://www.cursorless.org/docs/user/customization/ for more information
initialState:
documentContents: aaa-bbb ccc-ddd eee-fff ggg-hhh
selections:
diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint2.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint2.yml
index bfb63aa1e6..a60149376b 100644
--- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint2.yml
+++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearFirstPaint2.yml
@@ -11,7 +11,9 @@ command:
length: 1
usePrePhraseSnapshot: true
action: {name: clearAndSetSelection}
-spokenFormError: Scope type 'customRegex'
+spokenFormError: >-
+ custom regex with id [^\s"'`]+; please see
+ https://www.cursorless.org/docs/user/customization/ for more information
initialState:
documentContents: aaa-bbb ccc-ddd eee-fff ggg-hhh
selections:
diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint.yml
index 879c9da17f..d8c3cbe502 100644
--- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint.yml
+++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint.yml
@@ -11,7 +11,9 @@ command:
length: 1
usePrePhraseSnapshot: true
action: {name: clearAndSetSelection}
-spokenFormError: Scope type 'customRegex'
+spokenFormError: >-
+ custom regex with id [^\s"'`]+; please see
+ https://www.cursorless.org/docs/user/customization/ for more information
initialState:
documentContents: aaa-bbb ccc-ddd eee-fff ggg-hhh
selections:
diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint2.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint2.yml
index f46e7fc1e2..cd4a8df92b 100644
--- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint2.yml
+++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/ordinalScopes/clearLastPaint2.yml
@@ -11,7 +11,9 @@ command:
length: 1
usePrePhraseSnapshot: true
action: {name: clearAndSetSelection}
-spokenFormError: Scope type 'customRegex'
+spokenFormError: >-
+ custom regex with id [^\s"'`]+; please see
+ https://www.cursorless.org/docs/user/customization/ for more information
initialState:
documentContents: aaa-bbb ccc-ddd eee-fff ggg-hhh
selections:
diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearCustomRegex.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearCustomRegex.yml
index 24d99bc00c..a192da5d1a 100644
--- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearCustomRegex.yml
+++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearCustomRegex.yml
@@ -9,7 +9,9 @@ command:
scopeType: {type: customRegex, regex: '[\w/_.]+'}
usePrePhraseSnapshot: true
action: {name: clearAndSetSelection}
-spokenFormError: Scope type 'customRegex'
+spokenFormError: >-
+ custom regex with id [\w/_.]+; please see
+ https://www.cursorless.org/docs/user/customization/ for more information
initialState:
documentContents: aa.bb/cc_dd123( )
selections:
diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearEveryCustomRegex.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearEveryCustomRegex.yml
index abe8c17b05..4ef31c9dbd 100644
--- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearEveryCustomRegex.yml
+++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/selectionTypes/clearEveryCustomRegex.yml
@@ -9,7 +9,9 @@ command:
scopeType: {type: customRegex, regex: '[\w/_.]+'}
usePrePhraseSnapshot: true
action: {name: clearAndSetSelection}
-spokenFormError: Scope type 'customRegex'
+spokenFormError: >-
+ custom regex with id [\w/_.]+; please see
+ https://www.cursorless.org/docs/user/customization/ for more information
initialState:
documentContents: aa.bb/cc_dd123 aa.bb/cc_dd123( )
selections:
diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts
new file mode 100644
index 0000000000..0697298880
--- /dev/null
+++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts
@@ -0,0 +1,41 @@
+import { ScopeType, ScopeTypeInfo } from "@cursorless/common";
+import * as sinon from "sinon";
+import { assert } from "chai";
+import { sleepWithBackoff } from "../../endToEndTestSetup";
+import { isEqual } from "lodash";
+
+export async function assertCalledWithScopeInfo(
+ fake: sinon.SinonSpy<[scopeInfos: T[]], void>,
+ ...expectedScopeInfos: T[]
+) {
+ await sleepWithBackoff(25);
+ sinon.assert.called(fake);
+
+ for (const expectedScopeInfo of expectedScopeInfos) {
+ const actualScopeInfo = fake.lastCall.args[0].find((scopeInfo) =>
+ isEqual(scopeInfo.scopeType, expectedScopeInfo.scopeType),
+ );
+ assert.isDefined(actualScopeInfo);
+ assert.deepEqual(actualScopeInfo, expectedScopeInfo);
+ }
+
+ fake.resetHistory();
+}
+
+export async function assertCalledWithoutScopeInfo(
+ fake: sinon.SinonSpy<[scopeInfos: T[]], void>,
+ ...scopeTypes: ScopeType[]
+) {
+ await sleepWithBackoff(25);
+ sinon.assert.called(fake);
+
+ for (const scopeType of scopeTypes) {
+ assert.isUndefined(
+ fake.lastCall.args[0].find((scopeInfo) =>
+ isEqual(scopeInfo.scopeType, scopeType),
+ ),
+ );
+ }
+
+ fake.resetHistory();
+}
diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts
new file mode 100644
index 0000000000..71d624db52
--- /dev/null
+++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts
@@ -0,0 +1,81 @@
+import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common";
+import {
+ ScopeSupport,
+ ScopeSupportInfo,
+ ScopeSupportLevels,
+} from "@cursorless/common";
+import * as sinon from "sinon";
+import { Position, Range, TextDocument, commands } from "vscode";
+import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo";
+
+/**
+ * Tests that the scope provider correctly reports the scope support for a
+ * simple named function.
+ */
+export async function runBasicScopeInfoTest() {
+ const { scopeProvider } = (await getCursorlessApi()).testHelpers!;
+ const fake = sinon.fake<[scopeInfos: ScopeSupportLevels], void>();
+
+ await commands.executeCommand("workbench.action.closeAllEditors");
+
+ const disposable = scopeProvider.onDidChangeScopeSupport(fake);
+
+ try {
+ await assertCalledWithScopeInfo(fake, unsupported);
+
+ const editor = await openNewEditor("", {
+ languageId: "typescript",
+ });
+ await assertCalledWithScopeInfo(fake, supported);
+
+ await editor.edit((editBuilder) => {
+ editBuilder.insert(new Position(0, 0), contents);
+ });
+ await assertCalledWithScopeInfo(fake, present);
+
+ await editor.edit((editBuilder) => {
+ editBuilder.delete(getDocumentRange(editor.document));
+ });
+ await assertCalledWithScopeInfo(fake, supported);
+
+ await commands.executeCommand("workbench.action.closeAllEditors");
+ await assertCalledWithScopeInfo(fake, unsupported);
+ } finally {
+ disposable.dispose();
+ }
+}
+
+function getDocumentRange(textDocument: TextDocument) {
+ const { end } = textDocument.lineAt(textDocument.lineCount - 1).range;
+ return new Range(0, 0, end.line, end.character);
+}
+
+const contents = `
+function helloWorld() {
+
+}
+`;
+
+function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo {
+ return {
+ humanReadableName: "named function",
+ isLanguageSpecific: true,
+ iterationScopeSupport:
+ scopeSupport === ScopeSupport.unsupported
+ ? ScopeSupport.unsupported
+ : ScopeSupport.supportedAndPresentInEditor,
+ scopeType: {
+ type: "namedFunction",
+ },
+ spokenForm: {
+ alternatives: [],
+ preferred: "funk",
+ type: "success",
+ },
+ support: scopeSupport,
+ };
+}
+
+const unsupported = getExpectedScope(ScopeSupport.unsupported);
+const supported = getExpectedScope(ScopeSupport.supportedButNotPresentInEditor);
+const present = getExpectedScope(ScopeSupport.supportedAndPresentInEditor);
diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts
new file mode 100644
index 0000000000..e5db8c6038
--- /dev/null
+++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts
@@ -0,0 +1,105 @@
+import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common";
+import {
+ LATEST_VERSION,
+ ScopeSupport,
+ ScopeSupportInfo,
+ ScopeSupportLevels,
+ ScopeType,
+ sleep,
+} from "@cursorless/common";
+import * as sinon from "sinon";
+import {
+ assertCalledWithScopeInfo,
+ assertCalledWithoutScopeInfo,
+} from "./assertCalledWithScopeInfo";
+import { stat, unlink, writeFile } from "fs/promises";
+import { sleepWithBackoff } from "../../endToEndTestSetup";
+import { commands } from "vscode";
+
+/**
+ * Tests that the scope provider correctly reports the scope support for a
+ * simple named function.
+ */
+export async function runCustomRegexScopeInfoTest() {
+ const { scopeProvider, spokenFormsJsonPath } = (await getCursorlessApi())
+ .testHelpers!;
+ const fake = sinon.fake<[scopeInfos: ScopeSupportLevels], void>();
+
+ await commands.executeCommand("workbench.action.closeAllEditors");
+
+ const disposable = scopeProvider.onDidChangeScopeSupport(fake);
+
+ try {
+ await assertCalledWithoutScopeInfo(fake, scopeType);
+
+ await writeFile(
+ spokenFormsJsonPath,
+ JSON.stringify(spokenFormJsonContents),
+ );
+ await sleepWithBackoff(50);
+ await assertCalledWithScopeInfo(fake, unsupported);
+
+ await openNewEditor(contents);
+ await assertCalledWithScopeInfo(fake, present);
+
+ await unlink(spokenFormsJsonPath);
+ await sleepWithBackoff(50);
+ await assertCalledWithoutScopeInfo(fake, scopeType);
+ } finally {
+ disposable.dispose();
+
+ // Delete spokenFormsJsonPath if it exists
+ try {
+ await stat(spokenFormsJsonPath);
+ await unlink(spokenFormsJsonPath);
+ // Sleep to ensure that the scope support provider has time to update
+ // before the next test starts
+ await sleep(250);
+ } catch (e) {
+ // Do nothing
+ }
+ }
+}
+
+const contents = `
+hello world
+`;
+
+const regex = "[a-zA-Z]+";
+
+const spokenFormJsonContents = {
+ version: LATEST_VERSION,
+ entries: [
+ {
+ type: "customRegex",
+ id: regex,
+ spokenForms: ["spaghetti"],
+ },
+ ],
+};
+
+const scopeType: ScopeType = {
+ type: "customRegex",
+ regex,
+};
+
+function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo {
+ return {
+ humanReadableName: "Regex `[a-zA-Z]+`",
+ isLanguageSpecific: false,
+ iterationScopeSupport:
+ scopeSupport === ScopeSupport.unsupported
+ ? ScopeSupport.unsupported
+ : ScopeSupport.supportedAndPresentInEditor,
+ scopeType,
+ spokenForm: {
+ alternatives: [],
+ preferred: "spaghetti",
+ type: "success",
+ },
+ support: scopeSupport,
+ };
+}
+
+const unsupported = getExpectedScope(ScopeSupport.unsupported);
+const present = getExpectedScope(ScopeSupport.supportedAndPresentInEditor);
diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts
new file mode 100644
index 0000000000..0afbe0ad8a
--- /dev/null
+++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts
@@ -0,0 +1,235 @@
+import { getCursorlessApi } from "@cursorless/vscode-common";
+import { LATEST_VERSION, ScopeTypeInfo, sleep } from "@cursorless/common";
+import * as sinon from "sinon";
+import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo";
+import { stat, unlink, writeFile } from "fs/promises";
+import { sleepWithBackoff } from "../../endToEndTestSetup";
+
+/**
+ * Tests that the scope provider correctly reports custom spoken forms
+ */
+export async function runCustomSpokenFormScopeInfoTest() {
+ const { scopeProvider, spokenFormsJsonPath } = (await getCursorlessApi())
+ .testHelpers!;
+ const fake = sinon.fake<[scopeInfos: ScopeTypeInfo[]], void>();
+
+ const disposable = scopeProvider.onDidChangeScopeInfo(fake);
+
+ try {
+ await assertCalledWithScopeInfo(
+ fake,
+ roundStandard,
+ namedFunctionStandard,
+ lambdaStandard,
+ statementStandard,
+ squareStandard,
+ subjectStandard,
+ );
+
+ await writeFile(
+ spokenFormsJsonPath,
+ JSON.stringify(spokenFormJsonContents),
+ );
+ await sleepWithBackoff(50);
+ await assertCalledWithScopeInfo(
+ fake,
+ subjectCustom,
+ roundCustom,
+ namedFunctionCustom,
+ lambdaCustom,
+ statementMissing,
+ squareMissing,
+ );
+
+ await unlink(spokenFormsJsonPath);
+ await sleepWithBackoff(50);
+ await assertCalledWithScopeInfo(
+ fake,
+ roundStandard,
+ namedFunctionStandard,
+ lambdaStandard,
+ statementStandard,
+ squareStandard,
+ subjectStandard,
+ );
+ } finally {
+ disposable.dispose();
+
+ // Delete spokenFormsJsonPath if it exists
+ try {
+ await stat(spokenFormsJsonPath);
+ await unlink(spokenFormsJsonPath);
+ // Sleep to ensure that the scope support provider has time to update
+ // before the next test starts
+ await sleep(250);
+ } catch (e) {
+ // Do nothing
+ }
+ }
+}
+
+const spokenFormJsonContents = {
+ version: LATEST_VERSION,
+ entries: [
+ {
+ type: "pairedDelimiter",
+ id: "parentheses",
+ spokenForms: ["custom round", "alternate custom round"],
+ },
+ {
+ type: "simpleScopeTypeType",
+ id: "switchStatementSubject",
+ spokenForms: ["custom subject"],
+ },
+ {
+ type: "simpleScopeTypeType",
+ id: "namedFunction",
+ spokenForms: ["custom funk"],
+ },
+ {
+ type: "simpleScopeTypeType",
+ id: "anonymousFunction",
+ spokenForms: [],
+ },
+ ],
+};
+
+const subjectStandard: ScopeTypeInfo = {
+ humanReadableName: "switch statement subject",
+ isLanguageSpecific: true,
+ scopeType: { type: "switchStatementSubject" },
+ spokenForm: {
+ isSecret: true,
+ reason:
+ "simple scope type type with id switchStatementSubject; please see https://www.cursorless.org/docs/user/customization/ for more information",
+ requiresTalonUpdate: false,
+ type: "error",
+ },
+};
+
+const subjectCustom: ScopeTypeInfo = {
+ humanReadableName: "switch statement subject",
+ isLanguageSpecific: true,
+ scopeType: { type: "switchStatementSubject" },
+ spokenForm: {
+ alternatives: [],
+ preferred: "custom subject",
+ type: "success",
+ },
+};
+
+const roundStandard: ScopeTypeInfo = {
+ humanReadableName: "Matching pair of parentheses",
+ isLanguageSpecific: false,
+ scopeType: { type: "surroundingPair", delimiter: "parentheses" },
+ spokenForm: {
+ alternatives: [],
+ preferred: "round",
+ type: "success",
+ },
+};
+
+const roundCustom: ScopeTypeInfo = {
+ humanReadableName: "Matching pair of parentheses",
+ isLanguageSpecific: false,
+ scopeType: { type: "surroundingPair", delimiter: "parentheses" },
+ spokenForm: {
+ alternatives: ["alternate custom round"],
+ preferred: "custom round",
+ type: "success",
+ },
+};
+
+const squareStandard: ScopeTypeInfo = {
+ humanReadableName: "Matching pair of square brackets",
+ isLanguageSpecific: false,
+ scopeType: { type: "surroundingPair", delimiter: "squareBrackets" },
+ spokenForm: {
+ alternatives: [],
+ preferred: "box",
+ type: "success",
+ },
+};
+
+const squareMissing: ScopeTypeInfo = {
+ humanReadableName: "Matching pair of square brackets",
+ isLanguageSpecific: false,
+ scopeType: { type: "surroundingPair", delimiter: "squareBrackets" },
+ spokenForm: {
+ isSecret: false,
+ reason:
+ "paired delimiter with id squareBrackets; please see https://www.cursorless.org/docs/user/customization/ for more information",
+ requiresTalonUpdate: true,
+ type: "error",
+ },
+};
+
+const namedFunctionStandard: ScopeTypeInfo = {
+ humanReadableName: "named function",
+ isLanguageSpecific: true,
+ scopeType: { type: "namedFunction" },
+ spokenForm: {
+ alternatives: [],
+ preferred: "funk",
+ type: "success",
+ },
+};
+
+const namedFunctionCustom: ScopeTypeInfo = {
+ humanReadableName: "named function",
+ isLanguageSpecific: true,
+ scopeType: { type: "namedFunction" },
+ spokenForm: {
+ alternatives: [],
+ preferred: "custom funk",
+ type: "success",
+ },
+};
+
+const lambdaStandard: ScopeTypeInfo = {
+ humanReadableName: "anonymous function",
+ isLanguageSpecific: true,
+ scopeType: { type: "anonymousFunction" },
+ spokenForm: {
+ alternatives: [],
+ preferred: "lambda",
+ type: "success",
+ },
+};
+
+const lambdaCustom: ScopeTypeInfo = {
+ humanReadableName: "anonymous function",
+ isLanguageSpecific: true,
+ scopeType: { type: "anonymousFunction" },
+ spokenForm: {
+ isSecret: false,
+ reason:
+ "simple scope type type with id anonymousFunction; please see https://www.cursorless.org/docs/user/customization/ for more information",
+ requiresTalonUpdate: false,
+ type: "error",
+ },
+};
+
+const statementStandard: ScopeTypeInfo = {
+ humanReadableName: "statement",
+ isLanguageSpecific: true,
+ scopeType: { type: "statement" },
+ spokenForm: {
+ alternatives: [],
+ preferred: "state",
+ type: "success",
+ },
+};
+
+const statementMissing: ScopeTypeInfo = {
+ humanReadableName: "statement",
+ isLanguageSpecific: true,
+ scopeType: { type: "statement" },
+ spokenForm: {
+ isSecret: false,
+ reason:
+ "simple scope type type with id statement; please see https://www.cursorless.org/docs/user/customization/ for more information",
+ requiresTalonUpdate: true,
+ type: "error",
+ },
+};
diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runSurroundingPairScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runSurroundingPairScopeInfoTest.ts
new file mode 100644
index 0000000000..8889f187d1
--- /dev/null
+++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runSurroundingPairScopeInfoTest.ts
@@ -0,0 +1,55 @@
+import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common";
+import {
+ ScopeSupport,
+ ScopeSupportInfo,
+ ScopeSupportLevels,
+} from "@cursorless/common";
+import * as sinon from "sinon";
+import { commands } from "vscode";
+import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo";
+
+/**
+ * Tests that the scope provider correctly reports the scope support for a
+ * simple surrounding pair.
+ */
+export async function runSurroundingPairScopeInfoTest() {
+ const { scopeProvider } = (await getCursorlessApi()).testHelpers!;
+ const fake = sinon.fake<[scopeInfos: ScopeSupportLevels], void>();
+
+ await commands.executeCommand("workbench.action.closeAllEditors");
+
+ const disposable = scopeProvider.onDidChangeScopeSupport(fake);
+
+ try {
+ await assertCalledWithScopeInfo(fake, unsupported);
+
+ await openNewEditor("");
+ await assertCalledWithScopeInfo(fake, legacy);
+
+ await commands.executeCommand("workbench.action.closeAllEditors");
+ await assertCalledWithScopeInfo(fake, unsupported);
+ } finally {
+ disposable.dispose();
+ }
+}
+
+function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo {
+ return {
+ humanReadableName: "Matching pair of parentheses",
+ isLanguageSpecific: false,
+ iterationScopeSupport:
+ scopeSupport === ScopeSupport.unsupported
+ ? ScopeSupport.unsupported
+ : ScopeSupport.supportedLegacy,
+ scopeType: { type: "surroundingPair", delimiter: "parentheses" },
+ spokenForm: {
+ alternatives: [],
+ preferred: "round",
+ type: "success",
+ },
+ support: scopeSupport,
+ };
+}
+
+const unsupported = getExpectedScope(ScopeSupport.unsupported);
+const legacy = getExpectedScope(ScopeSupport.supportedLegacy);
diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts
new file mode 100644
index 0000000000..dd0a5ed94e
--- /dev/null
+++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts
@@ -0,0 +1,27 @@
+import { asyncSafety } from "@cursorless/common";
+import { endToEndTestSetup } from "../../endToEndTestSetup";
+import { runBasicScopeInfoTest } from "./runBasicScopeInfoTest";
+import { runCustomRegexScopeInfoTest } from "./runCustomRegexScopeInfoTest";
+import { runCustomSpokenFormScopeInfoTest } from "./runCustomSpokenFormScopeInfoTest";
+import { runSurroundingPairScopeInfoTest } from "./runSurroundingPairScopeInfoTest";
+
+suite("scope provider", async function () {
+ endToEndTestSetup(this);
+
+ test(
+ "basic",
+ asyncSafety(() => runBasicScopeInfoTest()),
+ );
+ test(
+ "surrounding pair",
+ asyncSafety(() => runSurroundingPairScopeInfoTest()),
+ );
+ test(
+ "custom spoken form",
+ asyncSafety(() => runCustomSpokenFormScopeInfoTest()),
+ );
+ test(
+ "custom regex",
+ asyncSafety(() => runCustomRegexScopeInfoTest()),
+ );
+});
diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json
index 9b9f03e9fa..1b185bf957 100644
--- a/packages/cursorless-vscode/package.json
+++ b/packages/cursorless-vscode/package.json
@@ -46,6 +46,7 @@
],
"activationEvents": [
"onLanguage",
+ "onView:cursorless.scopes",
"onCommand:cursorless.command",
"onCommand:cursorless.internal.updateCheatsheetDefaults",
"onCommand:cursorless.keyboard.escape",
@@ -77,6 +78,14 @@
}
},
"contributes": {
+ "views": {
+ "cursorless": [
+ {
+ "id": "cursorless.scopes",
+ "name": "Scopes"
+ }
+ ]
+ },
"commands": [
{
"command": "cursorless.toggleDecorations",
@@ -106,6 +115,14 @@
"command": "cursorless.showDocumentation",
"title": "Cursorless: Show documentation"
},
+ {
+ "command": "cursorless.showScopeVisualizer",
+ "title": "Cursorless: Show the scope visualizer"
+ },
+ {
+ "command": "cursorless.hideScopeVisualizer",
+ "title": "Cursorless: Hide the scope visualizer"
+ },
{
"command": "cursorless.command",
"title": "Cursorless: The core cursorless command",
@@ -175,16 +192,6 @@
"command": "cursorless.keyboard.modal.modeToggle",
"title": "Cursorless: Toggle the cursorless modal mode",
"enablement": "false"
- },
- {
- "command": "cursorless.showScopeVisualizer",
- "title": "Cursorless: Show the scope visualizer",
- "enablement": "false"
- },
- {
- "command": "cursorless.hideScopeVisualizer",
- "title": "Cursorless: Hide the scope visualizer",
- "enablement": "false"
}
],
"colors": [
@@ -1032,6 +1039,15 @@
"fontCharacter": "\\E900"
}
}
+ },
+ "viewsContainers": {
+ "activitybar": [
+ {
+ "id": "cursorless",
+ "title": "Cursorless",
+ "icon": "images/icon.svg"
+ }
+ ]
}
},
"sponsor": {
diff --git a/packages/cursorless-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts
new file mode 100644
index 0000000000..00a4ec4f53
--- /dev/null
+++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts
@@ -0,0 +1,314 @@
+import {
+ CursorlessCommandId,
+ Disposer,
+ ScopeProvider,
+ ScopeSupport,
+ ScopeSupportLevels,
+ ScopeTypeInfo,
+} from "@cursorless/common";
+import { CustomSpokenFormGenerator } from "@cursorless/cursorless-engine";
+import { VscodeApi } from "@cursorless/vscode-common";
+import { CURSORLESS_SCOPE_TREE_VIEW_ID } from "@cursorless/vscode-common";
+import { isEqual } from "lodash";
+import type {
+ Event,
+ ExtensionContext,
+ TreeDataProvider,
+ TreeItemLabel,
+ TreeView,
+ TreeViewVisibilityChangeEvent,
+} from "vscode";
+import {
+ EventEmitter,
+ ThemeIcon,
+ TreeItem,
+ TreeItemCollapsibleState,
+} from "vscode";
+import { URI } from "vscode-uri";
+import {
+ ScopeVisualizer,
+ VisualizationType,
+} from "./ScopeVisualizerCommandApi";
+
+export const DONT_SHOW_TALON_UPDATE_MESSAGE_KEY = "dontShowUpdateTalonMessage";
+
+export class ScopeTreeProvider implements TreeDataProvider {
+ private visibleDisposable: Disposer | undefined;
+ private treeView: TreeView;
+ private supportLevels: ScopeSupportLevels = [];
+ private shownUpdateTalonMessage = false;
+
+ private _onDidChangeTreeData: EventEmitter<
+ MyTreeItem | undefined | null | void
+ > = new EventEmitter();
+ readonly onDidChangeTreeData: Event =
+ this._onDidChangeTreeData.event;
+
+ constructor(
+ private vscodeApi: VscodeApi,
+ private context: ExtensionContext,
+ private scopeProvider: ScopeProvider,
+ private scopeVisualizer: ScopeVisualizer,
+ private customSpokenFormGenerator: CustomSpokenFormGenerator,
+ private hasCommandServer: boolean,
+ ) {
+ this.treeView = vscodeApi.window.createTreeView(
+ CURSORLESS_SCOPE_TREE_VIEW_ID,
+ {
+ treeDataProvider: this,
+ },
+ );
+
+ this.context.subscriptions.push(
+ this.treeView,
+ this.treeView.onDidChangeVisibility(this.onDidChangeVisible, this),
+ this,
+ );
+ }
+
+ static create(
+ vscodeApi: VscodeApi,
+ context: ExtensionContext,
+ scopeProvider: ScopeProvider,
+ scopeVisualizer: ScopeVisualizer,
+ customSpokenFormGenerator: CustomSpokenFormGenerator,
+ hasCommandServer: boolean,
+ ): ScopeTreeProvider {
+ const treeProvider = new ScopeTreeProvider(
+ vscodeApi,
+ context,
+ scopeProvider,
+ scopeVisualizer,
+ customSpokenFormGenerator,
+ hasCommandServer,
+ );
+ treeProvider.init();
+ return treeProvider;
+ }
+
+ init() {
+ if (this.treeView.visible) {
+ this.registerScopeSupportListener();
+ }
+ }
+
+ onDidChangeVisible(e: TreeViewVisibilityChangeEvent) {
+ if (e.visible) {
+ if (this.visibleDisposable != null) {
+ return;
+ }
+
+ this.registerScopeSupportListener();
+ } else {
+ if (this.visibleDisposable == null) {
+ return;
+ }
+
+ this.visibleDisposable.dispose();
+ this.visibleDisposable = undefined;
+ }
+ }
+
+ private registerScopeSupportListener() {
+ this.visibleDisposable = new Disposer();
+ this.visibleDisposable.push(
+ this.scopeProvider.onDidChangeScopeSupport((supportLevels) => {
+ this.supportLevels = supportLevels;
+ this._onDidChangeTreeData.fire();
+ }),
+ this.scopeVisualizer.onDidChangeScopeType(() => {
+ this._onDidChangeTreeData.fire();
+ }),
+ );
+ }
+
+ getTreeItem(element: MyTreeItem): MyTreeItem {
+ return element;
+ }
+
+ getChildren(element?: MyTreeItem): MyTreeItem[] {
+ if (element == null) {
+ this.possiblyShowUpdateTalonMessage();
+ return getSupportCategories();
+ }
+
+ if (element instanceof SupportCategoryTreeItem) {
+ return this.getScopeTypesWithSupport(element.scopeSupport);
+ }
+
+ throw new Error("Unexpected element");
+ }
+
+ private async possiblyShowUpdateTalonMessage() {
+ if (
+ !this.customSpokenFormGenerator.needsInitialTalonUpdate ||
+ this.shownUpdateTalonMessage ||
+ !this.hasCommandServer ||
+ (await this.context.globalState.get(DONT_SHOW_TALON_UPDATE_MESSAGE_KEY))
+ ) {
+ return;
+ }
+
+ this.shownUpdateTalonMessage = true;
+
+ const HOW_BUTTON_TEXT = "How?";
+ const DONT_SHOW_AGAIN_BUTTON_TEXT = "Don't show again";
+ const result = await this.vscodeApi.window.showInformationMessage(
+ "In order to see your custom spoken forms in the sidebar, you'll need to update your Cursorless Talon files.",
+ HOW_BUTTON_TEXT,
+ DONT_SHOW_AGAIN_BUTTON_TEXT,
+ );
+
+ if (result === HOW_BUTTON_TEXT) {
+ await this.vscodeApi.env.openExternal(
+ URI.parse(
+ "https://www.cursorless.org/docs/user/updating/#updating-the-talon-side",
+ ),
+ );
+ } else if (result === DONT_SHOW_AGAIN_BUTTON_TEXT) {
+ await this.context.globalState.update(
+ DONT_SHOW_TALON_UPDATE_MESSAGE_KEY,
+ true,
+ );
+ }
+ }
+
+ getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] {
+ return this.supportLevels
+ .filter(
+ (supportLevel) =>
+ supportLevel.support === scopeSupport &&
+ (supportLevel.spokenForm.type !== "error" ||
+ !supportLevel.spokenForm.isSecret),
+ )
+ .map(
+ (supportLevel) =>
+ new ScopeSupportTreeItem(
+ supportLevel,
+ isEqual(supportLevel.scopeType, this.scopeVisualizer.scopeType),
+ ),
+ )
+ .sort((a, b) => {
+ if (
+ a.scopeTypeInfo.spokenForm.type !== b.scopeTypeInfo.spokenForm.type
+ ) {
+ return a.scopeTypeInfo.spokenForm.type === "error" ? 1 : -1;
+ }
+
+ if (
+ a.scopeTypeInfo.isLanguageSpecific !==
+ b.scopeTypeInfo.isLanguageSpecific
+ ) {
+ return a.scopeTypeInfo.isLanguageSpecific ? -1 : 1;
+ }
+
+ return a.label.label.localeCompare(b.label.label);
+ });
+ }
+
+ dispose() {
+ this.visibleDisposable?.dispose();
+ }
+}
+
+function getSupportCategories(): SupportCategoryTreeItem[] {
+ return [
+ new SupportCategoryTreeItem(
+ "Present",
+ ScopeSupport.supportedAndPresentInEditor,
+ TreeItemCollapsibleState.Expanded,
+ ),
+ new SupportCategoryTreeItem(
+ "Not present",
+ ScopeSupport.supportedButNotPresentInEditor,
+ TreeItemCollapsibleState.Expanded,
+ ),
+ new SupportCategoryTreeItem(
+ "Legacy",
+ ScopeSupport.supportedLegacy,
+ TreeItemCollapsibleState.Expanded,
+ ),
+ new SupportCategoryTreeItem(
+ "Unsupported",
+ ScopeSupport.unsupported,
+ TreeItemCollapsibleState.Collapsed,
+ ),
+ ];
+}
+
+class ScopeSupportTreeItem extends TreeItem {
+ public label: TreeItemLabel;
+
+ /**
+ * @param scopeTypeInfo The scope type info
+ * @param isVisualized Whether the scope type is currently being visualized
+ with the scope visualizer
+ */
+ constructor(
+ public scopeTypeInfo: ScopeTypeInfo,
+ isVisualized: boolean,
+ ) {
+ const label =
+ scopeTypeInfo.spokenForm.type === "error"
+ ? "-"
+ : `"${scopeTypeInfo.spokenForm.preferred}"`;
+ const description = scopeTypeInfo.humanReadableName;
+
+ super(label, TreeItemCollapsibleState.None);
+
+ const requiresTalonUpdate =
+ scopeTypeInfo.spokenForm.type === "error" &&
+ scopeTypeInfo.spokenForm.requiresTalonUpdate;
+
+ this.label = {
+ label,
+ highlights: isVisualized ? [[0, label.length]] : [],
+ };
+
+ this.description = description;
+
+ if (scopeTypeInfo.spokenForm.type === "success") {
+ if (scopeTypeInfo.spokenForm.alternatives.length > 0) {
+ this.tooltip = scopeTypeInfo.spokenForm.alternatives
+ .map((spokenForm) => `"${spokenForm}"`)
+ .join("\n");
+ }
+ } else if (requiresTalonUpdate) {
+ this.tooltip = "Requires Talon update";
+ } else {
+ this.tooltip = "Spoken form disabled; see customization docs";
+ }
+
+ this.command = isVisualized
+ ? {
+ command:
+ "cursorless.hideScopeVisualizer" satisfies CursorlessCommandId,
+ title: "Hide the scope visualizer",
+ }
+ : {
+ command:
+ "cursorless.showScopeVisualizer" satisfies CursorlessCommandId,
+ arguments: [
+ scopeTypeInfo.scopeType,
+ "content" satisfies VisualizationType,
+ ],
+ title: `Visualize ${scopeTypeInfo.humanReadableName}`,
+ };
+
+ if (scopeTypeInfo.isLanguageSpecific) {
+ this.iconPath = new ThemeIcon("code");
+ }
+ }
+}
+
+class SupportCategoryTreeItem extends TreeItem {
+ constructor(
+ label: string,
+ public readonly scopeSupport: ScopeSupport,
+ collapsibleState: TreeItemCollapsibleState,
+ ) {
+ super(label, collapsibleState);
+ }
+}
+
+type MyTreeItem = ScopeSupportTreeItem | SupportCategoryTreeItem;
diff --git a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts
index 77dcc80c6a..2bdafaaf9f 100644
--- a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts
+++ b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts
@@ -1,8 +1,15 @@
-import { ScopeType } from "@cursorless/common";
+import { Disposable, ScopeType } from "@cursorless/common";
-export interface ScopeVisualizerCommandApi {
+export type VisualizerScopeTypeListener = (
+ scopeType: ScopeType | undefined,
+ visualizationType: VisualizationType | undefined,
+) => void;
+
+export interface ScopeVisualizer {
start(scopeType: ScopeType, visualizationType: VisualizationType): void;
stop(): void;
+ readonly scopeType: ScopeType | undefined;
+ onDidChangeScopeType(listener: VisualizerScopeTypeListener): Disposable;
}
export type VisualizationType = "content" | "removal" | "iteration";
diff --git a/packages/cursorless-vscode/src/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts
index a68539edb0..4e3ec799a6 100644
--- a/packages/cursorless-vscode/src/constructTestHelpers.ts
+++ b/packages/cursorless-vscode/src/constructTestHelpers.ts
@@ -5,6 +5,7 @@ import {
HatTokenMap,
IDE,
NormalizedIDE,
+ ScopeProvider,
SerializedMarks,
TargetPlainObject,
TestCaseSnapshot,
@@ -28,6 +29,8 @@ export function constructTestHelpers(
hatTokenMap: HatTokenMap,
vscodeIDE: VscodeIDE,
normalizedIde: NormalizedIDE,
+ spokenFormsJsonPath: string,
+ scopeProvider: ScopeProvider,
injectIde: (ide: IDE) => void,
runIntegrationTests: () => Promise,
): TestHelpers | undefined {
@@ -35,6 +38,7 @@ export function constructTestHelpers(
commandServerApi: commandServerApi!,
ide: normalizedIde,
injectIde,
+ scopeProvider,
toVscodeEditor,
@@ -61,6 +65,8 @@ export function constructTestHelpers(
);
},
+ spokenFormsJsonPath,
+
setStoredTarget(
editor: vscode.TextEditor,
key: StoredTargetKey,
diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts
index 941817b47d..c535191832 100644
--- a/packages/cursorless-vscode/src/extension.ts
+++ b/packages/cursorless-vscode/src/extension.ts
@@ -1,16 +1,17 @@
import {
+ Disposable,
FakeIDE,
getFakeCommandServerApi,
IDE,
isTesting,
NormalizedIDE,
Range,
+ ScopeProvider,
ScopeType,
TextDocument,
} from "@cursorless/common";
import {
createCursorlessEngine,
- ScopeProvider,
TreeSitter,
} from "@cursorless/cursorless-engine";
import {
@@ -34,12 +35,15 @@ import {
import { KeyboardCommands } from "./keyboard/KeyboardCommands";
import { registerCommands } from "./registerCommands";
import { ReleaseNotes } from "./ReleaseNotes";
+import { ScopeTreeProvider } from "./ScopeTreeProvider";
import {
- ScopeVisualizerCommandApi,
+ ScopeVisualizer,
+ VisualizerScopeTypeListener as ScopeVisualizerListener,
VisualizationType,
} from "./ScopeVisualizerCommandApi";
import { StatusBarItem } from "./StatusBarItem";
import { vscodeApi } from "./vscodeApi";
+import { revisualizeOnCustomRegexChange } from "./revisualizeOnCustomRegexChange";
/**
* Extension entrypoint called by VSCode on Cursorless startup.
@@ -80,7 +84,9 @@ export async function activate(
scopeProvider,
snippets,
injectIde,
+ spokenFormsJsonPath,
runIntegrationTests,
+ customSpokenFormGenerator,
} = createCursorlessEngine(
treeSitter,
normalizedIde,
@@ -91,13 +97,25 @@ export async function activate(
const statusBarItem = StatusBarItem.create("cursorless.showQuickPick");
const keyboardCommands = KeyboardCommands.create(context, statusBarItem);
+ const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider);
+ context.subscriptions.push(
+ revisualizeOnCustomRegexChange(scopeVisualizer, scopeProvider),
+ );
+ ScopeTreeProvider.create(
+ vscodeApi,
+ context,
+ scopeProvider,
+ scopeVisualizer,
+ customSpokenFormGenerator,
+ commandServerApi != null,
+ );
registerCommands(
context,
vscodeIDE,
commandApi,
testCaseRecorder,
- createScopeVisualizerCommandApi(normalizedIde, scopeProvider),
+ scopeVisualizer,
keyboardCommands,
hats,
);
@@ -112,6 +130,8 @@ export async function activate(
hatTokenMap,
vscodeIDE,
normalizedIde as NormalizedIDE,
+ spokenFormsJsonPath,
+ scopeProvider,
injectIde,
runIntegrationTests,
)
@@ -155,11 +175,14 @@ function createTreeSitter(parseTreeApi: ParseTreeApi): TreeSitter {
};
}
-function createScopeVisualizerCommandApi(
+function createScopeVisualizer(
ide: IDE,
scopeProvider: ScopeProvider,
-): ScopeVisualizerCommandApi {
+): ScopeVisualizer {
let scopeVisualizer: VscodeScopeVisualizer | undefined;
+ let currentScopeType: ScopeType | undefined;
+
+ const listeners: ScopeVisualizerListener[] = [];
return {
start(scopeType: ScopeType, visualizationType: VisualizationType) {
@@ -171,11 +194,29 @@ function createScopeVisualizerCommandApi(
visualizationType,
);
scopeVisualizer.start();
+ currentScopeType = scopeType;
+ listeners.forEach((listener) => listener(scopeType, visualizationType));
},
stop() {
scopeVisualizer?.dispose();
scopeVisualizer = undefined;
+ currentScopeType = undefined;
+ listeners.forEach((listener) => listener(undefined, undefined));
+ },
+
+ get scopeType() {
+ return currentScopeType;
+ },
+
+ onDidChangeScopeType(listener: ScopeVisualizerListener): Disposable {
+ listeners.push(listener);
+
+ return {
+ dispose() {
+ listeners.splice(listeners.indexOf(listener), 1);
+ },
+ };
},
};
}
diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts
index b160f8631c..da3dca3fef 100644
--- a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts
+++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeIterationScopeVisualizer.ts
@@ -1,7 +1,11 @@
-import { Disposable, TextEditor, toCharacterRange } from "@cursorless/common";
+import {
+ Disposable,
+ ScopeSupport,
+ TextEditor,
+ toCharacterRange,
+} from "@cursorless/common";
import { VscodeTextEditorImpl } from "../VscodeTextEditorImpl";
import { VscodeScopeVisualizer } from "./VscodeScopeVisualizer";
-import { ScopeSupport } from "@cursorless/cursorless-engine";
export class VscodeIterationScopeVisualizer extends VscodeScopeVisualizer {
protected getScopeSupport(editor: TextEditor): ScopeSupport {
diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts
index 6303d6ee3c..f52862a400 100644
--- a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts
+++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeTargetVisualizer.ts
@@ -1,10 +1,11 @@
import {
Disposable,
GeneralizedRange,
+ ScopeSupport,
+ TargetRanges,
TextEditor,
toCharacterRange,
} from "@cursorless/common";
-import { ScopeSupport, TargetRanges } from "@cursorless/cursorless-engine";
import { VscodeScopeVisualizer } from ".";
import { VscodeTextEditorImpl } from "../VscodeTextEditorImpl";
diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts
index 862a75e41b..a5e9c69e58 100644
--- a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts
+++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/VscodeScopeVisualizer.ts
@@ -1,11 +1,12 @@
import {
Disposable,
IDE,
+ ScopeProvider,
+ ScopeSupport,
ScopeType,
TextEditor,
showError,
} from "@cursorless/common";
-import { ScopeProvider, ScopeSupport } from "@cursorless/cursorless-engine";
import {
ScopeRangeType,
ScopeVisualizerColorConfig,
diff --git a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts
index 4abd4b6515..aad6cd3ec9 100644
--- a/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts
+++ b/packages/cursorless-vscode/src/ide/vscode/VSCodeScopeVisualizer/createVscodeScopeVisualizer.ts
@@ -1,5 +1,4 @@
-import { IDE, ScopeType } from "@cursorless/common";
-import { ScopeProvider } from "@cursorless/cursorless-engine";
+import { IDE, ScopeProvider, ScopeType } from "@cursorless/common";
import { VisualizationType } from "../../../ScopeVisualizerCommandApi";
import { VscodeIterationScopeVisualizer } from "./VscodeIterationScopeVisualizer";
import {
diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts
index 49da4f0017..8387de3cba 100644
--- a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts
+++ b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts
@@ -1,52 +1,16 @@
-import {
- Disposable,
- FileSystem,
- PathChangeListener,
- walkFiles,
-} from "@cursorless/common";
-import { stat } from "fs/promises";
-import { max } from "lodash";
+import { Disposable, FileSystem, PathChangeListener } from "@cursorless/common";
+import { RelativePattern, Uri, workspace } from "vscode";
export class VscodeFileSystem implements FileSystem {
- watchDir(path: string, onDidChange: PathChangeListener): Disposable {
- // Just poll for now; we can take advantage of VSCode's sophisticated
- // watcher later. Note that we would need to do a version check, as VSCode
- // file watcher is only available in more recent versions of VSCode.
- return new PollingFileSystemWatcher(path, onDidChange);
- }
-}
-
-const CHECK_INTERVAL_MS = 1000;
-
-class PollingFileSystemWatcher implements Disposable {
- private maxMtimeMs: number = -1;
- private timer: NodeJS.Timer;
-
- constructor(
- private readonly path: string,
- private readonly onDidChange: PathChangeListener,
- ) {
- this.checkForChanges = this.checkForChanges.bind(this);
- this.timer = setInterval(this.checkForChanges, CHECK_INTERVAL_MS);
- }
-
- private async checkForChanges() {
- const paths = await walkFiles(this.path);
-
- const maxMtime =
- max(
- (await Promise.all(paths.map((file) => stat(file)))).map(
- (stat) => stat.mtimeMs,
- ),
- ) ?? 0;
-
- if (maxMtime > this.maxMtimeMs) {
- this.maxMtimeMs = maxMtime;
- this.onDidChange();
- }
- }
-
- dispose() {
- clearInterval(this.timer);
+ watch(path: string, onDidChange: PathChangeListener): Disposable {
+ console.log(`path: ${path}`);
+ // FIXME: Support globs?
+ const watcher = workspace.createFileSystemWatcher(
+ new RelativePattern(Uri.file(path), "**"),
+ );
+ watcher.onDidChange(onDidChange);
+ watcher.onDidCreate(onDidChange);
+ watcher.onDidDelete(onDidChange);
+ return watcher;
}
}
diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts
index 760524d893..6e0f2744c9 100644
--- a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts
+++ b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts
@@ -1,7 +1,8 @@
-import type { ExtensionContext } from "vscode";
import type { State, StateData, StateKey } from "@cursorless/common";
import { STATE_DEFAULTS } from "@cursorless/common";
+import type { ExtensionContext } from "vscode";
import { VERSION_KEY } from "../../ReleaseNotes";
+import { DONT_SHOW_TALON_UPDATE_MESSAGE_KEY } from "../../ScopeTreeProvider";
export default class VscodeGlobalState implements State {
constructor(private extensionContext: ExtensionContext) {
@@ -9,6 +10,7 @@ export default class VscodeGlobalState implements State {
extensionContext.globalState.setKeysForSync([
...Object.keys(STATE_DEFAULTS),
VERSION_KEY,
+ DONT_SHOW_TALON_UPDATE_MESSAGE_KEY,
]);
}
diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts
index 55c076ea8c..18b18c6299 100644
--- a/packages/cursorless-vscode/src/registerCommands.ts
+++ b/packages/cursorless-vscode/src/registerCommands.ts
@@ -14,14 +14,14 @@ import { showDocumentation, showQuickPick } from "./commands";
import { VscodeIDE } from "./ide/vscode/VscodeIDE";
import { VscodeHats } from "./ide/vscode/hats/VscodeHats";
import { KeyboardCommands } from "./keyboard/KeyboardCommands";
-import { ScopeVisualizerCommandApi } from "./ScopeVisualizerCommandApi";
+import { ScopeVisualizer } from "./ScopeVisualizerCommandApi";
export function registerCommands(
extensionContext: vscode.ExtensionContext,
vscodeIde: VscodeIDE,
commandApi: CommandApi,
testCaseRecorder: TestCaseRecorder,
- scopeVisualizer: ScopeVisualizerCommandApi,
+ scopeVisualizer: ScopeVisualizer,
keyboardCommands: KeyboardCommands,
hats: VscodeHats,
): void {
diff --git a/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts b/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts
new file mode 100644
index 0000000000..cfc913bc64
--- /dev/null
+++ b/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts
@@ -0,0 +1,62 @@
+import {
+ Disposable,
+ Disposer,
+ ScopeProvider,
+ ScopeTypeInfo,
+} from "@cursorless/common";
+import {
+ ScopeVisualizer,
+ VisualizationType,
+} from "./ScopeVisualizerCommandApi";
+import { isEqual } from "lodash";
+
+/**
+ * Attempts to ensure that the scope visualizer is still visualizing the same
+ * scope type after the user changes one of their custom regexes. Because custom
+ * regexes don't have a unique identifier, we have to do some guesswork to
+ * figure out which custom regex the user changed. This function look for a
+ * custom regex with the same spoken form as the one that was changed, and if it
+ * finds one, it starts visualizing that one instead.
+ *
+ * @param scopeVisualizer The scope visualizer to listen to
+ * @param scopeProvider Provides scope information
+ * @returns A {@link Disposable} which will stop the callback from running
+ */
+export function revisualizeOnCustomRegexChange(
+ scopeVisualizer: ScopeVisualizer,
+ scopeProvider: ScopeProvider,
+): Disposable {
+ let currentRegexScopeInfo: ScopeTypeInfo | undefined;
+ let currentVisualizationType: VisualizationType | undefined;
+
+ return new Disposer(
+ scopeVisualizer.onDidChangeScopeType((scopeType, visualizationType) => {
+ currentRegexScopeInfo =
+ scopeType?.type === "customRegex"
+ ? scopeProvider.getScopeInfo(scopeType)
+ : undefined;
+ currentVisualizationType = visualizationType;
+ }),
+
+ scopeProvider.onDidChangeScopeInfo((scopeInfos) => {
+ if (
+ currentRegexScopeInfo != null &&
+ !scopeInfos.some((scopeInfo) =>
+ isEqual(scopeInfo.scopeType, currentRegexScopeInfo!.scopeType),
+ )
+ ) {
+ const replacement = scopeInfos.find(
+ (scopeInfo) =>
+ scopeInfo.scopeType.type === "customRegex" &&
+ isEqual(scopeInfo.spokenForm, currentRegexScopeInfo!.spokenForm),
+ );
+ if (replacement != null) {
+ scopeVisualizer.start(
+ replacement.scopeType,
+ currentVisualizationType!,
+ );
+ }
+ }
+ }),
+ );
+}
diff --git a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts
index b628120496..d84df78ed2 100644
--- a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts
+++ b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts
@@ -25,6 +25,7 @@ export const assets: Asset[] = [
},
{ source: "../../images/hats", destination: "images/hats" },
{ source: "../../images/icon.png", destination: "images/icon.png" },
+ { source: "../../images/icon.svg", destination: "images/icon.svg" },
{ source: "../../schemas", destination: "schemas" },
{
source: "../../third-party-licenses.csv",
diff --git a/packages/meta-updater/src/getCursorlessVscodeFields.ts b/packages/meta-updater/src/getCursorlessVscodeFields.ts
index 2abf53d185..424eaf3634 100644
--- a/packages/meta-updater/src/getCursorlessVscodeFields.ts
+++ b/packages/meta-updater/src/getCursorlessVscodeFields.ts
@@ -21,6 +21,10 @@ export function getCursorlessVscodeFields(input: PackageJson) {
// Causes extension to activate whenever any text editor is opened
"onLanguage",
+ // Causes extension to activate when the Cursorless scope support side bar
+ // is opened
+ "onView:cursorless.scopes",
+
// Causes extension to activate when any Cursorless command is run.
// Technically we don't need to do this since VSCode 1.74.0, but we support
// older versions
diff --git a/packages/vscode-common/src/cursorlessSideBarIds.ts b/packages/vscode-common/src/cursorlessSideBarIds.ts
new file mode 100644
index 0000000000..c51bb17147
--- /dev/null
+++ b/packages/vscode-common/src/cursorlessSideBarIds.ts
@@ -0,0 +1 @@
+export const CURSORLESS_SCOPE_TREE_VIEW_ID = "cursorless.scopes";
diff --git a/packages/vscode-common/src/getExtensionApi.ts b/packages/vscode-common/src/getExtensionApi.ts
index 079f85b704..72151c0a87 100644
--- a/packages/vscode-common/src/getExtensionApi.ts
+++ b/packages/vscode-common/src/getExtensionApi.ts
@@ -5,6 +5,7 @@ import type {
HatTokenMap,
IDE,
NormalizedIDE,
+ ScopeProvider,
SerializedMarks,
SnippetMap,
TargetPlainObject,
@@ -19,6 +20,8 @@ export interface TestHelpers {
ide: NormalizedIDE;
injectIde: (ide: IDE) => void;
+ scopeProvider: ScopeProvider;
+
hatTokenMap: HatTokenMap;
commandServerApi: CommandServerApi;
@@ -44,6 +47,8 @@ export interface TestHelpers {
runIntegrationTests(): Promise;
+ spokenFormsJsonPath: string;
+
/**
* A thin wrapper around the VSCode API that allows us to mock it for testing.
*/
diff --git a/packages/vscode-common/src/index.ts b/packages/vscode-common/src/index.ts
index c6a679e3bc..1b1db4c637 100644
--- a/packages/vscode-common/src/index.ts
+++ b/packages/vscode-common/src/index.ts
@@ -5,3 +5,4 @@ export * from "./vscodeUtil";
export * from "./runCommand";
export * from "./VscodeApi";
export * from "./ScopeVisualizerColorConfig";
+export * from "./cursorlessSideBarIds";
diff --git a/typings/object.d.ts b/typings/object.d.ts
index e600e07ee8..8f94f31e6d 100644
--- a/typings/object.d.ts
+++ b/typings/object.d.ts
@@ -9,4 +9,24 @@ type ObjectKeys = T extends object
interface ObjectConstructor {
keys(o: T): ObjectKeys;
+
+ fromEntries<
+ V extends PropertyKey,
+ T extends [readonly [V, any]] | Array,
+ >(
+ entries: T,
+ ): Flatten>>;
}
+
+// From https://github.com/microsoft/TypeScript/issues/35745#issuecomment-566932289
+type UnionToIntersection = (T extends T ? (p: T) => void : never) extends (
+ p: infer U,
+) => void
+ ? U
+ : never;
+type FromEntries = T extends T
+ ? Record
+ : never;
+type Flatten = object & {
+ [P in keyof T]: T[P];
+};