From 9157c4b0a09bd1d77f98f00459954ce515b1d8c9 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:40:13 +0100 Subject: [PATCH 01/36] Add tree view to indicate scope support --- cursorless-talon/src/csv_overrides.py | 73 +-- cursorless-talon/src/marks/decorated_mark.py | 2 +- cursorless-talon/src/spoken_forms.py | 74 ++- cursorless-talon/src/spoken_forms_output.py | 40 ++ .../common/src/ide/types/FileSystem.types.ts | 2 +- packages/common/src/index.ts | 2 + .../command/PartialTargetDescriptor.types.ts | 165 ++++--- packages/common/src/util/Disposer.ts | 26 + .../common/src/util/camelCaseToAllDown.ts | 7 + .../src/CustomSpokenForms.ts | 102 ++++ .../src/DefaultSpokenFormMap.ts | 133 +++++ .../cursorless-engine/src/SpokenFormMap.ts | 44 ++ .../src/api/ScopeProvider.ts | 38 ++ .../cursorless-engine/src/core/Debouncer.ts | 7 +- .../cursorless-engine/src/cursorlessEngine.ts | 23 +- .../GeneratorSpokenFormMap.ts | 47 ++ .../defaultSpokenForms/modifiers.ts | 170 +------ .../generateSpokenForm.test.ts | 15 +- .../generateSpokenForm/generateSpokenForm.ts | 366 ++++++++------ .../primitiveTargetToSpokenForm.ts | 458 ++++++++++-------- .../src/languages/LanguageDefinitions.ts | 2 +- .../src/scopeProviders/ScopeInfoProvider.ts | 279 +++++++++++ .../ScopeRangeProvider.ts | 0 .../ScopeRangeWatcher.ts | 0 .../ScopeSupportChecker.ts | 0 .../src/scopeProviders/ScopeSupportWatcher.ts | 116 +++++ .../getIterationRange.ts | 0 .../getIterationScopeRanges.ts | 0 .../getScopeRanges.ts | 0 .../scopeProviders/getSpokenFormEntries.ts | 37 ++ .../getTargetRanges.ts | 0 .../src/scopeProviders/scopeTypeToString.ts | 21 + .../src/testCaseRecorder/TestCaseRecorder.ts | 8 +- packages/cursorless-vscode/package.json | 9 + .../src/ScopeSupportTreeProvider.ts | 159 ++++++ packages/cursorless-vscode/src/extension.ts | 2 + .../src/ide/vscode/VscodeFileSystem.ts | 59 +-- typings/object.d.ts | 20 + 38 files changed, 1803 insertions(+), 703 deletions(-) create mode 100644 cursorless-talon/src/spoken_forms_output.py create mode 100644 packages/common/src/util/Disposer.ts create mode 100644 packages/common/src/util/camelCaseToAllDown.ts create mode 100644 packages/cursorless-engine/src/CustomSpokenForms.ts create mode 100644 packages/cursorless-engine/src/DefaultSpokenFormMap.ts create mode 100755 packages/cursorless-engine/src/SpokenFormMap.ts create mode 100644 packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts create mode 100644 packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts rename packages/cursorless-engine/src/{ScopeVisualizer => scopeProviders}/ScopeRangeProvider.ts (100%) rename packages/cursorless-engine/src/{ScopeVisualizer => scopeProviders}/ScopeRangeWatcher.ts (100%) rename packages/cursorless-engine/src/{ScopeVisualizer => scopeProviders}/ScopeSupportChecker.ts (100%) create mode 100644 packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts rename packages/cursorless-engine/src/{ScopeVisualizer => scopeProviders}/getIterationRange.ts (100%) rename packages/cursorless-engine/src/{ScopeVisualizer => scopeProviders}/getIterationScopeRanges.ts (100%) rename packages/cursorless-engine/src/{ScopeVisualizer => scopeProviders}/getScopeRanges.ts (100%) create mode 100644 packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts rename packages/cursorless-engine/src/{ScopeVisualizer => scopeProviders}/getTargetRanges.ts (100%) create mode 100644 packages/cursorless-engine/src/scopeProviders/scopeTypeToString.ts create mode 100644 packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts diff --git a/cursorless-talon/src/csv_overrides.py b/cursorless-talon/src/csv_overrides.py index 4c51b7c3a4..a006f96576 100644 --- a/cursorless-talon/src/csv_overrides.py +++ b/cursorless-talon/src/csv_overrides.py @@ -2,7 +2,7 @@ from collections.abc import Container from datetime import datetime from pathlib import Path -from typing import Optional +from typing import Callable, Optional from talon import Context, Module, actions, app, fs @@ -25,49 +25,65 @@ 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 """ +ListToSpokenForms = dict[str, dict[str, str]] + + def init_csv_and_watch_changes( filename: str, - default_values: dict[str, dict[str, str]], + default_values: ListToSpokenForms, + handle_new_values: Optional[Callable[[ListToSpokenForms], 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[[ListToSpokenForms], 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 +112,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 +133,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 +145,7 @@ def on_watch(path, flags): allow_unknown_values, default_list_name, pluralize_lists, - ctx, + handle_new_values, ) def unsubscribe(): @@ -165,7 +181,7 @@ 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( @@ -175,7 +191,7 @@ def update_dicts( allow_unknown_values: bool, default_list_name: Optional[str], pluralize_lists: list[str], - ctx: Context, + handle_new_values: Optional[Callable[[ListToSpokenForms], None]], ): # Create map with all default values results_map = {} @@ -219,6 +235,9 @@ def update_dicts( # Assign result to talon context list assign_lists_to_context(ctx, results, pluralize_lists) + if handle_new_values is not None: + handle_new_values(results) + def assign_lists_to_context( ctx: Context, @@ -386,7 +405,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/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..26aefd0122 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -1,4 +1,5 @@ import json +from itertools import groupby from pathlib import Path from typing import Callable, Concatenate, ParamSpec, TypeVar @@ -6,25 +7,25 @@ from .csv_overrides import SPOKEN_FORM_HEADER, 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") +# Maps from Talon list name to a map from spoken form to value +ListToSpokenForms = dict[str, dict[str, str]] + 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[[ListToSpokenForms], None], + f: Callable[ + Concatenate[str, ListToSpokenForms, Callable[[ListToSpokenForms], None], P], R + ], ): """ Decorator that automatically constructs the default values for the @@ -37,17 +38,32 @@ 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, handle_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 +73,36 @@ 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: ListToSpokenForms = {} + spoken_forms_output = SpokenFormsOutput() + spoken_forms_output.init() + + def update_spoken_forms_output(): + spoken_forms_output.write( + [ + { + "type": entry_type, + "id": value, + "spokenForms": [spoken_form[0] for spoken_form in spoken_forms], + } + for list_name, entry_type in LIST_TO_TYPE_MAP.items() + for value, spoken_forms in groupby( + custom_spoken_forms[list_name].items(), lambda item: item[1] + ) + ] + ) + + def handle_new_values(values: ListToSpokenForms): + custom_spoken_forms.update(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 +152,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..ed2b128806 --- /dev/null +++ b/cursorless-talon/src/spoken_forms_output.py @@ -0,0 +1,40 @@ +import json +from pathlib import Path +from typing import TypedDict + +from talon import app + +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": 0, "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/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..60c346b54a 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"; diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index 799aa47e85..e3539c127e 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -75,88 +75,107 @@ 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.includes(scopeType.type as any); +} + +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..93cacd833a --- /dev/null +++ b/packages/common/src/util/Disposer.ts @@ -0,0 +1,26 @@ +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[] = []; + + 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..ddfd7c39d3 --- /dev/null +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -0,0 +1,102 @@ +import { + CustomRegexScopeType, + Disposer, + FileSystem, + Notifier, +} from "@cursorless/common"; +import { homedir } from "os"; +import * as path from "path"; +import { + CustomRegexSpokenFormEntry, + PairedDelimiterSpokenFormEntry, + SimpleScopeTypeTypeSpokenFormEntry, + getSpokenFormEntries, +} from "./scopeProviders/getSpokenFormEntries"; +import { SpokenFormMap } from "./SpokenFormMap"; +import { defaultSpokenFormMap } from "./DefaultSpokenFormMap"; + +export const spokenFormsPath = path.join( + homedir(), + ".cursorless", + "spokenForms.json", +); + +/** + * Maintains a list of all scope types and notifies listeners when it changes. + */ +export class CustomSpokenForms implements SpokenFormMap { + private disposer = new Disposer(); + private notifier = new Notifier(); + + // Initialize to defaults + simpleScopeTypeType = defaultSpokenFormMap.simpleScopeTypeType; + pairedDelimiter = defaultSpokenFormMap.pairedDelimiter; + customRegex = defaultSpokenFormMap.customRegex; + + // FIXME: Get these from Talon + surroundingPairForceDirection = + defaultSpokenFormMap.surroundingPairForceDirection; + simpleModifier = defaultSpokenFormMap.simpleModifier; + modifierExtra = defaultSpokenFormMap.modifierExtra; + + private isInitialized_ = false; + + get isInitialized() { + return this.isInitialized_; + } + + private constructor(fileSystem: FileSystem) { + this.disposer.push( + fileSystem.watch(spokenFormsPath, () => 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 { + const entries = await getSpokenFormEntries(); + + this.simpleScopeTypeType = Object.fromEntries( + entries + .filter( + (entry): entry is SimpleScopeTypeTypeSpokenFormEntry => + entry.type === "simpleScopeTypeType", + ) + .map(({ id, spokenForms }) => [id, spokenForms] as const), + ); + this.customRegex = Object.fromEntries( + entries + .filter( + (entry): entry is CustomRegexSpokenFormEntry => + entry.type === "customRegex", + ) + .map(({ id, spokenForms }) => [id, spokenForms] as const), + ); + this.pairedDelimiter = Object.fromEntries( + entries + .filter( + (entry): entry is PairedDelimiterSpokenFormEntry => + entry.type === "pairedDelimiter", + ) + .map(({ id, spokenForms }) => [id, spokenForms] as const), + ); + + this.notifier.notifyListeners(); + } + + getCustomRegexScopeTypes(): CustomRegexScopeType[] { + return Object.keys(this.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..ff6198b061 --- /dev/null +++ b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts @@ -0,0 +1,133 @@ +import { mapValues } from "lodash"; +import { SpokenFormMap, SpokenFormMapKeyTypes } from "./SpokenFormMap"; + +type DefaultSpokenFormMap = { + readonly [K in keyof SpokenFormMapKeyTypes]: Readonly< + Record + >; +}; + +const defaultSpokenFormMapCore: DefaultSpokenFormMap = { + 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: "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, + }, + + 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: {}, +}; + +// TODO: Don't cast here; need to make our own mapValues with stronger typing +// using tricks from our object.d.ts +export const defaultSpokenFormMap = mapValues( + defaultSpokenFormMapCore, + (entry) => + mapValues(entry, (subEntry) => (subEntry == null ? [] : [subEntry])), +) as SpokenFormMap; diff --git a/packages/cursorless-engine/src/SpokenFormMap.ts b/packages/cursorless-engine/src/SpokenFormMap.ts new file mode 100755 index 0000000000..30f5e82d9e --- /dev/null +++ b/packages/cursorless-engine/src/SpokenFormMap.ts @@ -0,0 +1,44 @@ +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 type SpokenFormMap = { + readonly [K in keyof SpokenFormMapKeyTypes]: Readonly< + Record + >; +}; diff --git a/packages/cursorless-engine/src/api/ScopeProvider.ts b/packages/cursorless-engine/src/api/ScopeProvider.ts index 40d4bd6916..85d0980551 100644 --- a/packages/cursorless-engine/src/api/ScopeProvider.ts +++ b/packages/cursorless-engine/src/api/ScopeProvider.ts @@ -75,6 +75,24 @@ 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; } interface ScopeRangeConfigBase { @@ -108,6 +126,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; + spokenForms: string[] | undefined; + 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/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..7ab0bc13a5 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -8,8 +8,8 @@ import { 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 { ScopeRangeProvider } from "./scopeProviders/ScopeRangeProvider"; +import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker"; import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; @@ -21,7 +21,9 @@ import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandler import { runCommand } from "./runCommand"; import { runIntegrationTests } from "./runIntegrationTests"; import { injectIde } from "./singletons/ide.singleton"; -import { ScopeRangeWatcher } from "./ScopeVisualizer/ScopeRangeWatcher"; +import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher"; +import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher"; +import { ScopeInfoProvider } from "./scopeProviders/ScopeInfoProvider"; export function createCursorlessEngine( treeSitter: TreeSitter, @@ -85,7 +87,11 @@ export function createCursorlessEngine( ); }, }, - scopeProvider: createScopeProvider(languageDefinitions, storedTargets), + scopeProvider: createScopeProvider( + languageDefinitions, + storedTargets, + fileSystem, + ), testCaseRecorder, storedTargets, hatTokenMap, @@ -99,6 +105,7 @@ export function createCursorlessEngine( function createScopeProvider( languageDefinitions: LanguageDefinitions, storedTargets: StoredTargetMap, + fileSystem: FileSystem, ): ScopeProvider { const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions); @@ -116,6 +123,12 @@ function createScopeProvider( rangeProvider, ); const supportChecker = new ScopeSupportChecker(scopeHandlerFactory); + const infoProvider = ScopeInfoProvider.create(fileSystem); + const supportWatcher = new ScopeSupportWatcher( + languageDefinitions, + supportChecker, + infoProvider, + ); return { provideScopeRanges: rangeProvider.provideScopeRanges, @@ -125,5 +138,7 @@ function createScopeProvider( rangeWatcher.onDidChangeIterationScopeRanges, getScopeSupport: supportChecker.getScopeSupport, getIterationScopeSupport: supportChecker.getIterationScopeSupport, + onDidChangeScopeSupport: supportWatcher.onDidChangeScopeSupport, + onDidChangeScopeInfo: infoProvider.onDidChangeScopeInfo, }; } diff --git a/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts b/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts new file mode 100644 index 0000000000..c134c983cd --- /dev/null +++ b/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts @@ -0,0 +1,47 @@ +import { + SpokenFormMap, + SpokenFormMapKeyTypes, + SpokenFormType, +} from "../SpokenFormMap"; + +export type GeneratorSpokenFormMap = { + readonly [K in keyof SpokenFormMapKeyTypes]: Record< + SpokenFormMapKeyTypes[K], + SingleTermSpokenForm + >; +}; + +export interface SingleTermSpokenForm { + type: "singleTerm"; + spokenForms: string[]; + spokenFormType: SpokenFormType; + id: string; +} + +export type SpokenFormComponent = + | SingleTermSpokenForm + | string + | SpokenFormComponent[]; + +export function getGeneratorSpokenForms( + spokenFormMap: SpokenFormMap, +): GeneratorSpokenFormMap { + // TODO: 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/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..7bdc5048a2 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts @@ -8,8 +8,9 @@ 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 { defaultSpokenFormMap } from "../DefaultSpokenFormMap"; suite("Generate spoken forms", () => { getRecordedTestPaths().forEach(({ name, path }) => @@ -21,23 +22,25 @@ 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(defaultSpokenFormMap); + + 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 +50,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..b1691eb682 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, } from "@cursorless/common"; -import { RecursiveArray, flattenDeep } from "lodash"; +import { RecursiveArray } from "lodash"; import { NoSpokenFormError } from "./NoSpokenFormError"; import { actions } from "./defaultSpokenForms/actions"; import { connectives } from "./defaultSpokenForms/connectives"; @@ -15,11 +16,18 @@ 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"; export interface SpokenFormSuccess { type: "success"; - value: string; + preferred: string; + alternatives: string[]; } export interface SpokenFormError { @@ -29,177 +37,227 @@ export interface SpokenFormError { export type SpokenForm = SpokenFormSuccess | SpokenFormError; -/** - * 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 }; - } +export class SpokenFormGenerator { + private primitiveGenerator: PrimitiveTargetSpokenFormGenerator; + private spokenFormMap: GeneratorSpokenFormMap; + + constructor(spokenFormMap: SpokenFormMap) { + this.spokenFormMap = getGeneratorSpokenForms(spokenFormMap); - throw e; + this.primitiveGenerator = new PrimitiveTargetSpokenFormGenerator( + this.spokenFormMap, + ); } -} -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)]; - } - 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`); + /** + * 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)); + } + + /** + * 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), + ]); + } + + 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 }; } - return [actions[action.name], targetToSpokenForm(action.target)]; - } - default: { - return [actions[action.name], targetToSpokenForm(action.target)]; + throw e; } } -} -function targetToSpokenForm( - target: PartialTargetDescriptor, -): RecursiveArray { - switch (target.type) { - case "list": - if (target.elements.length < 2) { - throw new NoSpokenFormError("List target with < 2 elements"); + 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)]; } - 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]; + default: { + return [actions[action.name], this.handleTarget(action.target)]; + } } + } - case "primitive": - return primitiveTargetToSpokenForm(target); + private handleTarget( + target: PartialTargetDescriptor, + ): RecursiveArray { + switch (target.type) { + case "list": + if (target.elements.length < 2) { + throw new NoSpokenFormError("List target with < 2 elements"); + } - case "implicit": - return []; - } -} + return target.elements.map((element, i) => + i === 0 + ? this.handleTarget(element) + : [connectives.listConnective, this.handleTarget(element)], + ); -function destinationToSpokenForm( - destination: DestinationDescriptor, -): RecursiveArray { - switch (destination.type) { - case "list": - if (destination.destinations.length < 2) { - throw new NoSpokenFormError("List destination with < 2 elements"); + 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 destination.destinations.map((destination, i) => - i === 0 - ? destinationToSpokenForm(destination) - : [connectives.listConnective, destinationToSpokenForm(destination)], - ); + case "primitive": + return this.primitiveGenerator.handlePrimitiveTarget(target); + + case "implicit": + return []; + } + } + + private handleDestination( + destination: DestinationDescriptor, + ): RecursiveArray { + switch (destination.type) { + case "list": + if (destination.destinations.length < 2) { + throw new NoSpokenFormError("List destination with < 2 elements"); + } - case "primitive": - return [ - insertionModeToSpokenForm(destination.insertionMode), - targetToSpokenForm(destination.target), - ]; + return destination.destinations.map((destination, i) => + i === 0 + ? this.handleDestination(destination) + : [connectives.listConnective, this.handleDestination(destination)], + ); - case "implicit": - return []; + case "primitive": + return [ + this.handleInsertionMode(destination.insertionMode), + this.handleTarget(destination.target), + ]; + + 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 insertionModeToSpokenForm(insertionMode: InsertionMode): string { - switch (insertionMode) { - case "to": - return connectives.sourceDestinationConnective; - case "before": - return connectives.before; - case "after": - return connectives.after; +function constructSpokenForms(component: SpokenFormComponent): string[] { + if (typeof component === "string") { + return [component]; } + + if (Array.isArray(component)) { + return constructSpokenFormsArray(component); + } +} + +function cartesianProduct(...arrays: T[][]): T[] { + return arrays.reduce((acc, val) => + acc.flatMap((x) => val.map((y) => [...x, y])), + ); } diff --git a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts index 59452aa775..486b18193c 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,307 @@ 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 { surroundingPairNameToSpokenForm } from "./defaultSpokenForms/modifiers"; +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: SpokenFormComponents = []; + 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, + ): SpokenFormComponents { + 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, + ): SpokenFormComponents { + 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]; - } - - throw new NoSpokenFormError( - `${modifier.type} modifier with offset > 1 and length > 1`, - ); -} - -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}`; + handleScopeType(scopeType: ScopeType): SpokenForms { + switch (scopeType.type) { + case "oneOf": + case "customRegex": + case "switchStatementSubject": + case "string": + 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 = surroundingPairNameToSpokenForm( + this.spokenFormMap, + scopeType.delimiter, + ); + if (scopeType.forceDirection != null) { + const direction = + scopeType.forceDirection === "left" + ? this.spokenFormMap.surroundingPairForceDirection.left + : this.spokenFormMap.surroundingPairForceDirection.right; + return `${direction} ${pair}`; + } + return pair; } - return pair; - } - default: - return scopeSpokenForms[scopeType.type]; + default: + return this.spokenFormMap.simpleScopeTypeType[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): SpokenFormComponents { + 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, + ); + const [typeActive, numberActive] = this.handleLineNumberMark( + mark.active, ); - // Row five past seven - return [typeAnchor, numberAnchor, connective, numberActive]; + 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}'`); + case "explicit": + throw new NoSpokenFormError(`Mark '${mark.type}'`); - default: - return [marks[mark.type]]; + default: + return [marks[mark.type]]; + } } -} -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)), - ]; + 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)), + ]; + } } } } +// FIXME: Properly pluralize function pluralize(name: string): string { return `${name}s`; } 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..4b48d9c29d --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -0,0 +1,279 @@ +import { + CustomRegexScopeType, + Disposable, + FileSystem, + ScopeType, + SimpleScopeTypeType, + SurroundingPairName, + SurroundingPairScopeType, + isSimpleScopeType, + simpleScopeTypeTypes, + surroundingPairNames, +} from "@cursorless/common"; +import { pull } from "lodash"; +import { ScopeTypeInfo, ScopeTypeInfoEventCallback } from ".."; +import { Debouncer } from "../core/Debouncer"; +import { homedir } from "os"; +import * as path from "path"; +import { scopeTypeToString } from "./scopeTypeToString"; +import { + CustomRegexSpokenFormEntry, + PairedDelimiterSpokenFormEntry, + SimpleScopeTypeTypeSpokenFormEntry, + getSpokenFormEntries, +} from "./getSpokenFormEntries"; + +export const spokenFormsPath = path.join( + homedir(), + ".cursorless", + "spokenForms.json", +); + +/** + * Maintains a list of all scope types and notifies listeners when it changes. + */ +export class ScopeInfoProvider { + private disposables: Disposable[] = []; + private debouncer = new Debouncer(() => this.onChange(), 250); + private listeners: ScopeTypeInfoEventCallback[] = []; + private simpleScopeTypeSpokenFormMap?: Record; + private pairedDelimiterSpokenFormMap?: Record; + private customRegexSpokenFormMap?: Record; + private scopeInfos!: ScopeTypeInfo[]; + + private constructor(fileSystem: FileSystem) { + this.disposables.push( + fileSystem.watch(spokenFormsPath, this.debouncer.run), + this.debouncer, + ); + + this.onDidChangeScopeInfo = this.onDidChangeScopeInfo.bind(this); + } + + static create(fileSystem: FileSystem) { + const obj = new ScopeInfoProvider(fileSystem); + obj.init(); + return obj; + } + + private async init() { + await 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 { + this.updateScopeTypeInfos().then(() => callback(this.getScopeTypeInfos())); + callback(this.getScopeTypeInfos()); + + this.listeners.push(callback); + + return { + dispose: () => { + pull(this.listeners, callback); + }, + }; + } + + private async onChange() { + await this.updateScopeTypeInfos(); + + this.listeners.forEach((listener) => listener(this.scopeInfos)); + } + + private async updateScopeTypeInfos(): Promise { + const update = () => { + const scopeTypes: ScopeType[] = [ + ...simpleScopeTypeTypes + // Ignore instance pseudo-scope for now + // Skip "string" because we use surrounding pair for that + .filter( + (scopeTypeType) => + scopeTypeType !== "instance" && scopeTypeType !== "string", + ) + .map((scopeTypeType) => ({ + type: scopeTypeType, + })), + + ...surroundingPairNames.map( + (surroundingPairName): SurroundingPairScopeType => ({ + type: "surroundingPair", + delimiter: surroundingPairName, + }), + ), + + ...(this.customRegexSpokenFormMap == null + ? [] + : Object.keys(this.customRegexSpokenFormMap) + ).map( + (regex): CustomRegexScopeType => ({ type: "customRegex", regex }), + ), + ]; + + this.scopeInfos = scopeTypes.map((scopeType) => + this.getScopeTypeInfo(scopeType), + ); + }; + + update(); + + return this.updateSpokenFormMaps().then(update); + } + + private async updateSpokenFormMaps() { + const entries = await getSpokenFormEntries(); + + this.simpleScopeTypeSpokenFormMap = Object.fromEntries( + entries + .filter( + (entry): entry is SimpleScopeTypeTypeSpokenFormEntry => + entry.type === "simpleScopeTypeType", + ) + .map(({ id, spokenForms }) => [id, spokenForms] as const), + ); + this.customRegexSpokenFormMap = Object.fromEntries( + entries + .filter( + (entry): entry is CustomRegexSpokenFormEntry => + entry.type === "customRegex", + ) + .map(({ id, spokenForms }) => [id, spokenForms] as const), + ); + this.pairedDelimiterSpokenFormMap = Object.fromEntries( + entries + .filter( + (entry): entry is PairedDelimiterSpokenFormEntry => + entry.type === "pairedDelimiter", + ) + .map(({ id, spokenForms }) => [id, spokenForms] as const), + ); + } + + getScopeTypeInfos(): ScopeTypeInfo[] { + return this.scopeInfos; + } + + getScopeTypeInfo(scopeType: ScopeType): ScopeTypeInfo { + return { + scopeType, + spokenForms: this.getSpokenForms(scopeType), + humanReadableName: scopeTypeToString(scopeType), + isLanguageSpecific: isLanguageSpecific(scopeType), + }; + } + + getSpokenForms(scopeType: ScopeType): string[] | undefined { + if (isSimpleScopeType(scopeType)) { + return this.simpleScopeTypeSpokenFormMap?.[scopeType.type]; + } + + if (scopeType.type === "surroundingPair") { + return this.pairedDelimiterSpokenFormMap?.[scopeType.delimiter]; + } + + if (scopeType.type === "customRegex") { + return this.customRegexSpokenFormMap?.[scopeType.regex]; + } + + return undefined; + } + + 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 + } + }); + } +} + +/** + * @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 100% rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts rename to packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts similarity index 100% rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts rename to packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts similarity index 100% rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts rename to packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts new file mode 100644 index 0000000000..50bff837da --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts @@ -0,0 +1,116 @@ +import { Disposable, ScopeType } from "@cursorless/common"; +import { pull } from "lodash"; +import { ScopeSupport, ScopeSupportEventCallback, ScopeSupportInfo } from ".."; +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.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.debouncer.run), + this.debouncer, + ); + + this.onDidChangeScopeSupport = this.onDidChangeScopeSupport.bind(this); + } + + /** + * 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/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 100% rename from packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts rename to packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts b/packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts similarity index 100% rename from packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts rename to packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts diff --git a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts new file mode 100644 index 0000000000..6c571af02d --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts @@ -0,0 +1,37 @@ +import { SimpleScopeTypeType } from "@cursorless/common"; +import { readFile } from "fs/promises"; +import { spokenFormsPath } from "./ScopeInfoProvider"; +import { SpeakableSurroundingPairName } from "../SpokenFormMap"; + +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[]; +} + +type SpokenFormEntry = + | CustomRegexSpokenFormEntry + | PairedDelimiterSpokenFormEntry + | SimpleScopeTypeTypeSpokenFormEntry; + +export async function getSpokenFormEntries(): Promise { + try { + return JSON.parse(await readFile(spokenFormsPath, "utf-8")).entries; + } catch (err) { + console.error(`Error getting spoken forms`); + console.error(err); + return []; + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts b/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts similarity index 100% rename from packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts rename to packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts 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/package.json b/packages/cursorless-vscode/package.json index 9b9f03e9fa..21504f7ff4 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -46,6 +46,7 @@ ], "activationEvents": [ "onLanguage", + "onView:cursorlessScopeSupport", "onCommand:cursorless.command", "onCommand:cursorless.internal.updateCheatsheetDefaults", "onCommand:cursorless.keyboard.escape", @@ -77,6 +78,14 @@ } }, "contributes": { + "views": { + "explorer": [ + { + "id": "cursorlessScopeSupport", + "name": "Cursorless scope support" + } + ] + }, "commands": [ { "command": "cursorless.toggleDecorations", diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts new file mode 100644 index 0000000000..f2e5746845 --- /dev/null +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -0,0 +1,159 @@ +import { + ScopeProvider, + ScopeSupport, + ScopeSupportLevels, + ScopeTypeInfo, +} from "@cursorless/cursorless-engine"; +import * as vscode from "vscode"; + +export class ScopeSupportTreeProvider + implements vscode.TreeDataProvider +{ + private onDidChangeScopeSupportDisposable: vscode.Disposable | undefined; + private treeView: vscode.TreeView; + private supportLevels: ScopeSupportLevels = []; + + private _onDidChangeTreeData: vscode.EventEmitter< + MyTreeItem | undefined | null | void + > = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event< + MyTreeItem | undefined | null | void + > = this._onDidChangeTreeData.event; + + constructor( + private context: vscode.ExtensionContext, + private scopeProvider: ScopeProvider, + ) { + this.treeView = vscode.window.createTreeView("cursorlessScopeSupport", { + treeDataProvider: this, + }); + + this.context.subscriptions.push( + this.treeView, + this.treeView.onDidChangeVisibility(this.onDidChangeVisible, this), + this, + ); + } + + static create( + context: vscode.ExtensionContext, + scopeProvider: ScopeProvider, + ): ScopeSupportTreeProvider { + const treeProvider = new ScopeSupportTreeProvider(context, scopeProvider); + treeProvider.init(); + return treeProvider; + } + + init() { + if (this.treeView.visible) { + this.registerScopeSupportListener(); + } + } + + onDidChangeVisible(e: vscode.TreeViewVisibilityChangeEvent) { + if (e.visible) { + if (this.onDidChangeScopeSupportDisposable != null) { + return; + } + + this.registerScopeSupportListener(); + } else { + if (this.onDidChangeScopeSupportDisposable == null) { + return; + } + + this.onDidChangeScopeSupportDisposable.dispose(); + this.onDidChangeScopeSupportDisposable = undefined; + } + } + + private registerScopeSupportListener() { + this.onDidChangeScopeSupportDisposable = + this.scopeProvider.onDidChangeScopeSupport((supportLevels) => { + this.supportLevels = supportLevels; + this._onDidChangeTreeData.fire(); + }); + } + + getTreeItem(element: MyTreeItem): MyTreeItem { + return element; + } + + getChildren(element?: MyTreeItem): MyTreeItem[] { + if (element == null) { + return getSupportCategories(); + } + + if (element instanceof SupportCategoryTreeItem) { + return this.getScopeTypesWithSupport(element.scopeSupport); + } + + throw new Error("Unexpected element"); + } + + getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] { + return this.supportLevels + .filter((supportLevel) => supportLevel.support === scopeSupport) + .map((supportLevel) => new ScopeSupportTreeItem(supportLevel)); + } + + dispose() { + this.onDidChangeScopeSupportDisposable?.dispose(); + } +} + +function getSupportCategories(): SupportCategoryTreeItem[] { + return [ + new SupportCategoryTreeItem( + "Supported and present in editor", + ScopeSupport.supportedAndPresentInEditor, + vscode.TreeItemCollapsibleState.Expanded, + ), + new SupportCategoryTreeItem( + "Supported but not present in editor", + ScopeSupport.supportedButNotPresentInEditor, + vscode.TreeItemCollapsibleState.Expanded, + ), + new SupportCategoryTreeItem( + "Supported using legacy pathways", + ScopeSupport.supportedLegacy, + vscode.TreeItemCollapsibleState.Expanded, + ), + new SupportCategoryTreeItem( + "Unsupported", + ScopeSupport.unsupported, + vscode.TreeItemCollapsibleState.Collapsed, + ), + ]; +} + +class ScopeSupportTreeItem extends vscode.TreeItem { + constructor(scopeTypeInfo: ScopeTypeInfo) { + let label: string, description: string | undefined; + if (scopeTypeInfo.spokenForms == null) { + label = scopeTypeInfo.humanReadableName; + } else { + label = + scopeTypeInfo.spokenForms.length === 0 + ? "-" + : `"${scopeTypeInfo.spokenForms[0]}"`; + description = scopeTypeInfo.humanReadableName; + } + + super(label, vscode.TreeItemCollapsibleState.None); + + this.description = description; + } +} + +class SupportCategoryTreeItem extends vscode.TreeItem { + constructor( + label: string, + public readonly scopeSupport: ScopeSupport, + collapsibleState: vscode.TreeItemCollapsibleState, + ) { + super(label, collapsibleState); + } +} + +type MyTreeItem = ScopeSupportTreeItem | SupportCategoryTreeItem; diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 941817b47d..26cb5d4724 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -40,6 +40,7 @@ import { } from "./ScopeVisualizerCommandApi"; import { StatusBarItem } from "./StatusBarItem"; import { vscodeApi } from "./vscodeApi"; +import { ScopeSupportTreeProvider } from "./ScopeSupportTreeProvider"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -91,6 +92,7 @@ export async function activate( const statusBarItem = StatusBarItem.create("cursorless.showQuickPick"); const keyboardCommands = KeyboardCommands.create(context, statusBarItem); + ScopeSupportTreeProvider.create(context, scopeProvider); registerCommands( context, diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts index 49da4f0017..613435e582 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts @@ -1,52 +1,15 @@ -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, 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 { + // FIXME: Support globs? + const watcher = workspace.createFileSystemWatcher( + new RelativePattern(path, "**"), + ); + watcher.onDidChange(onDidChange); + watcher.onDidCreate(onDidChange); + watcher.onDidDelete(onDidChange); + return watcher; } } 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]; +}; From 05c71a613d89e0b3d8eaae47eba4359355764733 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 27 Sep 2023 16:10:36 +0100 Subject: [PATCH 02/36] Fixes --- .../generateSpokenForm/generateSpokenForm.ts | 45 +++++++++++++++---- .../primitiveTargetToSpokenForm.ts | 39 +++++++++++----- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts index b1691eb682..51463ecd07 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts @@ -5,8 +5,8 @@ import { InsertionMode, PartialTargetDescriptor, ScopeType, + camelCaseToAllDown, } from "@cursorless/common"; -import { RecursiveArray } from "lodash"; import { NoSpokenFormError } from "./NoSpokenFormError"; import { actions } from "./defaultSpokenForms/actions"; import { connectives } from "./defaultSpokenForms/connectives"; @@ -174,9 +174,7 @@ export class SpokenFormGenerator { } } - private handleTarget( - target: PartialTargetDescriptor, - ): RecursiveArray { + private handleTarget(target: PartialTargetDescriptor): SpokenFormComponent { switch (target.type) { case "list": if (target.elements.length < 2) { @@ -210,7 +208,7 @@ export class SpokenFormGenerator { private handleDestination( destination: DestinationDescriptor, - ): RecursiveArray { + ): SpokenFormComponent { switch (destination.type) { case "list": if (destination.destinations.length < 2) { @@ -252,12 +250,41 @@ function constructSpokenForms(component: SpokenFormComponent): string[] { } if (Array.isArray(component)) { - return constructSpokenFormsArray(component); + return cartesianProduct(component.map(constructSpokenForms)).map((words) => + words.join(" "), + ); } + + if (component.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`, + ); + } + + return component.spokenForms; } -function cartesianProduct(...arrays: T[][]): T[] { - return arrays.reduce((acc, val) => - acc.flatMap((x) => val.map((y) => [...x, y])), +/** + * 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 486b18193c..b399be81a0 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts @@ -20,7 +20,6 @@ import { numberToSpokenForm, ordinalToSpokenForm, } from "./defaultSpokenForms/numbers"; -import { surroundingPairNameToSpokenForm } from "./defaultSpokenForms/modifiers"; import { characterToSpokenForm } from "./defaultSpokenForms/characters"; import { GeneratorSpokenFormMap, @@ -35,7 +34,7 @@ export class PrimitiveTargetSpokenFormGenerator { handlePrimitiveTarget( target: PartialPrimitiveTargetDescriptor, ): SpokenFormComponent { - const components: SpokenFormComponents = []; + const components: SpokenFormComponent[] = []; if (target.modifiers != null) { components.push(target.modifiers.map(this.handleModifier)); } @@ -147,7 +146,7 @@ export class PrimitiveTargetSpokenFormGenerator { private handleRelativeScopeInclusive( modifier: RelativeScopeModifier, - ): SpokenFormComponents { + ): SpokenFormComponent { const scope = this.handleScopeType(modifier.scopeType); if (modifier.length === 1) { @@ -175,7 +174,7 @@ export class PrimitiveTargetSpokenFormGenerator { private handleRelativeScopeExclusive( modifier: RelativeScopeModifier, - ): SpokenFormComponents { + ): SpokenFormComponent { const scope = this.handleScopeType(modifier.scopeType); const direction = modifier.direction === "forward" @@ -207,7 +206,7 @@ export class PrimitiveTargetSpokenFormGenerator { ); } - handleScopeType(scopeType: ScopeType): SpokenForms { + handleScopeType(scopeType: ScopeType): SpokenFormComponent { switch (scopeType.type) { case "oneOf": case "customRegex": @@ -220,10 +219,7 @@ export class PrimitiveTargetSpokenFormGenerator { `Scope type '${scopeType.type}' with delimiter 'collectionBoundary'`, ); } - const pair = surroundingPairNameToSpokenForm( - this.spokenFormMap, - scopeType.delimiter, - ); + const pair = this.spokenFormMap.pairedDelimiter[scopeType.delimiter]; if (scopeType.forceDirection != null) { const direction = scopeType.forceDirection === "left" @@ -239,7 +235,7 @@ export class PrimitiveTargetSpokenFormGenerator { } } - private handleMark(mark: PartialMark): SpokenFormComponents { + private handleMark(mark: PartialMark): SpokenFormComponent { switch (mark.type) { case "decoratedSymbol": { const [color, shape] = mark.symbolColor.split("-"); @@ -314,7 +310,28 @@ export class PrimitiveTargetSpokenFormGenerator { } } +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.map(pluralizeString), + }; +} + // FIXME: Properly pluralize -function pluralize(name: string): string { +function pluralizeString(name: string): string { return `${name}s`; } From ba7ac819ecb8b19792bd5a09d855709a4a9975ac Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 29 Sep 2023 16:58:04 +0100 Subject: [PATCH 03/36] Almost working --- .../src/CustomSpokenForms.ts | 11 +- .../src/api/ScopeProvider.ts | 3 +- .../cursorless-engine/src/cursorlessEngine.ts | 23 ++- .../src/scopeProviders/ScopeInfoProvider.ts | 163 ++++-------------- .../src/ScopeSupportTreeProvider.ts | 15 +- .../src/ide/vscode/VscodeFileSystem.ts | 5 +- 6 files changed, 74 insertions(+), 146 deletions(-) diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts index ddfd7c39d3..5fc3412060 100644 --- a/packages/cursorless-engine/src/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -41,11 +41,16 @@ export class CustomSpokenForms implements SpokenFormMap { private isInitialized_ = false; + /** + * 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 isInitialized() { return this.isInitialized_; } - private constructor(fileSystem: FileSystem) { + constructor(fileSystem: FileSystem) { this.disposer.push( fileSystem.watch(spokenFormsPath, () => this.updateSpokenFormMaps()), ); @@ -61,7 +66,9 @@ export class CustomSpokenForms implements SpokenFormMap { onDidChangeCustomSpokenForms = this.notifier.registerListener; private async updateSpokenFormMaps(): Promise { + console.log("updateSpokenFormMaps before getSpokenFormEntries"); const entries = await getSpokenFormEntries(); + console.log("updateSpokenFormMaps after getSpokenFormEntries"); this.simpleScopeTypeType = Object.fromEntries( entries @@ -88,6 +95,8 @@ export class CustomSpokenForms implements SpokenFormMap { .map(({ id, spokenForms }) => [id, spokenForms] as const), ); + console.log("updateSpokenFormMaps at end"); + this.isInitialized_ = true; this.notifier.notifyListeners(); } diff --git a/packages/cursorless-engine/src/api/ScopeProvider.ts b/packages/cursorless-engine/src/api/ScopeProvider.ts index 85d0980551..d655ecc704 100644 --- a/packages/cursorless-engine/src/api/ScopeProvider.ts +++ b/packages/cursorless-engine/src/api/ScopeProvider.ts @@ -5,6 +5,7 @@ import { ScopeType, TextEditor, } from "@cursorless/common"; +import { SpokenForm } from "../generateSpokenForm"; export interface ScopeProvider { /** @@ -139,7 +140,7 @@ export type ScopeSupportEventCallback = ( export interface ScopeTypeInfo { scopeType: ScopeType; - spokenForms: string[] | undefined; + spokenForm: SpokenForm; humanReadableName: string; isLanguageSpecific: boolean; } diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 7ab0bc13a5..90dc880484 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -6,10 +6,9 @@ import { IDE, } from "@cursorless/common"; import { StoredTargetMap, TestCaseRecorder, TreeSitter } from "."; +import { CustomSpokenForms } from "./CustomSpokenForms"; import { CursorlessEngine } from "./api/CursorlessEngineApi"; import { ScopeProvider } from "./api/ScopeProvider"; -import { ScopeRangeProvider } from "./scopeProviders/ScopeRangeProvider"; -import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker"; import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; @@ -20,10 +19,13 @@ import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryI import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers"; import { runCommand } from "./runCommand"; import { runIntegrationTests } from "./runIntegrationTests"; -import { injectIde } from "./singletons/ide.singleton"; +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 { ScopeInfoProvider } from "./scopeProviders/ScopeInfoProvider"; +import { injectIde } from "./singletons/ide.singleton"; +import { SpokenFormGenerator } from "./generateSpokenForm"; export function createCursorlessEngine( treeSitter: TreeSitter, @@ -55,8 +57,12 @@ export function createCursorlessEngine( const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter); + const customSpokenForms = new CustomSpokenForms(fileSystem); + ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug); + console.log("createCursorlessEngine"); + return { commandApi: { runCommand(command: Command) { @@ -90,7 +96,7 @@ export function createCursorlessEngine( scopeProvider: createScopeProvider( languageDefinitions, storedTargets, - fileSystem, + customSpokenForms, ), testCaseRecorder, storedTargets, @@ -105,7 +111,7 @@ export function createCursorlessEngine( function createScopeProvider( languageDefinitions: LanguageDefinitions, storedTargets: StoredTargetMap, - fileSystem: FileSystem, + customSpokenForms: CustomSpokenForms, ): ScopeProvider { const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions); @@ -123,7 +129,10 @@ function createScopeProvider( rangeProvider, ); const supportChecker = new ScopeSupportChecker(scopeHandlerFactory); - const infoProvider = ScopeInfoProvider.create(fileSystem); + const infoProvider = new ScopeInfoProvider( + customSpokenForms, + new SpokenFormGenerator(customSpokenForms), + ); const supportWatcher = new ScopeSupportWatcher( languageDefinitions, supportChecker, diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts index 4b48d9c29d..83a9afb939 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -1,27 +1,19 @@ import { - CustomRegexScopeType, Disposable, - FileSystem, + Disposer, ScopeType, - SimpleScopeTypeType, - SurroundingPairName, SurroundingPairScopeType, - isSimpleScopeType, simpleScopeTypeTypes, surroundingPairNames, } from "@cursorless/common"; import { pull } from "lodash"; -import { ScopeTypeInfo, ScopeTypeInfoEventCallback } from ".."; -import { Debouncer } from "../core/Debouncer"; import { homedir } from "os"; import * as path from "path"; +import { ScopeTypeInfo, ScopeTypeInfoEventCallback } from ".."; +import { CustomSpokenForms } from "../CustomSpokenForms"; + +import { SpokenFormGenerator } from "../generateSpokenForm"; import { scopeTypeToString } from "./scopeTypeToString"; -import { - CustomRegexSpokenFormEntry, - PairedDelimiterSpokenFormEntry, - SimpleScopeTypeTypeSpokenFormEntry, - getSpokenFormEntries, -} from "./getSpokenFormEntries"; export const spokenFormsPath = path.join( homedir(), @@ -33,31 +25,20 @@ export const spokenFormsPath = path.join( * Maintains a list of all scope types and notifies listeners when it changes. */ export class ScopeInfoProvider { - private disposables: Disposable[] = []; - private debouncer = new Debouncer(() => this.onChange(), 250); + private disposer = new Disposer(); private listeners: ScopeTypeInfoEventCallback[] = []; - private simpleScopeTypeSpokenFormMap?: Record; - private pairedDelimiterSpokenFormMap?: Record; - private customRegexSpokenFormMap?: Record; private scopeInfos!: ScopeTypeInfo[]; - private constructor(fileSystem: FileSystem) { - this.disposables.push( - fileSystem.watch(spokenFormsPath, this.debouncer.run), - this.debouncer, + constructor( + private customSpokenForms: CustomSpokenForms, + private spokenFormGenerator: SpokenFormGenerator, + ) { + this.disposer.push( + customSpokenForms.onDidChangeCustomSpokenForms(() => this.onChange()), ); this.onDidChangeScopeInfo = this.onDidChangeScopeInfo.bind(this); - } - - static create(fileSystem: FileSystem) { - const obj = new ScopeInfoProvider(fileSystem); - obj.init(); - return obj; - } - - private async init() { - await this.updateScopeTypeInfos(); + this.updateScopeTypeInfos(); } /** @@ -69,7 +50,6 @@ export class ScopeInfoProvider { * @returns A {@link Disposable} which will stop the callback from running */ onDidChangeScopeInfo(callback: ScopeTypeInfoEventCallback): Disposable { - this.updateScopeTypeInfos().then(() => callback(this.getScopeTypeInfos())); callback(this.getScopeTypeInfos()); this.listeners.push(callback); @@ -82,76 +62,36 @@ export class ScopeInfoProvider { } private async onChange() { - await this.updateScopeTypeInfos(); + this.updateScopeTypeInfos(); this.listeners.forEach((listener) => listener(this.scopeInfos)); } - private async updateScopeTypeInfos(): Promise { - const update = () => { - const scopeTypes: ScopeType[] = [ - ...simpleScopeTypeTypes - // Ignore instance pseudo-scope for now - // Skip "string" because we use surrounding pair for that - .filter( - (scopeTypeType) => - scopeTypeType !== "instance" && scopeTypeType !== "string", - ) - .map((scopeTypeType) => ({ - type: scopeTypeType, - })), - - ...surroundingPairNames.map( - (surroundingPairName): SurroundingPairScopeType => ({ - type: "surroundingPair", - delimiter: surroundingPairName, - }), - ), - - ...(this.customRegexSpokenFormMap == null - ? [] - : Object.keys(this.customRegexSpokenFormMap) - ).map( - (regex): CustomRegexScopeType => ({ type: "customRegex", regex }), - ), - ]; - - this.scopeInfos = scopeTypes.map((scopeType) => - this.getScopeTypeInfo(scopeType), - ); - }; - - update(); - - return this.updateSpokenFormMaps().then(update); - } - - private async updateSpokenFormMaps() { - const entries = await getSpokenFormEntries(); - - this.simpleScopeTypeSpokenFormMap = Object.fromEntries( - entries - .filter( - (entry): entry is SimpleScopeTypeTypeSpokenFormEntry => - entry.type === "simpleScopeTypeType", - ) - .map(({ id, spokenForms }) => [id, spokenForms] as const), - ); - this.customRegexSpokenFormMap = Object.fromEntries( - entries - .filter( - (entry): entry is CustomRegexSpokenFormEntry => - entry.type === "customRegex", - ) - .map(({ id, spokenForms }) => [id, spokenForms] as const), - ); - this.pairedDelimiterSpokenFormMap = Object.fromEntries( - entries + private updateScopeTypeInfos(): void { + const scopeTypes: ScopeType[] = [ + ...simpleScopeTypeTypes + // Ignore instance pseudo-scope for now + // Skip "string" because we use surrounding pair for that .filter( - (entry): entry is PairedDelimiterSpokenFormEntry => - entry.type === "pairedDelimiter", + (scopeTypeType) => + scopeTypeType !== "instance" && scopeTypeType !== "string", ) - .map(({ id, spokenForms }) => [id, spokenForms] as const), + .map((scopeTypeType) => ({ + type: scopeTypeType, + })), + + ...surroundingPairNames.map( + (surroundingPairName): SurroundingPairScopeType => ({ + type: "surroundingPair", + delimiter: surroundingPairName, + }), + ), + + ...this.customSpokenForms.getCustomRegexScopeTypes(), + ]; + + this.scopeInfos = scopeTypes.map((scopeType) => + this.getScopeTypeInfo(scopeType), ); } @@ -162,38 +102,11 @@ export class ScopeInfoProvider { getScopeTypeInfo(scopeType: ScopeType): ScopeTypeInfo { return { scopeType, - spokenForms: this.getSpokenForms(scopeType), + spokenForm: this.spokenFormGenerator.scopeType(scopeType), humanReadableName: scopeTypeToString(scopeType), isLanguageSpecific: isLanguageSpecific(scopeType), }; } - - getSpokenForms(scopeType: ScopeType): string[] | undefined { - if (isSimpleScopeType(scopeType)) { - return this.simpleScopeTypeSpokenFormMap?.[scopeType.type]; - } - - if (scopeType.type === "surroundingPair") { - return this.pairedDelimiterSpokenFormMap?.[scopeType.delimiter]; - } - - if (scopeType.type === "customRegex") { - return this.customRegexSpokenFormMap?.[scopeType.regex]; - } - - return undefined; - } - - 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-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts index f2e5746845..040dc4ca68 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -129,16 +129,11 @@ function getSupportCategories(): SupportCategoryTreeItem[] { class ScopeSupportTreeItem extends vscode.TreeItem { constructor(scopeTypeInfo: ScopeTypeInfo) { - let label: string, description: string | undefined; - if (scopeTypeInfo.spokenForms == null) { - label = scopeTypeInfo.humanReadableName; - } else { - label = - scopeTypeInfo.spokenForms.length === 0 - ? "-" - : `"${scopeTypeInfo.spokenForms[0]}"`; - description = scopeTypeInfo.humanReadableName; - } + const label = + scopeTypeInfo.spokenForm.type === "error" + ? "-" + : `"${scopeTypeInfo.spokenForm.preferred}"`; + const description = scopeTypeInfo.humanReadableName; super(label, vscode.TreeItemCollapsibleState.None); diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts index 613435e582..8387de3cba 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts @@ -1,11 +1,12 @@ import { Disposable, FileSystem, PathChangeListener } from "@cursorless/common"; -import { RelativePattern, workspace } from "vscode"; +import { RelativePattern, Uri, workspace } from "vscode"; export class VscodeFileSystem implements FileSystem { watch(path: string, onDidChange: PathChangeListener): Disposable { + console.log(`path: ${path}`); // FIXME: Support globs? const watcher = workspace.createFileSystemWatcher( - new RelativePattern(path, "**"), + new RelativePattern(Uri.file(path), "**"), ); watcher.onDidChange(onDidChange); watcher.onDidCreate(onDidChange); From fa3164ed5728785e690ca3aff2f73afa27c2f774 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 29 Sep 2023 19:46:50 +0100 Subject: [PATCH 04/36] Initial working version --- cursorless-talon/src/csv_overrides.py | 69 ++++++++++++++----- cursorless-talon/src/spoken_forms.py | 34 ++++----- .../cursorless-engine/src/cursorlessEngine.ts | 14 ++-- .../CustomSpokenFormGenerator.ts | 39 +++++++++++ .../src/scopeProviders/ScopeInfoProvider.ts | 17 +++-- 5 files changed, 121 insertions(+), 52 deletions(-) create mode 100644 packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts diff --git a/cursorless-talon/src/csv_overrides.py b/cursorless-talon/src/csv_overrides.py index a006f96576..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 Callable, Optional +from typing import Callable, Optional, TypedDict from talon import Context, Module, actions, app, fs @@ -35,13 +36,21 @@ """ +# 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: ListToSpokenForms, - handle_new_values: Optional[Callable[[ListToSpokenForms], None]] = None, + 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, @@ -69,7 +78,7 @@ def init_csv_and_watch_changes( `cursorles-settings` dir default_values (ListToSpokenForms): The default values for the lists to be customized in the given csv - handle_new_values (Optional[Callable[[ListToSpokenForms], None]]): A + 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 @@ -185,18 +194,18 @@ def create_default_vocabulary_dicts( 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], - handle_new_values: Optional[Callable[[ListToSpokenForms], None]], + 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 @@ -206,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, @@ -217,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"): @@ -230,13 +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) - - if handle_new_values is not None: - handle_new_values(results) + yield ( + obj["list"], + value, + spoken, + ) def assign_lists_to_context( diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index 26aefd0122..a8ac285534 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -1,11 +1,15 @@ import json -from itertools import groupby from pathlib import Path from typing import Callable, Concatenate, ParamSpec, TypeVar 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 @@ -16,15 +20,13 @@ P = ParamSpec("P") R = TypeVar("R") -# Maps from Talon list name to a map from spoken form to value -ListToSpokenForms = dict[str, dict[str, str]] - def auto_construct_defaults( spoken_forms: dict[str, ListToSpokenForms], - handle_new_values: Callable[[ListToSpokenForms], None], + handle_new_values: Callable[[list[SpokenFormEntry]], None], f: Callable[ - Concatenate[str, ListToSpokenForms, Callable[[ListToSpokenForms], None], P], R + Concatenate[str, ListToSpokenForms, Callable[[list[SpokenFormEntry]], None], P], + R, ], ): """ @@ -74,7 +76,7 @@ def update(): spoken_forms = json.load(file) initialized = False - custom_spoken_forms: ListToSpokenForms = {} + custom_spoken_forms: list[SpokenFormEntry] = [] spoken_forms_output = SpokenFormsOutput() spoken_forms_output.init() @@ -82,19 +84,17 @@ def update_spoken_forms_output(): spoken_forms_output.write( [ { - "type": entry_type, - "id": value, - "spokenForms": [spoken_form[0] for spoken_form in spoken_forms], + "type": LIST_TO_TYPE_MAP[entry.list_name], + "id": entry.id, + "spokenForms": entry.spoken_forms, } - for list_name, entry_type in LIST_TO_TYPE_MAP.items() - for value, spoken_forms in groupby( - custom_spoken_forms[list_name].items(), lambda item: item[1] - ) + for entry in custom_spoken_forms + if entry.list_name in LIST_TO_TYPE_MAP ] ) - def handle_new_values(values: ListToSpokenForms): - custom_spoken_forms.update(values) + def handle_new_values(values: list[SpokenFormEntry]): + custom_spoken_forms.extend(values) if initialized: # On first run, we just do one update at the end, so we suppress # writing until we get there diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 90dc880484..442119ea68 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -6,7 +6,6 @@ import { IDE, } from "@cursorless/common"; import { StoredTargetMap, TestCaseRecorder, TreeSitter } from "."; -import { CustomSpokenForms } from "./CustomSpokenForms"; import { CursorlessEngine } from "./api/CursorlessEngineApi"; import { ScopeProvider } from "./api/ScopeProvider"; import { Debug } from "./core/Debug"; @@ -14,6 +13,7 @@ import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; +import { CustomSpokenFormGenerator } from "./generateSpokenForm/CustomSpokenFormGenerator"; import { LanguageDefinitions } from "./languages/LanguageDefinitions"; import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl"; import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers"; @@ -25,7 +25,6 @@ import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher"; import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker"; import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher"; import { injectIde } from "./singletons/ide.singleton"; -import { SpokenFormGenerator } from "./generateSpokenForm"; export function createCursorlessEngine( treeSitter: TreeSitter, @@ -57,7 +56,7 @@ export function createCursorlessEngine( const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter); - const customSpokenForms = new CustomSpokenForms(fileSystem); + const customSpokenFormGenerator = new CustomSpokenFormGenerator(fileSystem); ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug); @@ -96,7 +95,7 @@ export function createCursorlessEngine( scopeProvider: createScopeProvider( languageDefinitions, storedTargets, - customSpokenForms, + customSpokenFormGenerator, ), testCaseRecorder, storedTargets, @@ -111,7 +110,7 @@ export function createCursorlessEngine( function createScopeProvider( languageDefinitions: LanguageDefinitions, storedTargets: StoredTargetMap, - customSpokenForms: CustomSpokenForms, + customSpokenFormGenerator: CustomSpokenFormGenerator, ): ScopeProvider { const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions); @@ -129,10 +128,7 @@ function createScopeProvider( rangeProvider, ); const supportChecker = new ScopeSupportChecker(scopeHandlerFactory); - const infoProvider = new ScopeInfoProvider( - customSpokenForms, - new SpokenFormGenerator(customSpokenForms), - ); + const infoProvider = new ScopeInfoProvider(customSpokenFormGenerator); const supportWatcher = new ScopeSupportWatcher( languageDefinitions, supportChecker, diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts new file mode 100644 index 0000000000..03ad02ffac --- /dev/null +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts @@ -0,0 +1,39 @@ +import { + CommandComplete, + Disposer, + FileSystem, + Listener, + ScopeType, +} from "@cursorless/common"; +import { SpokenFormGenerator } from "."; +import { CustomSpokenForms } from "../CustomSpokenForms"; + +export class CustomSpokenFormGenerator { + private customSpokenForms: CustomSpokenForms; + private spokenFormGenerator: SpokenFormGenerator; + private disposer = new Disposer(); + + constructor(fileSystem: FileSystem) { + this.customSpokenForms = new CustomSpokenForms(fileSystem); + this.spokenFormGenerator = new SpokenFormGenerator(this.customSpokenForms); + this.disposer.push( + this.customSpokenForms.onDidChangeCustomSpokenForms(() => { + this.spokenFormGenerator = new SpokenFormGenerator( + this.customSpokenForms, + ); + }), + ); + } + + onDidChangeCustomSpokenForms = (listener: Listener<[]>) => + this.customSpokenForms.onDidChangeCustomSpokenForms(listener); + + commandToSpokenForm = (command: CommandComplete) => + this.spokenFormGenerator.command(command); + + scopeTypeToSpokenForm = (scopeType: ScopeType) => + this.spokenFormGenerator.scopeType(scopeType); + + getCustomRegexScopeTypes = () => + this.customSpokenForms.getCustomRegexScopeTypes(); +} diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts index 83a9afb939..fcf38a57b4 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -10,9 +10,8 @@ import { pull } from "lodash"; import { homedir } from "os"; import * as path from "path"; import { ScopeTypeInfo, ScopeTypeInfoEventCallback } from ".."; -import { CustomSpokenForms } from "../CustomSpokenForms"; -import { SpokenFormGenerator } from "../generateSpokenForm"; +import { CustomSpokenFormGenerator } from "../generateSpokenForm/CustomSpokenFormGenerator"; import { scopeTypeToString } from "./scopeTypeToString"; export const spokenFormsPath = path.join( @@ -29,12 +28,11 @@ export class ScopeInfoProvider { private listeners: ScopeTypeInfoEventCallback[] = []; private scopeInfos!: ScopeTypeInfo[]; - constructor( - private customSpokenForms: CustomSpokenForms, - private spokenFormGenerator: SpokenFormGenerator, - ) { + constructor(private customSpokenFormGenerator: CustomSpokenFormGenerator) { this.disposer.push( - customSpokenForms.onDidChangeCustomSpokenForms(() => this.onChange()), + customSpokenFormGenerator.onDidChangeCustomSpokenForms(() => + this.onChange(), + ), ); this.onDidChangeScopeInfo = this.onDidChangeScopeInfo.bind(this); @@ -87,7 +85,7 @@ export class ScopeInfoProvider { }), ), - ...this.customSpokenForms.getCustomRegexScopeTypes(), + ...this.customSpokenFormGenerator.getCustomRegexScopeTypes(), ]; this.scopeInfos = scopeTypes.map((scopeType) => @@ -102,7 +100,8 @@ export class ScopeInfoProvider { getScopeTypeInfo(scopeType: ScopeType): ScopeTypeInfo { return { scopeType, - spokenForm: this.spokenFormGenerator.scopeType(scopeType), + spokenForm: + this.customSpokenFormGenerator.scopeTypeToSpokenForm(scopeType), humanReadableName: scopeTypeToString(scopeType), isLanguageSpecific: isLanguageSpecific(scopeType), }; From f358dcafd1bb775e45bce48a60b671fea2cae05c Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 29 Sep 2023 21:20:05 +0100 Subject: [PATCH 05/36] Cleanup --- cursorless-talon/src/spoken_forms_output.py | 8 ++- .../src/CustomSpokenForms.ts | 53 +++++++------------ .../CustomSpokenFormGenerator.ts | 22 +++++--- .../scopeProviders/getSpokenFormEntries.ts | 13 ++++- 4 files changed, 52 insertions(+), 44 deletions(-) diff --git a/cursorless-talon/src/spoken_forms_output.py b/cursorless-talon/src/spoken_forms_output.py index ed2b128806..3eaa97cf1e 100644 --- a/cursorless-talon/src/spoken_forms_output.py +++ b/cursorless-talon/src/spoken_forms_output.py @@ -4,6 +4,8 @@ from talon import app +from .command import CursorlessCommand + SPOKEN_FORMS_OUTPUT_PATH = Path.home() / ".cursorless" / "spokenForms.json" @@ -31,7 +33,11 @@ def init(self): def write(self, spoken_forms: list[SpokenFormEntry]): with open(SPOKEN_FORMS_OUTPUT_PATH, "w") as out: try: - out.write(json.dumps({"version": 0, "entries": spoken_forms})) + out.write( + json.dumps( + {"version": CursorlessCommand.version, "entries": spoken_forms} + ) + ) except Exception: error_message = ( f"Error writing spoken form json {SPOKEN_FORMS_OUTPUT_PATH}" diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts index 5fc3412060..2cfe749a46 100644 --- a/packages/cursorless-engine/src/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -6,12 +6,7 @@ import { } from "@cursorless/common"; import { homedir } from "os"; import * as path from "path"; -import { - CustomRegexSpokenFormEntry, - PairedDelimiterSpokenFormEntry, - SimpleScopeTypeTypeSpokenFormEntry, - getSpokenFormEntries, -} from "./scopeProviders/getSpokenFormEntries"; +import { getSpokenFormEntries } from "./scopeProviders/getSpokenFormEntries"; import { SpokenFormMap } from "./SpokenFormMap"; import { defaultSpokenFormMap } from "./DefaultSpokenFormMap"; @@ -21,6 +16,12 @@ export const spokenFormsPath = path.join( "spokenForms.json", ); +const ENTRY_TYPES = [ + "simpleScopeTypeType", + "customRegex", + "pairedDelimiter", +] as const; + /** * Maintains a list of all scope types and notifies listeners when it changes. */ @@ -66,36 +67,22 @@ export class CustomSpokenForms implements SpokenFormMap { onDidChangeCustomSpokenForms = this.notifier.registerListener; private async updateSpokenFormMaps(): Promise { - console.log("updateSpokenFormMaps before getSpokenFormEntries"); const entries = await getSpokenFormEntries(); - console.log("updateSpokenFormMaps after getSpokenFormEntries"); - this.simpleScopeTypeType = Object.fromEntries( - entries - .filter( - (entry): entry is SimpleScopeTypeTypeSpokenFormEntry => - entry.type === "simpleScopeTypeType", - ) - .map(({ id, spokenForms }) => [id, spokenForms] as const), - ); - this.customRegex = Object.fromEntries( - entries - .filter( - (entry): entry is CustomRegexSpokenFormEntry => - entry.type === "customRegex", - ) - .map(({ id, spokenForms }) => [id, spokenForms] as const), - ); - this.pairedDelimiter = Object.fromEntries( - entries - .filter( - (entry): entry is PairedDelimiterSpokenFormEntry => - entry.type === "pairedDelimiter", - ) - .map(({ id, spokenForms }) => [id, spokenForms] as const), - ); + for (const entryType of ENTRY_TYPES) { + // TODO: Handle case where we've added a new scope type but they haven't yet + // updated their talon files. In that case we want to indicate in tree view + // that the scope type exists but they need to update their talon files to + // be able to speak it. We could just detect that there's no entry for it in + // the spoken forms file, but that feels a bit brittle. + // FIXME: How to avoid the type assertion? + this[entryType] = Object.fromEntries( + entries + .filter((entry) => entry.type === entryType) + .map(({ id, spokenForms }) => [id, spokenForms]), + ) as any; + } - console.log("updateSpokenFormMaps at end"); this.isInitialized_ = true; this.notifier.notifyListeners(); } diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts index 03ad02ffac..b0961b9278 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts @@ -25,15 +25,21 @@ export class CustomSpokenFormGenerator { ); } - onDidChangeCustomSpokenForms = (listener: Listener<[]>) => - this.customSpokenForms.onDidChangeCustomSpokenForms(listener); + onDidChangeCustomSpokenForms(listener: Listener<[]>) { + return this.customSpokenForms.onDidChangeCustomSpokenForms(listener); + } - commandToSpokenForm = (command: CommandComplete) => - this.spokenFormGenerator.command(command); + commandToSpokenForm(command: CommandComplete) { + return this.spokenFormGenerator.command(command); + } - scopeTypeToSpokenForm = (scopeType: ScopeType) => - this.spokenFormGenerator.scopeType(scopeType); + scopeTypeToSpokenForm(scopeType: ScopeType) { + return this.spokenFormGenerator.scopeType(scopeType); + } + + getCustomRegexScopeTypes() { + return this.customSpokenForms.getCustomRegexScopeTypes(); + } - getCustomRegexScopeTypes = () => - this.customSpokenForms.getCustomRegexScopeTypes(); + dispose = this.disposer.dispose; } diff --git a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts index 6c571af02d..0db47d552c 100644 --- a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts +++ b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts @@ -1,4 +1,4 @@ -import { SimpleScopeTypeType } from "@cursorless/common"; +import { LATEST_VERSION, SimpleScopeTypeType } from "@cursorless/common"; import { readFile } from "fs/promises"; import { spokenFormsPath } from "./ScopeInfoProvider"; import { SpeakableSurroundingPairName } from "../SpokenFormMap"; @@ -28,7 +28,16 @@ type SpokenFormEntry = export async function getSpokenFormEntries(): Promise { try { - return JSON.parse(await readFile(spokenFormsPath, "utf-8")).entries; + const payload = JSON.parse(await readFile(spokenFormsPath, "utf-8")); + + if (payload.version !== LATEST_VERSION) { + // 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; } catch (err) { console.error(`Error getting spoken forms`); console.error(err); From cea0fbeb45dda29bb50cb039b01b7bd7a038ed65 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 29 Sep 2023 22:41:57 +0100 Subject: [PATCH 06/36] Remove undesirable pairs --- .../src/scopeProviders/ScopeInfoProvider.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts index fcf38a57b4..38fd0270c8 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -13,6 +13,7 @@ import { ScopeTypeInfo, ScopeTypeInfoEventCallback } from ".."; import { CustomSpokenFormGenerator } from "../generateSpokenForm/CustomSpokenFormGenerator"; import { scopeTypeToString } from "./scopeTypeToString"; +import { SpeakableSurroundingPairName } from "../SpokenFormMap"; export const spokenFormsPath = path.join( homedir(), @@ -68,7 +69,7 @@ export class ScopeInfoProvider { private updateScopeTypeInfos(): void { const scopeTypes: ScopeType[] = [ ...simpleScopeTypeTypes - // Ignore instance pseudo-scope for now + // Ignore instance pseudo-scope because it's not really a scope // Skip "string" because we use surrounding pair for that .filter( (scopeTypeType) => @@ -78,12 +79,21 @@ export class ScopeInfoProvider { type: scopeTypeType, })), - ...surroundingPairNames.map( - (surroundingPairName): SurroundingPairScopeType => ({ - type: "surroundingPair", - delimiter: surroundingPairName, - }), - ), + ...surroundingPairNames + .filter( + ( + surroundingPairName, + ): surroundingPairName is Exclude< + SpeakableSurroundingPairName, + "whitespace" + > => surroundingPairName !== "collectionBoundary", + ) + .map( + (surroundingPairName): SurroundingPairScopeType => ({ + type: "surroundingPair", + delimiter: surroundingPairName, + }), + ), ...this.customSpokenFormGenerator.getCustomRegexScopeTypes(), ]; From 1d9fe17ff8b935e4f1ad11c98b5ac90da0d815d5 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 4 Oct 2023 16:16:12 +0100 Subject: [PATCH 07/36] Fixes; support custom regex spoken forms --- .vscode/launch.json | 17 ++++++++++++++ .../generateSpokenForm.test.ts | 15 +++++++++++++ .../generateSpokenForm/generateSpokenForm.ts | 6 ++++- .../primitiveTargetToSpokenForm.ts | 22 ++++++++++++++----- .../recorded/customRegex/clearWhite.yml | 4 +++- .../ordinalScopes/clearFirstPaint.yml | 4 +++- .../ordinalScopes/clearFirstPaint2.yml | 4 +++- .../recorded/ordinalScopes/clearLastPaint.yml | 4 +++- .../ordinalScopes/clearLastPaint2.yml | 4 +++- .../selectionTypes/clearCustomRegex.yml | 4 +++- .../selectionTypes/clearEveryCustomRegex.yml | 4 +++- 11 files changed, 74 insertions(+), 14 deletions(-) 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/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts index 7bdc5048a2..25c2652d60 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts @@ -16,6 +16,21 @@ suite("Generate spoken forms", () => { getRecordedTestPaths().forEach(({ name, path }) => test(name, () => runTest(path)), ); + + test("generate spoken form for custom regex", () => { + const generator = new SpokenFormGenerator({ + ...defaultSpokenFormMap, + customRegex: { foo: ["bar"] }, + }); + + const spokenForm = generator.scopeType({ + type: "customRegex", + regex: "foo", + }); + + assert(spokenForm.type === "success"); + assert.equal(spokenForm.preferred, "bar"); + }); }); async function runTest(file: string) { diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts index 51463ecd07..ac5c26b98f 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts @@ -250,8 +250,12 @@ function constructSpokenForms(component: SpokenFormComponent): string[] { } if (Array.isArray(component)) { + if (component.length === 0) { + return [""]; + } + return cartesianProduct(component.map(constructSpokenForms)).map((words) => - words.join(" "), + words.filter((word) => word.length !== 0).join(" "), ); } diff --git a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts index b399be81a0..adafe742a9 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts @@ -209,7 +209,6 @@ export class PrimitiveTargetSpokenFormGenerator { handleScopeType(scopeType: ScopeType): SpokenFormComponent { switch (scopeType.type) { case "oneOf": - case "customRegex": case "switchStatementSubject": case "string": throw new NoSpokenFormError(`Scope type '${scopeType.type}'`); @@ -221,15 +220,26 @@ export class PrimitiveTargetSpokenFormGenerator { } const pair = this.spokenFormMap.pairedDelimiter[scopeType.delimiter]; if (scopeType.forceDirection != null) { - const direction = - scopeType.forceDirection === "left" - ? this.spokenFormMap.surroundingPairForceDirection.left - : this.spokenFormMap.surroundingPairForceDirection.right; - return `${direction} ${pair}`; + return [ + this.spokenFormMap.surroundingPairForceDirection[ + scopeType.forceDirection + ], + pair, + ]; } return pair; } + case "customRegex": + return ( + this.spokenFormMap.customRegex[scopeType.regex] ?? { + type: "singleTerm", + spokenForms: [], + spokenFormType: "customRegex", + id: scopeType.regex, + } + ); + default: return this.spokenFormMap.simpleScopeTypeType[scopeType.type]; } 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: From 23483e1cafbbd4a1c8d0863d95ce5fe858adad3f Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 4 Oct 2023 17:35:56 +0100 Subject: [PATCH 08/36] More fixes --- packages/common/src/cursorlessCommandIds.ts | 4 ++-- .../src/scopeProviders/ScopeInfoProvider.ts | 4 +++- packages/cursorless-vscode/package.json | 3 +-- .../src/ScopeSupportTreeProvider.ts | 20 +++++++++++++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index f1c69de9f5..2a047ef946 100644 --- a/packages/common/src/cursorlessCommandIds.ts +++ b/packages/common/src/cursorlessCommandIds.ts @@ -110,10 +110,10 @@ export const cursorlessCommandDescriptions: Record< ["cursorless.keyboard.modal.modeToggle"]: new HiddenCommand( "Toggle the cursorless modal mode", ), - ["cursorless.showScopeVisualizer"]: new HiddenCommand( + ["cursorless.showScopeVisualizer"]: new VisibleCommand( "Show the scope visualizer", ), - ["cursorless.hideScopeVisualizer"]: new HiddenCommand( + ["cursorless.hideScopeVisualizer"]: new VisibleCommand( "Hide the scope visualizer", ), }; diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts index 38fd0270c8..d4df70280c 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -73,7 +73,9 @@ export class ScopeInfoProvider { // Skip "string" because we use surrounding pair for that .filter( (scopeTypeType) => - scopeTypeType !== "instance" && scopeTypeType !== "string", + scopeTypeType !== "instance" && + scopeTypeType !== "string" && + scopeTypeType !== "switchStatementSubject", ) .map((scopeTypeType) => ({ type: scopeTypeType, diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 21504f7ff4..7f49d58be4 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -187,8 +187,7 @@ }, { "command": "cursorless.showScopeVisualizer", - "title": "Cursorless: Show the scope visualizer", - "enablement": "false" + "title": "Cursorless: Show the scope visualizer" }, { "command": "cursorless.hideScopeVisualizer", diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts index 040dc4ca68..fa725b7787 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -1,3 +1,4 @@ +import { CursorlessCommandId } from "@cursorless/common"; import { ScopeProvider, ScopeSupport, @@ -5,6 +6,7 @@ import { ScopeTypeInfo, } from "@cursorless/cursorless-engine"; import * as vscode from "vscode"; +import { VisualizationType } from "./ScopeVisualizerCommandApi"; export class ScopeSupportTreeProvider implements vscode.TreeDataProvider @@ -138,6 +140,24 @@ class ScopeSupportTreeItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); this.description = description; + + if ( + scopeTypeInfo.spokenForm.type === "success" && + scopeTypeInfo.spokenForm.alternatives.length > 0 + ) { + this.tooltip = scopeTypeInfo.spokenForm.alternatives + .map((spokenForm) => `"${spokenForm}"`) + .join("\n"); + } + + this.command = { + command: "cursorless.showScopeVisualizer" satisfies CursorlessCommandId, + arguments: [ + scopeTypeInfo.scopeType, + "content" satisfies VisualizationType, + ], + title: `Visualize ${scopeTypeInfo.humanReadableName}`, + }; } } From 8855b3dbff979934595eeb837d7521317ad60d9e Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 4 Oct 2023 17:47:51 +0100 Subject: [PATCH 09/36] Add icons --- .../src/ScopeSupportTreeProvider.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts index fa725b7787..dd427214ac 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -96,7 +96,17 @@ export class ScopeSupportTreeProvider getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] { return this.supportLevels .filter((supportLevel) => supportLevel.support === scopeSupport) - .map((supportLevel) => new ScopeSupportTreeItem(supportLevel)); + .map((supportLevel) => new ScopeSupportTreeItem(supportLevel)) + .sort((a, b) => { + if ( + a.scopeTypeInfo.isLanguageSpecific !== + b.scopeTypeInfo.isLanguageSpecific + ) { + return a.scopeTypeInfo.isLanguageSpecific ? -1 : 1; + } + + return a.label.localeCompare(b.label); + }); } dispose() { @@ -130,7 +140,9 @@ function getSupportCategories(): SupportCategoryTreeItem[] { } class ScopeSupportTreeItem extends vscode.TreeItem { - constructor(scopeTypeInfo: ScopeTypeInfo) { + public label: string; + + constructor(public scopeTypeInfo: ScopeTypeInfo) { const label = scopeTypeInfo.spokenForm.type === "error" ? "-" @@ -139,6 +151,8 @@ class ScopeSupportTreeItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); + this.label = label; + this.description = description; if ( @@ -158,6 +172,10 @@ class ScopeSupportTreeItem extends vscode.TreeItem { ], title: `Visualize ${scopeTypeInfo.humanReadableName}`, }; + + if (scopeTypeInfo.isLanguageSpecific) { + this.resourceUri = vscode.window.activeTextEditor?.document.uri; + } } } From e80dfec40a8048d7aed1cf2f9cb97fcec5ed7b4f Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 4 Oct 2023 17:50:50 +0100 Subject: [PATCH 10/36] move unspeakable to end --- packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts index dd427214ac..32284f905d 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -98,6 +98,12 @@ export class ScopeSupportTreeProvider .filter((supportLevel) => supportLevel.support === scopeSupport) .map((supportLevel) => new ScopeSupportTreeItem(supportLevel)) .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 From 961dafe73423c9cfb0408cc29df807c2f45ad5df Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 4 Oct 2023 17:57:10 +0100 Subject: [PATCH 11/36] Fix package.json --- packages/common/src/cursorlessCommandIds.ts | 12 ++++++------ packages/cursorless-vscode/package.json | 17 ++++++++--------- .../src/getCursorlessVscodeFields.ts | 4 ++++ 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index 2a047ef946..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 VisibleCommand( - "Show the scope visualizer", - ), - ["cursorless.hideScopeVisualizer"]: new VisibleCommand( - "Hide the scope visualizer", - ), }; diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 7f49d58be4..7533814b71 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -115,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", @@ -184,15 +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" - }, - { - "command": "cursorless.hideScopeVisualizer", - "title": "Cursorless: Hide the scope visualizer", - "enablement": "false" } ], "colors": [ diff --git a/packages/meta-updater/src/getCursorlessVscodeFields.ts b/packages/meta-updater/src/getCursorlessVscodeFields.ts index 2abf53d185..7975005f25 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:cursorlessScopeSupport", + // 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 From 8be3ab70cd83a74c1123e3b3ab350ed0f0c93278 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 5 Oct 2023 15:08:26 +0100 Subject: [PATCH 12/36] Use code icons for language specific scopes --- packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts index 32284f905d..eee07ff421 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -180,7 +180,7 @@ class ScopeSupportTreeItem extends vscode.TreeItem { }; if (scopeTypeInfo.isLanguageSpecific) { - this.resourceUri = vscode.window.activeTextEditor?.document.uri; + this.iconPath = new vscode.ThemeIcon("code"); } } } From 16caab3cbe16d762f6491cede966a0525ff09d57 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 5 Oct 2023 15:52:55 +0100 Subject: [PATCH 13/36] Add sidebar view --- cursorless-talon/src/apps/cursorless_vscode.py | 6 ++++++ cursorless-talon/src/cursorless.py | 3 +++ cursorless-talon/src/cursorless.talon | 3 +++ images/icon.svg | 13 +++++++++++++ packages/cursorless-vscode/package.json | 13 +++++++++++-- .../src/scripts/populateDist/assets.ts | 1 + 6 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 images/icon.svg 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/cursorless.py b/cursorless-talon/src/cursorless.py index 86147fb1eb..2383d7af5a 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 sidebar""" 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/images/icon.svg b/images/icon.svg new file mode 100644 index 0000000000..18ff1f36be --- /dev/null +++ b/images/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 7533814b71..121ae6829e 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -79,10 +79,10 @@ }, "contributes": { "views": { - "explorer": [ + "cursorless": [ { "id": "cursorlessScopeSupport", - "name": "Cursorless scope support" + "name": "Scope support" } ] }, @@ -1039,6 +1039,15 @@ "fontCharacter": "\\E900" } } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "cursorless", + "title": "Cursorless", + "icon": "images/icon.svg" + } + ] } }, "sponsor": { 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", From 98d3aeea5c898b9c15d24155215ec45c6c883ce0 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 5 Oct 2023 16:00:14 +0100 Subject: [PATCH 14/36] `cursorlessScopeSupport` => `cursorless.scopeSupport` --- packages/cursorless-vscode/package.json | 4 ++-- packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts | 2 +- packages/meta-updater/src/getCursorlessVscodeFields.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 121ae6829e..fc44646524 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -46,7 +46,7 @@ ], "activationEvents": [ "onLanguage", - "onView:cursorlessScopeSupport", + "onView:cursorless.scopeSupport", "onCommand:cursorless.command", "onCommand:cursorless.internal.updateCheatsheetDefaults", "onCommand:cursorless.keyboard.escape", @@ -81,7 +81,7 @@ "views": { "cursorless": [ { - "id": "cursorlessScopeSupport", + "id": "cursorless.scopeSupport", "name": "Scope support" } ] diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts index eee07ff421..407f4fbdc3 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -26,7 +26,7 @@ export class ScopeSupportTreeProvider private context: vscode.ExtensionContext, private scopeProvider: ScopeProvider, ) { - this.treeView = vscode.window.createTreeView("cursorlessScopeSupport", { + this.treeView = vscode.window.createTreeView("cursorless.scopeSupport", { treeDataProvider: this, }); diff --git a/packages/meta-updater/src/getCursorlessVscodeFields.ts b/packages/meta-updater/src/getCursorlessVscodeFields.ts index 7975005f25..64eaa63bc6 100644 --- a/packages/meta-updater/src/getCursorlessVscodeFields.ts +++ b/packages/meta-updater/src/getCursorlessVscodeFields.ts @@ -23,7 +23,7 @@ export function getCursorlessVscodeFields(input: PackageJson) { // Causes extension to activate when the Cursorless scope support side bar // is opened - "onView:cursorlessScopeSupport", + "onView:cursorless.scopeSupport", // 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 From 6a17506825bb89ef3eb05f1765108f399423ec53 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 5 Oct 2023 16:02:53 +0100 Subject: [PATCH 15/36] tweak typing --- .../common/src/types/command/PartialTargetDescriptor.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index e3539c127e..06d4bae36c 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -172,7 +172,7 @@ export const simpleScopeTypeTypes = [ export function isSimpleScopeType( scopeType: ScopeType, ): scopeType is SimpleScopeType { - return simpleScopeTypeTypes.includes(scopeType.type as any); + return (simpleScopeTypeTypes as readonly string[]).includes(scopeType.type); } export type SimpleScopeTypeType = (typeof simpleScopeTypeTypes)[number]; From cd200bcf2ef0542272b03240dfb9e3ceecb1c227 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 5 Oct 2023 16:28:06 +0100 Subject: [PATCH 16/36] Detect which scope is being visualized --- .../src/ScopeSupportTreeProvider.ts | 82 +++++++++++++------ .../src/ScopeVisualizerCommandApi.ts | 10 ++- packages/cursorless-vscode/src/extension.ts | 32 ++++++-- .../cursorless-vscode/src/registerCommands.ts | 4 +- 4 files changed, 95 insertions(+), 33 deletions(-) diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts index 407f4fbdc3..ee057baadf 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -1,4 +1,4 @@ -import { CursorlessCommandId } from "@cursorless/common"; +import { CursorlessCommandId, Disposer } from "@cursorless/common"; import { ScopeProvider, ScopeSupport, @@ -6,12 +6,16 @@ import { ScopeTypeInfo, } from "@cursorless/cursorless-engine"; import * as vscode from "vscode"; -import { VisualizationType } from "./ScopeVisualizerCommandApi"; +import { + ScopeVisualizer, + VisualizationType, +} from "./ScopeVisualizerCommandApi"; +import { isEqual } from "lodash"; export class ScopeSupportTreeProvider implements vscode.TreeDataProvider { - private onDidChangeScopeSupportDisposable: vscode.Disposable | undefined; + private visibleDisposable: Disposer | undefined; private treeView: vscode.TreeView; private supportLevels: ScopeSupportLevels = []; @@ -25,6 +29,7 @@ export class ScopeSupportTreeProvider constructor( private context: vscode.ExtensionContext, private scopeProvider: ScopeProvider, + private scopeVisualizer: ScopeVisualizer, ) { this.treeView = vscode.window.createTreeView("cursorless.scopeSupport", { treeDataProvider: this, @@ -40,8 +45,13 @@ export class ScopeSupportTreeProvider static create( context: vscode.ExtensionContext, scopeProvider: ScopeProvider, + scopeVisualizer: ScopeVisualizer, ): ScopeSupportTreeProvider { - const treeProvider = new ScopeSupportTreeProvider(context, scopeProvider); + const treeProvider = new ScopeSupportTreeProvider( + context, + scopeProvider, + scopeVisualizer, + ); treeProvider.init(); return treeProvider; } @@ -54,27 +64,32 @@ export class ScopeSupportTreeProvider onDidChangeVisible(e: vscode.TreeViewVisibilityChangeEvent) { if (e.visible) { - if (this.onDidChangeScopeSupportDisposable != null) { + if (this.visibleDisposable != null) { return; } this.registerScopeSupportListener(); } else { - if (this.onDidChangeScopeSupportDisposable == null) { + if (this.visibleDisposable == null) { return; } - this.onDidChangeScopeSupportDisposable.dispose(); - this.onDidChangeScopeSupportDisposable = undefined; + this.visibleDisposable.dispose(); + this.visibleDisposable = undefined; } } private registerScopeSupportListener() { - this.onDidChangeScopeSupportDisposable = + 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 { @@ -96,7 +111,13 @@ export class ScopeSupportTreeProvider getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] { return this.supportLevels .filter((supportLevel) => supportLevel.support === scopeSupport) - .map((supportLevel) => new ScopeSupportTreeItem(supportLevel)) + .map( + (supportLevel) => + new ScopeSupportTreeItem( + supportLevel, + isEqual(supportLevel.scopeType, this.scopeVisualizer.scopeType), + ), + ) .sort((a, b) => { if ( a.scopeTypeInfo.spokenForm.type !== b.scopeTypeInfo.spokenForm.type @@ -111,12 +132,12 @@ export class ScopeSupportTreeProvider return a.scopeTypeInfo.isLanguageSpecific ? -1 : 1; } - return a.label.localeCompare(b.label); + return a.label.label.localeCompare(b.label.label); }); } dispose() { - this.onDidChangeScopeSupportDisposable?.dispose(); + this.visibleDisposable?.dispose(); } } @@ -146,9 +167,12 @@ function getSupportCategories(): SupportCategoryTreeItem[] { } class ScopeSupportTreeItem extends vscode.TreeItem { - public label: string; + public label: vscode.TreeItemLabel; - constructor(public scopeTypeInfo: ScopeTypeInfo) { + constructor( + public scopeTypeInfo: ScopeTypeInfo, + isVisualized: boolean, + ) { const label = scopeTypeInfo.spokenForm.type === "error" ? "-" @@ -157,7 +181,10 @@ class ScopeSupportTreeItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); - this.label = label; + this.label = { + label, + highlights: isVisualized ? [[0, label.length]] : [], + }; this.description = description; @@ -170,14 +197,21 @@ class ScopeSupportTreeItem extends vscode.TreeItem { .join("\n"); } - this.command = { - command: "cursorless.showScopeVisualizer" satisfies CursorlessCommandId, - arguments: [ - scopeTypeInfo.scopeType, - "content" satisfies VisualizationType, - ], - title: `Visualize ${scopeTypeInfo.humanReadableName}`, - }; + 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 vscode.ThemeIcon("code"); diff --git a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts index 77dcc80c6a..10b6b16d4c 100644 --- a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts +++ b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts @@ -1,8 +1,14 @@ -import { ScopeType } from "@cursorless/common"; +import { Disposable, ScopeType } from "@cursorless/common"; -export interface ScopeVisualizerCommandApi { +export type VisualizerScopeTypeListener = ( + scopeType: ScopeType | 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/extension.ts b/packages/cursorless-vscode/src/extension.ts index 26cb5d4724..578b03c801 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -35,7 +35,7 @@ import { KeyboardCommands } from "./keyboard/KeyboardCommands"; import { registerCommands } from "./registerCommands"; import { ReleaseNotes } from "./ReleaseNotes"; import { - ScopeVisualizerCommandApi, + ScopeVisualizer, VisualizationType, } from "./ScopeVisualizerCommandApi"; import { StatusBarItem } from "./StatusBarItem"; @@ -92,14 +92,15 @@ export async function activate( const statusBarItem = StatusBarItem.create("cursorless.showQuickPick"); const keyboardCommands = KeyboardCommands.create(context, statusBarItem); - ScopeSupportTreeProvider.create(context, scopeProvider); + const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider); + ScopeSupportTreeProvider.create(context, scopeProvider, scopeVisualizer); registerCommands( context, vscodeIDE, commandApi, testCaseRecorder, - createScopeVisualizerCommandApi(normalizedIde, scopeProvider), + scopeVisualizer, keyboardCommands, hats, ); @@ -157,11 +158,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: VisualizerScopeTypeListener[] = []; return { start(scopeType: ScopeType, visualizationType: VisualizationType) { @@ -173,11 +177,29 @@ function createScopeVisualizerCommandApi( visualizationType, ); scopeVisualizer.start(); + currentScopeType = scopeType; + listeners.forEach((listener) => listener(scopeType)); }, stop() { scopeVisualizer?.dispose(); scopeVisualizer = undefined; + currentScopeType = undefined; + listeners.forEach((listener) => listener(undefined)); + }, + + get scopeType() { + return currentScopeType; + }, + + onDidChangeScopeType(listener: VisualizerScopeTypeListener): Disposable { + listeners.push(listener); + + return { + dispose() { + listeners.splice(listeners.indexOf(listener), 1); + }, + }; }, }; } 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 { From 664ec80f4540ac081778c29fa4b453f23df1736d Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 6 Oct 2023 17:12:16 +0100 Subject: [PATCH 17/36] More sophisticated custom spoken forms --- cursorless-talon/src/cursorless.py | 2 +- .../command/PartialTargetDescriptor.types.ts | 9 ++ .../src/CustomSpokenForms.ts | 104 +++++++++++++++--- .../src/DefaultSpokenFormMap.ts | 91 ++++++++++++--- .../cursorless-engine/src/SpokenFormMap.ts | 10 +- .../src/api/CursorlessEngineApi.ts | 11 ++ .../cursorless-engine/src/cursorlessEngine.ts | 7 +- ...or.ts => CustomSpokenFormGeneratorImpl.ts} | 9 +- .../GeneratorSpokenFormMap.ts | 5 +- .../generateSpokenForm/NoSpokenFormError.ts | 6 +- .../generateSpokenForm.test.ts | 31 +++++- .../generateSpokenForm/generateSpokenForm.ts | 15 ++- .../primitiveTargetToSpokenForm.ts | 7 +- .../src/scopeProviders/ScopeInfoProvider.ts | 22 +--- .../scopeProviders/getSpokenFormEntries.ts | 41 ++++--- .../src/ScopeSupportTreeProvider.ts | 85 ++++++++++++-- packages/cursorless-vscode/src/extension.ts | 12 +- .../src/ide/vscode/VscodeGlobalState.ts | 4 +- 18 files changed, 377 insertions(+), 94 deletions(-) rename packages/cursorless-engine/src/generateSpokenForm/{CustomSpokenFormGenerator.ts => CustomSpokenFormGeneratorImpl.ts} (84%) diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py index 2383d7af5a..d64791219d 100644 --- a/cursorless-talon/src/cursorless.py +++ b/cursorless-talon/src/cursorless.py @@ -14,4 +14,4 @@ def private_cursorless_show_settings_in_ide(): """Show Cursorless-specific settings in ide""" def private_cursorless_show_sidebar(): - """Show Cursorless sidebar""" + """Show Cursorless-specific settings in ide""" diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index 06d4bae36c..ff4cccceff 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -175,6 +175,15 @@ export function isSimpleScopeType( 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 { diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts index 2cfe749a46..792e3c5977 100644 --- a/packages/cursorless-engine/src/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -3,18 +3,25 @@ import { Disposer, FileSystem, Notifier, + showError, } from "@cursorless/common"; -import { homedir } from "os"; -import * as path from "path"; -import { getSpokenFormEntries } from "./scopeProviders/getSpokenFormEntries"; -import { SpokenFormMap } from "./SpokenFormMap"; -import { defaultSpokenFormMap } from "./DefaultSpokenFormMap"; - -export const spokenFormsPath = path.join( - homedir(), - ".cursorless", - "spokenForms.json", -); +import { isEqual } from "lodash"; +import { + defaultSpokenFormInfo, + defaultSpokenFormMap, +} from "./DefaultSpokenFormMap"; +import { + SpokenFormMap, + SpokenFormMapEntry, + SpokenFormType, +} from "./SpokenFormMap"; +import { + SpokenFormEntry, + getSpokenFormEntries, + spokenFormsPath, +} from "./scopeProviders/getSpokenFormEntries"; +import { ide } from "./singletons/ide.singleton"; +import { dirname } from "node:path"; const ENTRY_TYPES = [ "simpleScopeTypeType", @@ -41,6 +48,15 @@ export class CustomSpokenForms implements SpokenFormMap { modifierExtra = defaultSpokenFormMap.modifierExtra; private isInitialized_ = 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 @@ -53,7 +69,9 @@ export class CustomSpokenForms implements SpokenFormMap { constructor(fileSystem: FileSystem) { this.disposer.push( - fileSystem.watch(spokenFormsPath, () => this.updateSpokenFormMaps()), + fileSystem.watch(dirname(spokenFormsPath), () => + this.updateSpokenFormMaps(), + ), ); this.updateSpokenFormMaps(); @@ -67,7 +85,30 @@ export class CustomSpokenForms implements SpokenFormMap { onDidChangeCustomSpokenForms = this.notifier.registerListener; private async updateSpokenFormMaps(): Promise { - const entries = await getSpokenFormEntries(); + let entries: SpokenFormEntry[]; + try { + entries = await getSpokenFormEntries(); + } catch (err) { + if ((err as any)?.code === "ENOENT") { + // Handle case where spokenForms.json doesn't exist yet + console.log( + `Custom spoken forms file not found at ${spokenFormsPath}. Using default spoken forms.`, + ); + this.needsInitialTalonUpdate_ = true; + this.notifier.notifyListeners(); + } 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.`, + ); + } + + return; + } for (const entryType of ENTRY_TYPES) { // TODO: Handle case where we've added a new scope type but they haven't yet @@ -76,10 +117,45 @@ export class CustomSpokenForms implements SpokenFormMap { // be able to speak it. We could just detect that there's no entry for it in // the spoken forms file, but that feels a bit brittle. // FIXME: How to avoid the type assertion? - this[entryType] = Object.fromEntries( + const entry = Object.fromEntries( entries .filter((entry) => entry.type === entryType) .map(({ id, spokenForms }) => [id, spokenForms]), + ); + + this[entryType] = Object.fromEntries( + Object.entries(defaultSpokenFormInfo[entryType]).map( + ([key, { defaultSpokenForms, isSecret }]): [ + SpokenFormType, + SpokenFormMapEntry, + ] => { + const customSpokenForms = entry[key]; + if (customSpokenForms != null) { + return [ + key as SpokenFormType, + { + defaultSpokenForms, + spokenForms: customSpokenForms, + requiresTalonUpdate: false, + isCustom: isEqual(defaultSpokenForms, customSpokenForms), + isSecret, + }, + ]; + } else { + return [ + key 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; } diff --git a/packages/cursorless-engine/src/DefaultSpokenFormMap.ts b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts index ff6198b061..6681690b8f 100644 --- a/packages/cursorless-engine/src/DefaultSpokenFormMap.ts +++ b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts @@ -1,13 +1,17 @@ import { mapValues } from "lodash"; -import { SpokenFormMap, SpokenFormMapKeyTypes } from "./SpokenFormMap"; +import { + SpokenFormMap, + SpokenFormMapEntry, + SpokenFormMapKeyTypes, +} from "./SpokenFormMap"; -type DefaultSpokenFormMap = { +type DefaultSpokenFormMapDefinition = { readonly [K in keyof SpokenFormMapKeyTypes]: Readonly< - Record + Record >; }; -const defaultSpokenFormMapCore: DefaultSpokenFormMap = { +const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { pairedDelimiter: { curlyBrackets: "curly", angleBrackets: "diamond", @@ -45,15 +49,14 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = { name: "name", regularExpression: "regex", section: "section", - sectionLevelOne: "one section", - sectionLevelTwo: "two section", - sectionLevelThree: "three section", - sectionLevelFour: "four section", - sectionLevelFive: "five section", - sectionLevelSix: "six 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", - string: "string", branch: "branch", type: "type", value: "value", @@ -88,7 +91,8 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = { url: "link", notebookCell: "cell", - switchStatementSubject: null, + string: secret("parse tree string"), + switchStatementSubject: secret("subject"), }, surroundingPairForceDirection: { @@ -124,10 +128,67 @@ const defaultSpokenFormMapCore: DefaultSpokenFormMap = { customRegex: {}, }; -// TODO: Don't cast here; need to make our own mapValues with stronger typing +function disabledByDefault( + ...spokenForms: string[] +): DefaultSpokenFormMapEntry { + return { + defaultSpokenForms: spokenForms, + isDisabledByDefault: true, + isSecret: false, + }; +} + +function secret(...spokenForms: string[]): DefaultSpokenFormMapEntry { + return { + defaultSpokenForms: spokenForms, + isDisabledByDefault: true, + isSecret: true, + }; +} + +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 defaultSpokenFormMap = mapValues( +export const defaultSpokenFormInfo = mapValues( defaultSpokenFormMapCore, (entry) => - mapValues(entry, (subEntry) => (subEntry == null ? [] : [subEntry])), + 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 index 30f5e82d9e..4d4a4e691c 100755 --- a/packages/cursorless-engine/src/SpokenFormMap.ts +++ b/packages/cursorless-engine/src/SpokenFormMap.ts @@ -37,8 +37,16 @@ export interface SpokenFormMapKeyTypes { 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 + Record >; }; diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index 4dd323bf27..e4219a6897 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -7,6 +7,7 @@ import { ScopeProvider } from "./ScopeProvider"; export interface CursorlessEngine { commandApi: CommandApi; scopeProvider: ScopeProvider; + customSpokenFormGenerator: CustomSpokenFormGenerator; testCaseRecorder: TestCaseRecorder; storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; @@ -15,6 +16,16 @@ export interface CursorlessEngine { 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/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 442119ea68..fef1e197c1 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -13,7 +13,7 @@ import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; -import { CustomSpokenFormGenerator } from "./generateSpokenForm/CustomSpokenFormGenerator"; +import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl"; import { LanguageDefinitions } from "./languages/LanguageDefinitions"; import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl"; import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers"; @@ -56,7 +56,7 @@ export function createCursorlessEngine( const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter); - const customSpokenFormGenerator = new CustomSpokenFormGenerator(fileSystem); + const customSpokenFormGenerator = new CustomSpokenFormGeneratorImpl(fileSystem); ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug); @@ -97,6 +97,7 @@ export function createCursorlessEngine( storedTargets, customSpokenFormGenerator, ), + customSpokenFormGenerator, testCaseRecorder, storedTargets, hatTokenMap, @@ -110,7 +111,7 @@ export function createCursorlessEngine( function createScopeProvider( languageDefinitions: LanguageDefinitions, storedTargets: StoredTargetMap, - customSpokenFormGenerator: CustomSpokenFormGenerator, + customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, ): ScopeProvider { const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions); diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts similarity index 84% rename from packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts rename to packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts index b0961b9278..04e40d7649 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGenerator.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -6,9 +6,12 @@ import { ScopeType, } from "@cursorless/common"; import { SpokenFormGenerator } from "."; +import { CustomSpokenFormGenerator } from ".."; import { CustomSpokenForms } from "../CustomSpokenForms"; -export class CustomSpokenFormGenerator { +export class CustomSpokenFormGeneratorImpl + implements CustomSpokenFormGenerator +{ private customSpokenForms: CustomSpokenForms; private spokenFormGenerator: SpokenFormGenerator; private disposer = new Disposer(); @@ -41,5 +44,9 @@ export class CustomSpokenFormGenerator { 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 index c134c983cd..a6b85117ef 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/GeneratorSpokenFormMap.ts @@ -1,5 +1,6 @@ import { SpokenFormMap, + SpokenFormMapEntry, SpokenFormMapKeyTypes, SpokenFormType, } from "../SpokenFormMap"; @@ -13,7 +14,7 @@ export type GeneratorSpokenFormMap = { export interface SingleTermSpokenForm { type: "singleTerm"; - spokenForms: string[]; + spokenForms: SpokenFormMapEntry; spokenFormType: SpokenFormType; id: string; } @@ -26,7 +27,7 @@ export type SpokenFormComponent = export function getGeneratorSpokenForms( spokenFormMap: SpokenFormMap, ): GeneratorSpokenFormMap { - // TODO: Don't cast here; need to make our own mapValues with stronger typing + // 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]) => [ 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/generateSpokenForm.test.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts index 25c2652d60..fc063c51ad 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.test.ts @@ -10,7 +10,22 @@ import { promises as fsp } from "node:fs"; import { canonicalizeAndValidateCommand } from "../core/commandVersionUpgrades/canonicalizeAndValidateCommand"; import { getHatMapCommand } from "./getHatMapCommand"; import { SpokenFormGenerator } from "."; -import { defaultSpokenFormMap } from "../DefaultSpokenFormMap"; +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 }) => @@ -19,8 +34,16 @@ suite("Generate spoken forms", () => { test("generate spoken form for custom regex", () => { const generator = new SpokenFormGenerator({ - ...defaultSpokenFormMap, - customRegex: { foo: ["bar"] }, + ...spokenFormMap, + customRegex: { + foo: { + spokenForms: ["bar"], + isCustom: false, + defaultSpokenForms: ["bar"], + requiresTalonUpdate: false, + isSecret: false, + }, + }, }); const spokenForm = generator.scopeType({ @@ -37,7 +60,7 @@ async function runTest(file: string) { const buffer = await fsp.readFile(file); const fixture = yaml.load(buffer.toString()) as TestCaseFixtureLegacy; - const generator = new SpokenFormGenerator(defaultSpokenFormMap); + const generator = new SpokenFormGenerator(spokenFormMap); const generatedSpokenForm = generator.command( canonicalizeAndValidateCommand(fixture.command), diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts index ac5c26b98f..33f02b38f4 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts @@ -33,6 +33,8 @@ export interface SpokenFormSuccess { export interface SpokenFormError { type: "error"; reason: string; + requiresTalonUpdate: boolean; + isSecret: boolean; } export type SpokenForm = SpokenFormSuccess | SpokenFormError; @@ -80,7 +82,12 @@ export class SpokenFormGenerator { return { type: "success", preferred, alternatives }; } catch (e) { if (e instanceof NoSpokenFormError) { - return { type: "error", reason: e.reason }; + return { + type: "error", + reason: e.reason, + requiresTalonUpdate: e.requiresTalonUpdate, + isSecret: e.isSecret, + }; } throw e; @@ -259,15 +266,17 @@ function constructSpokenForms(component: SpokenFormComponent): string[] { ); } - if (component.spokenForms.length === 0) { + 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; + return component.spokenForms.spokenForms; } /** diff --git a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts index adafe742a9..79523126ed 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts @@ -209,8 +209,6 @@ export class PrimitiveTargetSpokenFormGenerator { handleScopeType(scopeType: ScopeType): SpokenFormComponent { switch (scopeType.type) { case "oneOf": - case "switchStatementSubject": - case "string": throw new NoSpokenFormError(`Scope type '${scopeType.type}'`); case "surroundingPair": { if (scopeType.delimiter === "collectionBoundary") { @@ -337,7 +335,10 @@ function pluralize(name: SpokenFormComponent): SpokenFormComponent { return { ...name, - spokenForms: name.spokenForms.map(pluralizeString), + spokenForms: { + ...name.spokenForms, + spokenForms: name.spokenForms.spokenForms.map(pluralizeString), + }, }; } diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts index d4df70280c..f9e9eb798e 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -7,20 +7,12 @@ import { surroundingPairNames, } from "@cursorless/common"; import { pull } from "lodash"; -import { homedir } from "os"; -import * as path from "path"; import { ScopeTypeInfo, ScopeTypeInfoEventCallback } from ".."; -import { CustomSpokenFormGenerator } from "../generateSpokenForm/CustomSpokenFormGenerator"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; import { scopeTypeToString } from "./scopeTypeToString"; import { SpeakableSurroundingPairName } from "../SpokenFormMap"; -export const spokenFormsPath = path.join( - homedir(), - ".cursorless", - "spokenForms.json", -); - /** * Maintains a list of all scope types and notifies listeners when it changes. */ @@ -29,7 +21,9 @@ export class ScopeInfoProvider { private listeners: ScopeTypeInfoEventCallback[] = []; private scopeInfos!: ScopeTypeInfo[]; - constructor(private customSpokenFormGenerator: CustomSpokenFormGenerator) { + constructor( + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) { this.disposer.push( customSpokenFormGenerator.onDidChangeCustomSpokenForms(() => this.onChange(), @@ -70,13 +64,7 @@ export class ScopeInfoProvider { const scopeTypes: ScopeType[] = [ ...simpleScopeTypeTypes // Ignore instance pseudo-scope because it's not really a scope - // Skip "string" because we use surrounding pair for that - .filter( - (scopeTypeType) => - scopeTypeType !== "instance" && - scopeTypeType !== "string" && - scopeTypeType !== "switchStatementSubject", - ) + .filter((scopeTypeType) => scopeTypeType !== "instance") .map((scopeTypeType) => ({ type: scopeTypeType, })), diff --git a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts index 0db47d552c..127f33e0c9 100644 --- a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts +++ b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts @@ -1,7 +1,14 @@ import { LATEST_VERSION, SimpleScopeTypeType } from "@cursorless/common"; import { readFile } from "fs/promises"; -import { spokenFormsPath } from "./ScopeInfoProvider"; +import { homedir } from "os"; import { SpeakableSurroundingPairName } from "../SpokenFormMap"; +import * as path from "path"; + +export const spokenFormsPath = path.join( + homedir(), + ".cursorless", + "spokenForms.json", +); export interface CustomRegexSpokenFormEntry { type: "customRegex"; @@ -21,26 +28,26 @@ export interface SimpleScopeTypeTypeSpokenFormEntry { spokenForms: string[]; } -type SpokenFormEntry = +export type SpokenFormEntry = | CustomRegexSpokenFormEntry | PairedDelimiterSpokenFormEntry | SimpleScopeTypeTypeSpokenFormEntry; export async function getSpokenFormEntries(): Promise { - try { - const payload = JSON.parse(await readFile(spokenFormsPath, "utf-8")); - - if (payload.version !== LATEST_VERSION) { - // 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; - } catch (err) { - console.error(`Error getting spoken forms`); - console.error(err); - return []; + const payload = JSON.parse(await readFile(spokenFormsPath, "utf-8")); + + /** + * 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; } diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts index ee057baadf..3efb0ef4af 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts @@ -1,16 +1,21 @@ import { CursorlessCommandId, Disposer } from "@cursorless/common"; import { + CustomSpokenFormGenerator, ScopeProvider, ScopeSupport, ScopeSupportLevels, ScopeTypeInfo, } from "@cursorless/cursorless-engine"; +import { VscodeApi } from "@cursorless/vscode-common"; +import { isEqual } from "lodash"; import * as vscode from "vscode"; +import { URI } from "vscode-uri"; import { ScopeVisualizer, VisualizationType, } from "./ScopeVisualizerCommandApi"; -import { isEqual } from "lodash"; + +export const DONT_SHOW_TALON_UPDATE_MESSAGE_KEY = "dontShowUpdateTalonMessage"; export class ScopeSupportTreeProvider implements vscode.TreeDataProvider @@ -18,6 +23,7 @@ export class ScopeSupportTreeProvider private visibleDisposable: Disposer | undefined; private treeView: vscode.TreeView; private supportLevels: ScopeSupportLevels = []; + private shownUpdateTalonMessage = false; private _onDidChangeTreeData: vscode.EventEmitter< MyTreeItem | undefined | null | void @@ -27,11 +33,14 @@ export class ScopeSupportTreeProvider > = this._onDidChangeTreeData.event; constructor( + private vscodeApi: VscodeApi, private context: vscode.ExtensionContext, private scopeProvider: ScopeProvider, private scopeVisualizer: ScopeVisualizer, + private customSpokenFormGenerator: CustomSpokenFormGenerator, + private hasCommandServer: boolean, ) { - this.treeView = vscode.window.createTreeView("cursorless.scopeSupport", { + this.treeView = vscodeApi.window.createTreeView("cursorless.scopeSupport", { treeDataProvider: this, }); @@ -43,14 +52,20 @@ export class ScopeSupportTreeProvider } static create( + vscodeApi: VscodeApi, context: vscode.ExtensionContext, scopeProvider: ScopeProvider, scopeVisualizer: ScopeVisualizer, + customSpokenFormGenerator: CustomSpokenFormGenerator, + hasCommandServer: boolean, ): ScopeSupportTreeProvider { const treeProvider = new ScopeSupportTreeProvider( + vscodeApi, context, scopeProvider, scopeVisualizer, + customSpokenFormGenerator, + hasCommandServer, ); treeProvider.init(); return treeProvider; @@ -98,6 +113,7 @@ export class ScopeSupportTreeProvider getChildren(element?: MyTreeItem): MyTreeItem[] { if (element == null) { + this.possiblyShowUpdateTalonMessage(); return getSupportCategories(); } @@ -108,9 +124,46 @@ export class ScopeSupportTreeProvider 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 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?", + "Don't show again", + ); + + if (result === "How?") { + await this.vscodeApi.env.openExternal( + URI.parse( + "https://www.cursorless.org/docs/user/updating/#updating-the-talon-side", + ), + ); + } else if (result === "Don't show again") { + await this.context.globalState.update( + DONT_SHOW_TALON_UPDATE_MESSAGE_KEY, + true, + ); + } + } + getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] { return this.supportLevels - .filter((supportLevel) => supportLevel.support === scopeSupport) + .filter( + (supportLevel) => + supportLevel.support === scopeSupport && + (supportLevel.spokenForm.type !== "error" || + !supportLevel.spokenForm.isSecret), + ) .map( (supportLevel) => new ScopeSupportTreeItem( @@ -169,6 +222,11 @@ function getSupportCategories(): SupportCategoryTreeItem[] { class ScopeSupportTreeItem extends vscode.TreeItem { public label: vscode.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, @@ -181,6 +239,10 @@ class ScopeSupportTreeItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); + const requiresTalonUpdate = + scopeTypeInfo.spokenForm.type === "error" && + scopeTypeInfo.spokenForm.requiresTalonUpdate; + this.label = { label, highlights: isVisualized ? [[0, label.length]] : [], @@ -188,13 +250,16 @@ class ScopeSupportTreeItem extends vscode.TreeItem { this.description = description; - if ( - scopeTypeInfo.spokenForm.type === "success" && - scopeTypeInfo.spokenForm.alternatives.length > 0 - ) { - this.tooltip = scopeTypeInfo.spokenForm.alternatives - .map((spokenForm) => `"${spokenForm}"`) - .join("\n"); + 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 diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 578b03c801..9614f182a7 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -1,4 +1,5 @@ import { + Disposable, FakeIDE, getFakeCommandServerApi, IDE, @@ -37,6 +38,7 @@ import { ReleaseNotes } from "./ReleaseNotes"; import { ScopeVisualizer, VisualizationType, + VisualizerScopeTypeListener, } from "./ScopeVisualizerCommandApi"; import { StatusBarItem } from "./StatusBarItem"; import { vscodeApi } from "./vscodeApi"; @@ -82,6 +84,7 @@ export async function activate( snippets, injectIde, runIntegrationTests, + customSpokenFormGenerator, } = createCursorlessEngine( treeSitter, normalizedIde, @@ -93,7 +96,14 @@ export async function activate( const statusBarItem = StatusBarItem.create("cursorless.showQuickPick"); const keyboardCommands = KeyboardCommands.create(context, statusBarItem); const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider); - ScopeSupportTreeProvider.create(context, scopeProvider, scopeVisualizer); + ScopeSupportTreeProvider.create( + vscodeApi, + context, + scopeProvider, + scopeVisualizer, + customSpokenFormGenerator, + commandServerApi != null, + ); registerCommands( context, diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts index 760524d893..2e7da3bb63 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 "../../ScopeSupportTreeProvider"; 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, ]); } From 21046a5e03f4aca0034ecfcd59945b8fafe473f1 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 6 Oct 2023 17:13:28 +0100 Subject: [PATCH 18/36] Remove TODO --- packages/cursorless-engine/src/CustomSpokenForms.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts index 792e3c5977..73a9fc1f99 100644 --- a/packages/cursorless-engine/src/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -111,11 +111,6 @@ export class CustomSpokenForms implements SpokenFormMap { } for (const entryType of ENTRY_TYPES) { - // TODO: Handle case where we've added a new scope type but they haven't yet - // updated their talon files. In that case we want to indicate in tree view - // that the scope type exists but they need to update their talon files to - // be able to speak it. We could just detect that there's no entry for it in - // the spoken forms file, but that feels a bit brittle. // FIXME: How to avoid the type assertion? const entry = Object.fromEntries( entries From 824c9420cdeb7ce1c04dd8ffc01637eeb985ab55 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 6 Oct 2023 16:15:27 +0000 Subject: [PATCH 19/36] [pre-commit.ci lite] apply automatic fixes --- packages/cursorless-engine/src/cursorlessEngine.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index fef1e197c1..a3d8dd783f 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -56,7 +56,9 @@ export function createCursorlessEngine( const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter); - const customSpokenFormGenerator = new CustomSpokenFormGeneratorImpl(fileSystem); + const customSpokenFormGenerator = new CustomSpokenFormGeneratorImpl( + fileSystem, + ); ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug); From 0e6d71a4c269b5266c933c612ac7bb9bc2b73810 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Mon, 9 Oct 2023 14:45:49 +0100 Subject: [PATCH 20/36] simplify icon --- images/icon.svg | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/images/icon.svg b/images/icon.svg index 18ff1f36be..e7fb1c351d 100644 --- a/images/icon.svg +++ b/images/icon.svg @@ -1,13 +1,6 @@ - - - - - - - - - - - + + + + From 534c172b0380e93e87f9e60df73f791aeadb2171 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 10 Oct 2023 14:30:26 +0100 Subject: [PATCH 21/36] Fix custom regexes --- .../src/CustomSpokenForms.ts | 69 ++++++++++--------- .../src/DefaultSpokenFormMap.ts | 2 +- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts index 73a9fc1f99..702508aa04 100644 --- a/packages/cursorless-engine/src/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -6,7 +6,9 @@ import { showError, } from "@cursorless/common"; import { isEqual } from "lodash"; +import { dirname } from "node:path"; import { + DefaultSpokenFormMapEntry, defaultSpokenFormInfo, defaultSpokenFormMap, } from "./DefaultSpokenFormMap"; @@ -21,7 +23,6 @@ import { spokenFormsPath, } from "./scopeProviders/getSpokenFormEntries"; import { ide } from "./singletons/ide.singleton"; -import { dirname } from "node:path"; const ENTRY_TYPES = [ "simpleScopeTypeType", @@ -118,39 +119,41 @@ export class CustomSpokenForms implements SpokenFormMap { .map(({ id, spokenForms }) => [id, spokenForms]), ); + const defaultEntry: Partial> = + defaultSpokenFormInfo[entryType]; + const ids = Array.from( + new Set([...Object.keys(defaultEntry), ...Object.keys(entry)]), + ); this[entryType] = Object.fromEntries( - Object.entries(defaultSpokenFormInfo[entryType]).map( - ([key, { defaultSpokenForms, isSecret }]): [ - SpokenFormType, - SpokenFormMapEntry, - ] => { - const customSpokenForms = entry[key]; - if (customSpokenForms != null) { - return [ - key as SpokenFormType, - { - defaultSpokenForms, - spokenForms: customSpokenForms, - requiresTalonUpdate: false, - isCustom: isEqual(defaultSpokenForms, customSpokenForms), - isSecret, - }, - ]; - } else { - return [ - key as SpokenFormType, - { - defaultSpokenForms, - spokenForms: [], - // If it's not a secret spoken form, then it's a new scope type - requiresTalonUpdate: !isSecret, - isCustom: false, - isSecret, - }, - ]; - } - }, - ), + 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; } diff --git a/packages/cursorless-engine/src/DefaultSpokenFormMap.ts b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts index 6681690b8f..340abbd0cc 100644 --- a/packages/cursorless-engine/src/DefaultSpokenFormMap.ts +++ b/packages/cursorless-engine/src/DefaultSpokenFormMap.ts @@ -146,7 +146,7 @@ function secret(...spokenForms: string[]): DefaultSpokenFormMapEntry { }; } -interface DefaultSpokenFormMapEntry { +export interface DefaultSpokenFormMapEntry { defaultSpokenForms: string[]; isDisabledByDefault: boolean; isSecret: boolean; From ccde2156be80a344f82c50706859a24678bc625a Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 10 Oct 2023 15:31:46 +0100 Subject: [PATCH 22/36] Move talon spoken forms json to its own file --- .../src/CustomSpokenForms.ts | 22 ++-- .../cursorless-engine/src/cursorlessEngine.ts | 5 +- .../CustomSpokenFormGeneratorImpl.ts | 6 +- .../src/scopeProviders/SpokenFormEntry.ts | 37 ++++++ .../scopeProviders/getSpokenFormEntries.ts | 112 ++++++++++++------ 5 files changed, 125 insertions(+), 57 deletions(-) create mode 100644 packages/cursorless-engine/src/scopeProviders/SpokenFormEntry.ts diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts index 702508aa04..4b2a28d077 100644 --- a/packages/cursorless-engine/src/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -1,12 +1,10 @@ import { CustomRegexScopeType, Disposer, - FileSystem, Notifier, showError, } from "@cursorless/common"; import { isEqual } from "lodash"; -import { dirname } from "node:path"; import { DefaultSpokenFormMapEntry, defaultSpokenFormInfo, @@ -18,10 +16,10 @@ import { SpokenFormType, } from "./SpokenFormMap"; import { + NeedsInitialTalonUpdateError, SpokenFormEntry, - getSpokenFormEntries, - spokenFormsPath, -} from "./scopeProviders/getSpokenFormEntries"; + TalonSpokenForms, +} from "./scopeProviders/SpokenFormEntry"; import { ide } from "./singletons/ide.singleton"; const ENTRY_TYPES = [ @@ -68,11 +66,9 @@ export class CustomSpokenForms implements SpokenFormMap { return this.isInitialized_; } - constructor(fileSystem: FileSystem) { + constructor(private talonSpokenForms: TalonSpokenForms) { this.disposer.push( - fileSystem.watch(dirname(spokenFormsPath), () => - this.updateSpokenFormMaps(), - ), + talonSpokenForms.onDidChange(() => this.updateSpokenFormMaps()), ); this.updateSpokenFormMaps(); @@ -88,13 +84,11 @@ export class CustomSpokenForms implements SpokenFormMap { private async updateSpokenFormMaps(): Promise { let entries: SpokenFormEntry[]; try { - entries = await getSpokenFormEntries(); + entries = await this.talonSpokenForms.getSpokenFormEntries(); } catch (err) { - if ((err as any)?.code === "ENOENT") { + if (err instanceof NeedsInitialTalonUpdateError) { // Handle case where spokenForms.json doesn't exist yet - console.log( - `Custom spoken forms file not found at ${spokenFormsPath}. Using default spoken forms.`, - ); + console.log(err.message); this.needsInitialTalonUpdate_ = true; this.notifier.notifyListeners(); } else { diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index a3d8dd783f..b09b737dcf 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -25,6 +25,7 @@ import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher"; import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker"; import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher"; import { injectIde } from "./singletons/ide.singleton"; +import { TalonSpokenFormsJsonReader } from "./scopeProviders/getSpokenFormEntries"; export function createCursorlessEngine( treeSitter: TreeSitter, @@ -56,8 +57,10 @@ export function createCursorlessEngine( const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter); + const talonSpokenForms = new TalonSpokenFormsJsonReader(fileSystem); + const customSpokenFormGenerator = new CustomSpokenFormGeneratorImpl( - fileSystem, + talonSpokenForms, ); ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug); diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts index 04e40d7649..0d1d770912 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -1,13 +1,13 @@ import { CommandComplete, Disposer, - FileSystem, 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 @@ -16,8 +16,8 @@ export class CustomSpokenFormGeneratorImpl private spokenFormGenerator: SpokenFormGenerator; private disposer = new Disposer(); - constructor(fileSystem: FileSystem) { - this.customSpokenForms = new CustomSpokenForms(fileSystem); + constructor(talonSpokenForms: TalonSpokenForms) { + this.customSpokenForms = new CustomSpokenForms(talonSpokenForms); this.spokenFormGenerator = new SpokenFormGenerator(this.customSpokenForms); this.disposer.push( this.customSpokenForms.onDidChangeCustomSpokenForms(() => { 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/getSpokenFormEntries.ts b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts index 127f33e0c9..b2bfb75638 100644 --- a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts +++ b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts @@ -1,53 +1,87 @@ -import { LATEST_VERSION, SimpleScopeTypeType } from "@cursorless/common"; -import { readFile } from "fs/promises"; -import { homedir } from "os"; -import { SpeakableSurroundingPairName } from "../SpokenFormMap"; -import * as path from "path"; +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"; -export const spokenFormsPath = path.join( - homedir(), - ".cursorless", - "spokenForms.json", -); +import * as path from "path"; +import { + NeedsInitialTalonUpdateError, + SpokenFormEntry, + TalonSpokenForms, +} from "./SpokenFormEntry"; -export interface CustomRegexSpokenFormEntry { - type: "customRegex"; - id: string; - spokenForms: string[]; +interface TalonSpokenFormsPayload { + version: number; + entries: SpokenFormEntry[]; } -export interface PairedDelimiterSpokenFormEntry { - type: "pairedDelimiter"; - id: SpeakableSurroundingPairName; - spokenForms: string[]; -} +export class TalonSpokenFormsJsonReader implements TalonSpokenForms { + private disposer = new Disposer(); + private notifier = new Notifier(); + private spokenFormsPath; -export interface SimpleScopeTypeTypeSpokenFormEntry { - type: "simpleScopeTypeType"; - id: SimpleScopeTypeType; - spokenForms: string[]; -} + constructor(private fileSystem: FileSystem) { + const cursorlessDir = isTesting() + ? path.join(os.tmpdir(), crypto.randomBytes(16).toString("hex")) + : path.join(os.homedir(), ".cursorless"); -export type SpokenFormEntry = - | CustomRegexSpokenFormEntry - | PairedDelimiterSpokenFormEntry - | SimpleScopeTypeTypeSpokenFormEntry; + this.spokenFormsPath = path.join(cursorlessDir, "spokenForms.json"); -export async function getSpokenFormEntries(): Promise { - const payload = JSON.parse(await readFile(spokenFormsPath, "utf-8")); + 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()), + ); + } /** - * This assignment is to ensure that the compiler will error if we forget to - * handle spokenForms.json when we bump the command version. + * 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 */ - const latestCommandVersion: 6 = LATEST_VERSION; + onDidChange = this.notifier.registerListener; - 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}`, - ); + 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; } - return payload.entries; + dispose() { + this.disposer.dispose(); + } } From 3dec077ea6848cf7f4e268baa46688a97131751c Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 10 Oct 2023 15:33:24 +0100 Subject: [PATCH 23/36] more stuff --- packages/cursorless-engine/src/api/CursorlessEngineApi.ts | 1 + packages/cursorless-engine/src/cursorlessEngine.ts | 1 + .../src/scopeProviders/getSpokenFormEntries.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index e4219a6897..d7c400618a 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -12,6 +12,7 @@ export interface CursorlessEngine { storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; snippets: Snippets; + spokenFormsJsonPath: string; injectIde: (ide: IDE | undefined) => void; runIntegrationTests: () => Promise; } diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index b09b737dcf..31826edebb 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -107,6 +107,7 @@ export function createCursorlessEngine( storedTargets, hatTokenMap, snippets, + spokenFormsJsonPath: talonSpokenForms.spokenFormsPath, injectIde, runIntegrationTests: () => runIntegrationTests(treeSitter, languageDefinitions), diff --git a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts index b2bfb75638..4ffcfc1ae9 100644 --- a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts +++ b/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts @@ -24,7 +24,7 @@ interface TalonSpokenFormsPayload { export class TalonSpokenFormsJsonReader implements TalonSpokenForms { private disposer = new Disposer(); private notifier = new Notifier(); - private spokenFormsPath; + public readonly spokenFormsPath; constructor(private fileSystem: FileSystem) { const cursorlessDir = isTesting() From 62e56c5c9723b1317c2eae9232f40d33e0b1868d Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 10 Oct 2023 17:56:53 +0100 Subject: [PATCH 24/36] Rename --- packages/cursorless-vscode/package.json | 6 +- ...rtTreeProvider.ts => ScopeTreeProvider.ts} | 77 +++++++++++-------- packages/cursorless-vscode/src/extension.ts | 4 +- .../src/ide/vscode/VscodeGlobalState.ts | 2 +- .../src/getCursorlessVscodeFields.ts | 2 +- .../vscode-common/src/cursorlessSideBarIds.ts | 1 + packages/vscode-common/src/index.ts | 1 + 7 files changed, 54 insertions(+), 39 deletions(-) rename packages/cursorless-vscode/src/{ScopeSupportTreeProvider.ts => ScopeTreeProvider.ts} (82%) create mode 100644 packages/vscode-common/src/cursorlessSideBarIds.ts diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index fc44646524..1b185bf957 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -46,7 +46,7 @@ ], "activationEvents": [ "onLanguage", - "onView:cursorless.scopeSupport", + "onView:cursorless.scopes", "onCommand:cursorless.command", "onCommand:cursorless.internal.updateCheatsheetDefaults", "onCommand:cursorless.keyboard.escape", @@ -81,8 +81,8 @@ "views": { "cursorless": [ { - "id": "cursorless.scopeSupport", - "name": "Scope support" + "id": "cursorless.scopes", + "name": "Scopes" } ] }, diff --git a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts similarity index 82% rename from packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts rename to packages/cursorless-vscode/src/ScopeTreeProvider.ts index 3efb0ef4af..2a54008c34 100644 --- a/packages/cursorless-vscode/src/ScopeSupportTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts @@ -7,8 +7,22 @@ import { ScopeTypeInfo, } 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 * as vscode from "vscode"; +import type { + Event, + ExtensionContext, + TreeDataProvider, + TreeItemLabel, + TreeView, + TreeViewVisibilityChangeEvent, +} from "vscode"; +import { + EventEmitter, + ThemeIcon, + TreeItem, + TreeItemCollapsibleState, +} from "vscode"; import { URI } from "vscode-uri"; import { ScopeVisualizer, @@ -16,33 +30,32 @@ import { } from "./ScopeVisualizerCommandApi"; export const DONT_SHOW_TALON_UPDATE_MESSAGE_KEY = "dontShowUpdateTalonMessage"; - -export class ScopeSupportTreeProvider - implements vscode.TreeDataProvider -{ +export class ScopeTreeProvider implements TreeDataProvider { private visibleDisposable: Disposer | undefined; - private treeView: vscode.TreeView; + private treeView: TreeView; private supportLevels: ScopeSupportLevels = []; private shownUpdateTalonMessage = false; - private _onDidChangeTreeData: vscode.EventEmitter< + private _onDidChangeTreeData: EventEmitter< MyTreeItem | undefined | null | void - > = new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event< - MyTreeItem | undefined | null | void - > = this._onDidChangeTreeData.event; + > = new EventEmitter(); + readonly onDidChangeTreeData: Event = + this._onDidChangeTreeData.event; constructor( private vscodeApi: VscodeApi, - private context: vscode.ExtensionContext, + private context: ExtensionContext, private scopeProvider: ScopeProvider, private scopeVisualizer: ScopeVisualizer, private customSpokenFormGenerator: CustomSpokenFormGenerator, private hasCommandServer: boolean, ) { - this.treeView = vscodeApi.window.createTreeView("cursorless.scopeSupport", { - treeDataProvider: this, - }); + this.treeView = vscodeApi.window.createTreeView( + CURSORLESS_SCOPE_TREE_VIEW_ID, + { + treeDataProvider: this, + }, + ); this.context.subscriptions.push( this.treeView, @@ -53,13 +66,13 @@ export class ScopeSupportTreeProvider static create( vscodeApi: VscodeApi, - context: vscode.ExtensionContext, + context: ExtensionContext, scopeProvider: ScopeProvider, scopeVisualizer: ScopeVisualizer, customSpokenFormGenerator: CustomSpokenFormGenerator, hasCommandServer: boolean, - ): ScopeSupportTreeProvider { - const treeProvider = new ScopeSupportTreeProvider( + ): ScopeTreeProvider { + const treeProvider = new ScopeTreeProvider( vscodeApi, context, scopeProvider, @@ -77,7 +90,7 @@ export class ScopeSupportTreeProvider } } - onDidChangeVisible(e: vscode.TreeViewVisibilityChangeEvent) { + onDidChangeVisible(e: TreeViewVisibilityChangeEvent) { if (e.visible) { if (this.visibleDisposable != null) { return; @@ -197,30 +210,30 @@ export class ScopeSupportTreeProvider function getSupportCategories(): SupportCategoryTreeItem[] { return [ new SupportCategoryTreeItem( - "Supported and present in editor", + "Present", ScopeSupport.supportedAndPresentInEditor, - vscode.TreeItemCollapsibleState.Expanded, + TreeItemCollapsibleState.Expanded, ), new SupportCategoryTreeItem( - "Supported but not present in editor", + "Not present", ScopeSupport.supportedButNotPresentInEditor, - vscode.TreeItemCollapsibleState.Expanded, + TreeItemCollapsibleState.Expanded, ), new SupportCategoryTreeItem( - "Supported using legacy pathways", + "Legacy", ScopeSupport.supportedLegacy, - vscode.TreeItemCollapsibleState.Expanded, + TreeItemCollapsibleState.Expanded, ), new SupportCategoryTreeItem( "Unsupported", ScopeSupport.unsupported, - vscode.TreeItemCollapsibleState.Collapsed, + TreeItemCollapsibleState.Collapsed, ), ]; } -class ScopeSupportTreeItem extends vscode.TreeItem { - public label: vscode.TreeItemLabel; +class ScopeSupportTreeItem extends TreeItem { + public label: TreeItemLabel; /** * @param scopeTypeInfo The scope type info @@ -237,7 +250,7 @@ class ScopeSupportTreeItem extends vscode.TreeItem { : `"${scopeTypeInfo.spokenForm.preferred}"`; const description = scopeTypeInfo.humanReadableName; - super(label, vscode.TreeItemCollapsibleState.None); + super(label, TreeItemCollapsibleState.None); const requiresTalonUpdate = scopeTypeInfo.spokenForm.type === "error" && @@ -279,16 +292,16 @@ class ScopeSupportTreeItem extends vscode.TreeItem { }; if (scopeTypeInfo.isLanguageSpecific) { - this.iconPath = new vscode.ThemeIcon("code"); + this.iconPath = new ThemeIcon("code"); } } } -class SupportCategoryTreeItem extends vscode.TreeItem { +class SupportCategoryTreeItem extends TreeItem { constructor( label: string, public readonly scopeSupport: ScopeSupport, - collapsibleState: vscode.TreeItemCollapsibleState, + collapsibleState: TreeItemCollapsibleState, ) { super(label, collapsibleState); } diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 9614f182a7..e3ac8e4396 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -42,7 +42,7 @@ import { } from "./ScopeVisualizerCommandApi"; import { StatusBarItem } from "./StatusBarItem"; import { vscodeApi } from "./vscodeApi"; -import { ScopeSupportTreeProvider } from "./ScopeSupportTreeProvider"; +import { ScopeTreeProvider } from "./ScopeTreeProvider"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -96,7 +96,7 @@ export async function activate( const statusBarItem = StatusBarItem.create("cursorless.showQuickPick"); const keyboardCommands = KeyboardCommands.create(context, statusBarItem); const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider); - ScopeSupportTreeProvider.create( + ScopeTreeProvider.create( vscodeApi, context, scopeProvider, diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts index 2e7da3bb63..6e0f2744c9 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts @@ -2,7 +2,7 @@ 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 "../../ScopeSupportTreeProvider"; +import { DONT_SHOW_TALON_UPDATE_MESSAGE_KEY } from "../../ScopeTreeProvider"; export default class VscodeGlobalState implements State { constructor(private extensionContext: ExtensionContext) { diff --git a/packages/meta-updater/src/getCursorlessVscodeFields.ts b/packages/meta-updater/src/getCursorlessVscodeFields.ts index 64eaa63bc6..424eaf3634 100644 --- a/packages/meta-updater/src/getCursorlessVscodeFields.ts +++ b/packages/meta-updater/src/getCursorlessVscodeFields.ts @@ -23,7 +23,7 @@ export function getCursorlessVscodeFields(input: PackageJson) { // Causes extension to activate when the Cursorless scope support side bar // is opened - "onView:cursorless.scopeSupport", + "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 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/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"; From 92b36e4a2e84331c0cd4c9b48b9535bb423560e9 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 10 Oct 2023 18:33:33 +0100 Subject: [PATCH 25/36] Tweaks --- packages/cursorless-vscode/src/ScopeTreeProvider.ts | 1 + packages/cursorless-vscode/src/constructTestHelpers.ts | 3 +++ packages/cursorless-vscode/src/extension.ts | 2 ++ packages/vscode-common/src/getExtensionApi.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/cursorless-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts index 2a54008c34..545b53b867 100644 --- a/packages/cursorless-vscode/src/ScopeTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts @@ -30,6 +30,7 @@ import { } from "./ScopeVisualizerCommandApi"; export const DONT_SHOW_TALON_UPDATE_MESSAGE_KEY = "dontShowUpdateTalonMessage"; + export class ScopeTreeProvider implements TreeDataProvider { private visibleDisposable: Disposer | undefined; private treeView: TreeView; diff --git a/packages/cursorless-vscode/src/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts index a68539edb0..053d157711 100644 --- a/packages/cursorless-vscode/src/constructTestHelpers.ts +++ b/packages/cursorless-vscode/src/constructTestHelpers.ts @@ -28,6 +28,7 @@ export function constructTestHelpers( hatTokenMap: HatTokenMap, vscodeIDE: VscodeIDE, normalizedIde: NormalizedIDE, + spokenFormsJsonPath: string, injectIde: (ide: IDE) => void, runIntegrationTests: () => Promise, ): TestHelpers | undefined { @@ -61,6 +62,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 e3ac8e4396..3568cee494 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -83,6 +83,7 @@ export async function activate( scopeProvider, snippets, injectIde, + spokenFormsJsonPath, runIntegrationTests, customSpokenFormGenerator, } = createCursorlessEngine( @@ -125,6 +126,7 @@ export async function activate( hatTokenMap, vscodeIDE, normalizedIde as NormalizedIDE, + spokenFormsJsonPath, injectIde, runIntegrationTests, ) diff --git a/packages/vscode-common/src/getExtensionApi.ts b/packages/vscode-common/src/getExtensionApi.ts index 079f85b704..8638aa7a00 100644 --- a/packages/vscode-common/src/getExtensionApi.ts +++ b/packages/vscode-common/src/getExtensionApi.ts @@ -44,6 +44,8 @@ export interface TestHelpers { runIntegrationTests(): Promise; + spokenFormsJsonPath: string; + /** * A thin wrapper around the VSCode API that allows us to mock it for testing. */ From 0408fa4eb98222848209eccafa8244a3406c3eb1 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 09:27:15 +0100 Subject: [PATCH 26/36] Migrate ScopeProvider type to cursorless common --- packages/common/src/index.ts | 2 ++ .../api => common/src/types}/ScopeProvider.ts | 4 ++-- packages/common/src/types/SpokenForm.ts | 14 ++++++++++++++ .../src/api/CursorlessEngineApi.ts | 2 +- .../cursorless-engine/src/cursorlessEngine.ts | 4 ++-- .../src/generateSpokenForm/generateSpokenForm.ts | 16 +--------------- packages/cursorless-engine/src/index.ts | 1 - .../src/scopeProviders/ScopeInfoProvider.ts | 5 +++-- .../src/scopeProviders/ScopeRangeProvider.ts | 5 +++-- .../src/scopeProviders/ScopeRangeWatcher.ts | 8 +++++--- .../src/scopeProviders/ScopeSupportChecker.ts | 2 +- .../src/scopeProviders/ScopeSupportWatcher.ts | 10 ++++++++-- .../scopeProviders/getIterationScopeRanges.ts | 3 +-- .../src/scopeProviders/getScopeRanges.ts | 4 ++-- .../src/scopeProviders/getTargetRanges.ts | 7 +++++-- .../cursorless-vscode/src/ScopeTreeProvider.ts | 7 ++++--- .../src/constructTestHelpers.ts | 3 +++ packages/cursorless-vscode/src/extension.ts | 3 ++- .../VscodeIterationScopeVisualizer.ts | 8 ++++++-- .../VscodeScopeTargetVisualizer.ts | 3 ++- .../VscodeScopeVisualizer.ts | 3 ++- .../createVscodeScopeVisualizer.ts | 3 +-- packages/vscode-common/src/getExtensionApi.ts | 3 +++ 23 files changed, 73 insertions(+), 47 deletions(-) rename packages/{cursorless-engine/src/api => common/src/types}/ScopeProvider.ts (98%) create mode 100644 packages/common/src/types/SpokenForm.ts diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 60c346b54a..caf991772f 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -44,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 98% rename from packages/cursorless-engine/src/api/ScopeProvider.ts rename to packages/common/src/types/ScopeProvider.ts index d655ecc704..cb715a2f45 100644 --- a/packages/cursorless-engine/src/api/ScopeProvider.ts +++ b/packages/common/src/types/ScopeProvider.ts @@ -3,9 +3,9 @@ import { GeneralizedRange, Range, ScopeType, + SpokenForm, TextEditor, -} from "@cursorless/common"; -import { SpokenForm } from "../generateSpokenForm"; +} from ".."; export interface ScopeProvider { /** 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/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index d7c400618a..a25e4dfea7 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -2,7 +2,7 @@ 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; diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 31826edebb..438fdd32b6 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -4,10 +4,10 @@ import { FileSystem, Hats, IDE, + ScopeProvider, } from "@cursorless/common"; import { StoredTargetMap, TestCaseRecorder, TreeSitter } from "."; import { CursorlessEngine } from "./api/CursorlessEngineApi"; -import { ScopeProvider } from "./api/ScopeProvider"; import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; @@ -24,8 +24,8 @@ import { ScopeRangeProvider } from "./scopeProviders/ScopeRangeProvider"; import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher"; import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker"; import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher"; -import { injectIde } from "./singletons/ide.singleton"; import { TalonSpokenFormsJsonReader } from "./scopeProviders/getSpokenFormEntries"; +import { injectIde } from "./singletons/ide.singleton"; export function createCursorlessEngine( treeSitter: TreeSitter, diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts index 33f02b38f4..9d5bea37b8 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts @@ -23,21 +23,7 @@ import { SpokenFormComponent, getGeneratorSpokenForms, } from "./GeneratorSpokenFormMap"; - -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; +import { SpokenForm } from "@cursorless/common"; export class SpokenFormGenerator { private primitiveGenerator: PrimitiveTargetSpokenFormGenerator; 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/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts index f9e9eb798e..d24981ce56 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -2,16 +2,17 @@ import { Disposable, Disposer, ScopeType, + ScopeTypeInfo, + ScopeTypeInfoEventCallback, SurroundingPairScopeType, simpleScopeTypeTypes, surroundingPairNames, } from "@cursorless/common"; import { pull } from "lodash"; -import { ScopeTypeInfo, ScopeTypeInfoEventCallback } from ".."; +import { SpeakableSurroundingPairName } from "../SpokenFormMap"; import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; import { scopeTypeToString } from "./scopeTypeToString"; -import { SpeakableSurroundingPairName } from "../SpokenFormMap"; /** * Maintains a list of all scope types and notifies listeners when it changes. diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts index 62f39039f9..0e1ae1d0b8 100644 --- a/packages/cursorless-engine/src/scopeProviders/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/scopeProviders/ScopeRangeWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts index 6a4a7971b5..92ef7daba3 100644 --- a/packages/cursorless-engine/src/scopeProviders/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"; diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts index d9ee8f1664..894d3b5262 100644 --- a/packages/cursorless-engine/src/scopeProviders/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 index 50bff837da..1723fa1364 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts @@ -1,6 +1,12 @@ -import { Disposable, ScopeType } from "@cursorless/common"; +import { + Disposable, + ScopeSupport, + ScopeSupportEventCallback, + ScopeSupportInfo, + ScopeType, +} from "@cursorless/common"; import { pull } from "lodash"; -import { ScopeSupport, ScopeSupportEventCallback, ScopeSupportInfo } from ".."; + import { Debouncer } from "../core/Debouncer"; import { LanguageDefinitions } from "../languages/LanguageDefinitions"; import { ide } from "../singletons/ide.singleton"; diff --git a/packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts b/packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts index feb42bffe9..a28b6eb8b8 100644 --- a/packages/cursorless-engine/src/scopeProviders/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/scopeProviders/getScopeRanges.ts b/packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts index 56e47dde50..3e36c4f768 100644 --- a/packages/cursorless-engine/src/scopeProviders/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/scopeProviders/getTargetRanges.ts b/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts index 5fd843310c..8be5e52e72 100644 --- a/packages/cursorless-engine/src/scopeProviders/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-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts index 545b53b867..17f1ecb5b0 100644 --- a/packages/cursorless-vscode/src/ScopeTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts @@ -1,11 +1,12 @@ -import { CursorlessCommandId, Disposer } from "@cursorless/common"; import { - CustomSpokenFormGenerator, + CursorlessCommandId, + Disposer, ScopeProvider, ScopeSupport, ScopeSupportLevels, ScopeTypeInfo, -} from "@cursorless/cursorless-engine"; +} 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"; diff --git a/packages/cursorless-vscode/src/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts index 053d157711..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, @@ -29,6 +30,7 @@ export function constructTestHelpers( vscodeIDE: VscodeIDE, normalizedIde: NormalizedIDE, spokenFormsJsonPath: string, + scopeProvider: ScopeProvider, injectIde: (ide: IDE) => void, runIntegrationTests: () => Promise, ): TestHelpers | undefined { @@ -36,6 +38,7 @@ export function constructTestHelpers( commandServerApi: commandServerApi!, ide: normalizedIde, injectIde, + scopeProvider, toVscodeEditor, diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 3568cee494..ab718bf00e 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -6,12 +6,12 @@ import { isTesting, NormalizedIDE, Range, + ScopeProvider, ScopeType, TextDocument, } from "@cursorless/common"; import { createCursorlessEngine, - ScopeProvider, TreeSitter, } from "@cursorless/cursorless-engine"; import { @@ -127,6 +127,7 @@ export async function activate( vscodeIDE, normalizedIde as NormalizedIDE, spokenFormsJsonPath, + scopeProvider, injectIde, runIntegrationTests, ) 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/vscode-common/src/getExtensionApi.ts b/packages/vscode-common/src/getExtensionApi.ts index 8638aa7a00..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; From 4e1900461439f3e594ab65a91316b194a592ac54 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:21:56 +0100 Subject: [PATCH 27/36] Tweaks --- packages/cursorless-vscode/src/ScopeTreeProvider.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cursorless-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts index 17f1ecb5b0..00a4ec4f53 100644 --- a/packages/cursorless-vscode/src/ScopeTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts @@ -151,19 +151,21 @@ export class ScopeTreeProvider implements TreeDataProvider { 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?", - "Don't show again", + HOW_BUTTON_TEXT, + DONT_SHOW_AGAIN_BUTTON_TEXT, ); - if (result === "How?") { + 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 === "Don't show again") { + } else if (result === DONT_SHOW_AGAIN_BUTTON_TEXT) { await this.context.globalState.update( DONT_SHOW_TALON_UPDATE_MESSAGE_KEY, true, From 1e7464006e58dd553fbe26bce7ab9fd180f083c9 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:44:04 +0100 Subject: [PATCH 28/36] Fix spoken forms bug --- cursorless-talon/src/spoken_forms.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index a8ac285534..be71e2ae72 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -23,7 +23,7 @@ def auto_construct_defaults( spoken_forms: dict[str, ListToSpokenForms], - handle_new_values: Callable[[list[SpokenFormEntry]], None], + handle_new_values: Callable[[str, list[SpokenFormEntry]], None], f: Callable[ Concatenate[str, ListToSpokenForms, Callable[[list[SpokenFormEntry]], None], P], R, @@ -47,7 +47,13 @@ def auto_construct_defaults( def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R: default_values = spoken_forms[filename] - return f(filename, default_values, handle_new_values, *args, **kwargs) + return f( + filename, + default_values, + lambda new_values: handle_new_values(filename, new_values), + *args, + **kwargs, + ) return ret @@ -76,7 +82,7 @@ def update(): spoken_forms = json.load(file) initialized = False - custom_spoken_forms: list[SpokenFormEntry] = [] + custom_spoken_forms: dict[str, list[SpokenFormEntry]] = {} spoken_forms_output = SpokenFormsOutput() spoken_forms_output.init() @@ -88,13 +94,14 @@ def update_spoken_forms_output(): "id": entry.id, "spokenForms": entry.spoken_forms, } - for entry in custom_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(values: list[SpokenFormEntry]): - custom_spoken_forms.extend(values) + 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 From f4776fb3311f633b8b790eba4425c662322414cd Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:45:02 +0100 Subject: [PATCH 29/36] Revisualize on custom regex change --- packages/common/src/types/ScopeProvider.ts | 12 +++++ packages/common/src/util/Disposer.ts | 4 ++ .../cursorless-engine/src/cursorlessEngine.ts | 1 + .../src/scopeProviders/ScopeInfoProvider.ts | 1 + .../src/scopeProviders/ScopeSupportWatcher.ts | 2 +- .../src/ScopeVisualizerCommandApi.ts | 2 +- packages/cursorless-vscode/src/extension.ts | 16 +++--- .../src/revisualizeOnCustomRegexChange.ts | 53 +++++++++++++++++++ 8 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts diff --git a/packages/common/src/types/ScopeProvider.ts b/packages/common/src/types/ScopeProvider.ts index cb715a2f45..6183ef2e57 100644 --- a/packages/common/src/types/ScopeProvider.ts +++ b/packages/common/src/types/ScopeProvider.ts @@ -94,6 +94,18 @@ export interface ScopeProvider { * @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 { diff --git a/packages/common/src/util/Disposer.ts b/packages/common/src/util/Disposer.ts index 93cacd833a..01fa8b62bd 100644 --- a/packages/common/src/util/Disposer.ts +++ b/packages/common/src/util/Disposer.ts @@ -9,6 +9,10 @@ import { Disposable } from "../ide/types/ide.types"; export class Disposer implements Disposable { private disposables: Disposable[] = []; + constructor(...disposables: Disposable[]) { + this.push(...disposables) + } + public push(...disposables: Disposable[]) { this.disposables.push(...disposables); } diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 438fdd32b6..7cf7bf48db 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -151,6 +151,7 @@ function createScopeProvider( getScopeSupport: supportChecker.getScopeSupport, getIterationScopeSupport: supportChecker.getIterationScopeSupport, onDidChangeScopeSupport: supportWatcher.onDidChangeScopeSupport, + getScopeInfo: infoProvider.getScopeTypeInfo, onDidChangeScopeInfo: infoProvider.onDidChangeScopeInfo, }; } diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts index d24981ce56..15c7d28515 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -32,6 +32,7 @@ export class ScopeInfoProvider { ); this.onDidChangeScopeInfo = this.onDidChangeScopeInfo.bind(this); + this.getScopeTypeInfo = this.getScopeTypeInfo.bind(this); this.updateScopeTypeInfos(); } diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts index 1723fa1364..c32c7a2c38 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts @@ -39,7 +39,7 @@ export class ScopeSupportWatcher { // dirty-state changes. ide().onDidChangeTextDocument(this.debouncer.run), languageDefinitions.onDidChangeDefinition(this.debouncer.run), - this.scopeInfoProvider.onDidChangeScopeInfo(this.debouncer.run), + this.scopeInfoProvider.onDidChangeScopeInfo(() => this.onChange()), this.debouncer, ); diff --git a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts index 10b6b16d4c..62f1d520ba 100644 --- a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts +++ b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts @@ -1,7 +1,7 @@ import { Disposable, ScopeType } from "@cursorless/common"; export type VisualizerScopeTypeListener = ( - scopeType: ScopeType | undefined, + scopeType: ScopeType | undefined, visualizationType: VisualizationType | undefined, ) => void; export interface ScopeVisualizer { diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index ab718bf00e..c535191832 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -35,14 +35,15 @@ import { import { KeyboardCommands } from "./keyboard/KeyboardCommands"; import { registerCommands } from "./registerCommands"; import { ReleaseNotes } from "./ReleaseNotes"; +import { ScopeTreeProvider } from "./ScopeTreeProvider"; import { ScopeVisualizer, + VisualizerScopeTypeListener as ScopeVisualizerListener, VisualizationType, - VisualizerScopeTypeListener, } from "./ScopeVisualizerCommandApi"; import { StatusBarItem } from "./StatusBarItem"; import { vscodeApi } from "./vscodeApi"; -import { ScopeTreeProvider } from "./ScopeTreeProvider"; +import { revisualizeOnCustomRegexChange } from "./revisualizeOnCustomRegexChange"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -97,6 +98,9 @@ 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, @@ -178,7 +182,7 @@ function createScopeVisualizer( let scopeVisualizer: VscodeScopeVisualizer | undefined; let currentScopeType: ScopeType | undefined; - const listeners: VisualizerScopeTypeListener[] = []; + const listeners: ScopeVisualizerListener[] = []; return { start(scopeType: ScopeType, visualizationType: VisualizationType) { @@ -191,21 +195,21 @@ function createScopeVisualizer( ); scopeVisualizer.start(); currentScopeType = scopeType; - listeners.forEach((listener) => listener(scopeType)); + listeners.forEach((listener) => listener(scopeType, visualizationType)); }, stop() { scopeVisualizer?.dispose(); scopeVisualizer = undefined; currentScopeType = undefined; - listeners.forEach((listener) => listener(undefined)); + listeners.forEach((listener) => listener(undefined, undefined)); }, get scopeType() { return currentScopeType; }, - onDidChangeScopeType(listener: VisualizerScopeTypeListener): Disposable { + onDidChangeScopeType(listener: ScopeVisualizerListener): Disposable { listeners.push(listener); return { diff --git a/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts b/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts new file mode 100644 index 0000000000..45cad8befa --- /dev/null +++ b/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts @@ -0,0 +1,53 @@ +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! + ); + } + } + }) + ); +} From 736c01b3d4463cc9283cf1f25ff66e0cfe0050cb Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:51:20 +0100 Subject: [PATCH 30/36] Remove some debouncing --- .../src/scopeProviders/ScopeRangeWatcher.ts | 13 +++++++------ .../src/scopeProviders/ScopeSupportWatcher.ts | 9 +++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts index 92ef7daba3..dab67eddb8 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts @@ -20,13 +20,18 @@ import { ScopeRangeProvider } from "./ScopeRangeProvider"; */ export class ScopeRangeWatcher { private disposables: Disposable[] = []; - private debouncer = new Debouncer(() => this.onChange()); + private debouncer = new Debouncer(() => this.onChange); private listeners: (() => void)[] = []; constructor( 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), @@ -39,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/scopeProviders/ScopeSupportWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts index c32c7a2c38..b971c64eb4 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts @@ -19,7 +19,7 @@ import { ScopeSupportChecker } from "./ScopeSupportChecker"; */ export class ScopeSupportWatcher { private disposables: Disposable[] = []; - private debouncer = new Debouncer(() => this.onChange()); + private debouncer = new Debouncer(() => this.onChange); private listeners: ScopeSupportEventCallback[] = []; constructor( @@ -27,6 +27,9 @@ export class ScopeSupportWatcher { 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), @@ -39,11 +42,9 @@ export class ScopeSupportWatcher { // dirty-state changes. ide().onDidChangeTextDocument(this.debouncer.run), languageDefinitions.onDidChangeDefinition(this.debouncer.run), - this.scopeInfoProvider.onDidChangeScopeInfo(() => this.onChange()), + this.scopeInfoProvider.onDidChangeScopeInfo(this.onChange), this.debouncer, ); - - this.onDidChangeScopeSupport = this.onDidChangeScopeSupport.bind(this); } /** From 2b340cf7775130ee515b6f1b0e8fae11dc3953c0 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 17:48:18 +0100 Subject: [PATCH 31/36] Initial working tests --- .../src/ide/normalized/NormalizedIDE.ts | 1 + .../src/scopeProviders/ScopeRangeWatcher.ts | 2 +- .../src/scopeProviders/ScopeSupportWatcher.ts | 2 +- .../assertCalledWithScopeInfo.ts | 19 ++++++ .../scopeProvider/runBasicScopeInfoTest.ts | 67 +++++++++++++++++++ .../scopeProvider.vscode.test.ts | 12 ++++ 6 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts create mode 100644 packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts create mode 100644 packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts 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/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts index dab67eddb8..fdc5790f32 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts @@ -20,7 +20,7 @@ import { ScopeRangeProvider } from "./ScopeRangeProvider"; */ export class ScopeRangeWatcher { private disposables: Disposable[] = []; - private debouncer = new Debouncer(() => this.onChange); + private debouncer = new Debouncer(() => this.onChange()); private listeners: (() => void)[] = []; constructor( diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts index b971c64eb4..3c6f435495 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts @@ -19,7 +19,7 @@ import { ScopeSupportChecker } from "./ScopeSupportChecker"; */ export class ScopeSupportWatcher { private disposables: Disposable[] = []; - private debouncer = new Debouncer(() => this.onChange); + private debouncer = new Debouncer(() => this.onChange()); private listeners: ScopeSupportEventCallback[] = []; constructor( 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..88ada2bd53 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts @@ -0,0 +1,19 @@ +import { + ScopeSupportInfo, + ScopeSupportLevels +} from "@cursorless/common"; +import Sinon = require("sinon"); +import { assert } from "chai"; + +export function assertCalledWithScopeInfo( + fake: Sinon.SinonSpy<[scopeInfos: ScopeSupportLevels], void>, + expectedScopeInfo: ScopeSupportInfo +) { + Sinon.assert.called(fake); + const actualScopeInfo = fake.lastCall.args[0].find( + (scopeInfo) => scopeInfo.scopeType.type === expectedScopeInfo.scopeType.type + ); + assert.isDefined(actualScopeInfo); + assert.deepEqual(actualScopeInfo, expectedScopeInfo); + 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..276025f88a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts @@ -0,0 +1,67 @@ +import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common"; +import { + ScopeSupport, + ScopeSupportInfo, + ScopeSupportLevels, +} from "@cursorless/common"; +import Sinon = require("sinon"); +import { sleepWithBackoff } from "../../endToEndTestSetup"; +import { 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 { + assertCalledWithScopeInfo(fake, unsupported); + + await openNewEditor(contents, { + languageId: "typescript", + }); + await sleepWithBackoff(25); + + assertCalledWithScopeInfo(fake, present); + + await commands.executeCommand("workbench.action.closeAllEditors"); + await sleepWithBackoff(25); + + assertCalledWithScopeInfo(fake, unsupported); + } finally { + disposable.dispose(); + } +} + +const contents = ` +function helloWorld() { + +} +`; + +function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo { + return { + humanReadableName: "named function", + isLanguageSpecific: true, + iterationScopeSupport: scopeSupport, + scopeType: { + type: "namedFunction", + }, + spokenForm: { + alternatives: [], + preferred: "funk", + type: "success", + }, + support: scopeSupport, + }; +} + +const unsupported = getExpectedScope(ScopeSupport.unsupported); +const present = getExpectedScope(ScopeSupport.supportedAndPresentInEditor); 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..2ba9e98136 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts @@ -0,0 +1,12 @@ +import { endToEndTestSetup } from "../../endToEndTestSetup"; +import { asyncSafety } from "@cursorless/common"; +import { runBasicScopeInfoTest } from "./runBasicScopeInfoTest"; + +suite("scope provider", async function () { + endToEndTestSetup(this); + + test( + "basic", + asyncSafety(() => runBasicScopeInfoTest()), + ); +}); From d232c30fce417319cbf34d02c4d2cab06f82cf0c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 11 Oct 2023 16:49:54 +0000 Subject: [PATCH 32/36] [pre-commit.ci lite] apply automatic fixes --- packages/common/src/types/ScopeProvider.ts | 4 +-- packages/common/src/util/Disposer.ts | 2 +- .../assertCalledWithScopeInfo.ts | 10 +++---- .../src/ScopeVisualizerCommandApi.ts | 3 +- .../src/revisualizeOnCustomRegexChange.ts | 29 ++++++++++++------- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/common/src/types/ScopeProvider.ts b/packages/common/src/types/ScopeProvider.ts index 6183ef2e57..71ebb99613 100644 --- a/packages/common/src/types/ScopeProvider.ts +++ b/packages/common/src/types/ScopeProvider.ts @@ -103,9 +103,7 @@ export interface ScopeProvider { * @returns The level of support for the iteration scope of {@link scopeType} * in {@link editor} */ - getScopeInfo: ( - scopeType: ScopeType, - ) => ScopeTypeInfo; + getScopeInfo: (scopeType: ScopeType) => ScopeTypeInfo; } interface ScopeRangeConfigBase { diff --git a/packages/common/src/util/Disposer.ts b/packages/common/src/util/Disposer.ts index 01fa8b62bd..cf300b746d 100644 --- a/packages/common/src/util/Disposer.ts +++ b/packages/common/src/util/Disposer.ts @@ -10,7 +10,7 @@ export class Disposer implements Disposable { private disposables: Disposable[] = []; constructor(...disposables: Disposable[]) { - this.push(...disposables) + this.push(...disposables); } public push(...disposables: Disposable[]) { diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts index 88ada2bd53..bcd3cf36f7 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts @@ -1,17 +1,15 @@ -import { - ScopeSupportInfo, - ScopeSupportLevels -} from "@cursorless/common"; +import { ScopeSupportInfo, ScopeSupportLevels } from "@cursorless/common"; import Sinon = require("sinon"); import { assert } from "chai"; export function assertCalledWithScopeInfo( fake: Sinon.SinonSpy<[scopeInfos: ScopeSupportLevels], void>, - expectedScopeInfo: ScopeSupportInfo + expectedScopeInfo: ScopeSupportInfo, ) { Sinon.assert.called(fake); const actualScopeInfo = fake.lastCall.args[0].find( - (scopeInfo) => scopeInfo.scopeType.type === expectedScopeInfo.scopeType.type + (scopeInfo) => + scopeInfo.scopeType.type === expectedScopeInfo.scopeType.type, ); assert.isDefined(actualScopeInfo); assert.deepEqual(actualScopeInfo, expectedScopeInfo); diff --git a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts index 62f1d520ba..2bdafaaf9f 100644 --- a/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts +++ b/packages/cursorless-vscode/src/ScopeVisualizerCommandApi.ts @@ -1,7 +1,8 @@ import { Disposable, ScopeType } from "@cursorless/common"; export type VisualizerScopeTypeListener = ( - scopeType: ScopeType | undefined, visualizationType: VisualizationType | undefined, + scopeType: ScopeType | undefined, + visualizationType: VisualizationType | undefined, ) => void; export interface ScopeVisualizer { diff --git a/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts b/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts index 45cad8befa..cfc913bc64 100644 --- a/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts +++ b/packages/cursorless-vscode/src/revisualizeOnCustomRegexChange.ts @@ -1,8 +1,13 @@ import { Disposable, - Disposer, ScopeProvider, ScopeTypeInfo + Disposer, + ScopeProvider, + ScopeTypeInfo, } from "@cursorless/common"; -import { ScopeVisualizer, VisualizationType } from "./ScopeVisualizerCommandApi"; +import { + ScopeVisualizer, + VisualizationType, +} from "./ScopeVisualizerCommandApi"; import { isEqual } from "lodash"; /** @@ -19,7 +24,7 @@ import { isEqual } from "lodash"; */ export function revisualizeOnCustomRegexChange( scopeVisualizer: ScopeVisualizer, - scopeProvider: ScopeProvider + scopeProvider: ScopeProvider, ): Disposable { let currentRegexScopeInfo: ScopeTypeInfo | undefined; let currentVisualizationType: VisualizationType | undefined; @@ -34,20 +39,24 @@ export function revisualizeOnCustomRegexChange( }), scopeProvider.onDidChangeScopeInfo((scopeInfos) => { - if (currentRegexScopeInfo != null && - !scopeInfos.some((scopeInfo) => isEqual(scopeInfo.scopeType, currentRegexScopeInfo!.scopeType) - )) { + 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) + (scopeInfo) => + scopeInfo.scopeType.type === "customRegex" && + isEqual(scopeInfo.spokenForm, currentRegexScopeInfo!.spokenForm), ); if (replacement != null) { scopeVisualizer.start( replacement.scopeType, - currentVisualizationType! + currentVisualizationType!, ); } } - }) + }), ); } From f4be0370876e3ec119e3a32ab44bcdaefe1e8d8b Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 20:31:07 +0100 Subject: [PATCH 33/36] Working custom regex test --- .../src/CustomSpokenForms.ts | 37 +++---- .../CustomSpokenFormGeneratorImpl.ts | 6 +- .../assertCalledWithScopeInfo.ts | 29 ++++-- .../scopeProvider/runBasicScopeInfoTest.ts | 34 +++++-- .../runCustomRegexScopeInfoTest.ts | 98 +++++++++++++++++++ .../scopeProvider.vscode.test.ts | 7 +- 6 files changed, 173 insertions(+), 38 deletions(-) create mode 100644 packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts diff --git a/packages/cursorless-engine/src/CustomSpokenForms.ts b/packages/cursorless-engine/src/CustomSpokenForms.ts index 4b2a28d077..00ca71953f 100644 --- a/packages/cursorless-engine/src/CustomSpokenForms.ts +++ b/packages/cursorless-engine/src/CustomSpokenForms.ts @@ -28,25 +28,24 @@ const ENTRY_TYPES = [ "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 implements SpokenFormMap { +export class CustomSpokenForms { private disposer = new Disposer(); private notifier = new Notifier(); - // Initialize to defaults - simpleScopeTypeType = defaultSpokenFormMap.simpleScopeTypeType; - pairedDelimiter = defaultSpokenFormMap.pairedDelimiter; - customRegex = defaultSpokenFormMap.customRegex; + private spokenFormMap_: Writable = { ...defaultSpokenFormMap }; - // FIXME: Get these from Talon - surroundingPairForceDirection = - defaultSpokenFormMap.surroundingPairForceDirection; - simpleModifier = defaultSpokenFormMap.simpleModifier; - modifierExtra = defaultSpokenFormMap.modifierExtra; + get spokenFormMap(): SpokenFormMap { + return this.spokenFormMap_; + } - private isInitialized_ = false; + private customSpokenFormsInitialized_ = false; private needsInitialTalonUpdate_: boolean | undefined; /** @@ -62,8 +61,8 @@ export class CustomSpokenForms implements SpokenFormMap { * default spoken forms are currently being used while the custom spoken forms * are being loaded. */ - get isInitialized() { - return this.isInitialized_; + get customSpokenFormsInitialized() { + return this.customSpokenFormsInitialized_; } constructor(private talonSpokenForms: TalonSpokenForms) { @@ -88,9 +87,7 @@ export class CustomSpokenForms implements SpokenFormMap { } catch (err) { if (err instanceof NeedsInitialTalonUpdateError) { // Handle case where spokenForms.json doesn't exist yet - console.log(err.message); this.needsInitialTalonUpdate_ = true; - this.notifier.notifyListeners(); } else { console.error("Error loading custom spoken forms", err); showError( @@ -102,6 +99,10 @@ export class CustomSpokenForms implements SpokenFormMap { ); } + this.spokenFormMap_ = { ...defaultSpokenFormMap }; + this.customSpokenFormsInitialized_ = false; + this.notifier.notifyListeners(); + return; } @@ -118,7 +119,7 @@ export class CustomSpokenForms implements SpokenFormMap { const ids = Array.from( new Set([...Object.keys(defaultEntry), ...Object.keys(entry)]), ); - this[entryType] = Object.fromEntries( + this.spokenFormMap_[entryType] = Object.fromEntries( ids.map((id): [SpokenFormType, SpokenFormMapEntry] => { const { defaultSpokenForms = [], isSecret = false } = defaultEntry[id] ?? {}; @@ -151,12 +152,12 @@ export class CustomSpokenForms implements SpokenFormMap { ) as any; } - this.isInitialized_ = true; + this.customSpokenFormsInitialized_ = true; this.notifier.notifyListeners(); } getCustomRegexScopeTypes(): CustomRegexScopeType[] { - return Object.keys(this.customRegex).map((regex) => ({ + return Object.keys(this.spokenFormMap_.customRegex).map((regex) => ({ type: "customRegex", regex, })); diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts index 0d1d770912..5aa0aeb339 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -18,11 +18,13 @@ export class CustomSpokenFormGeneratorImpl constructor(talonSpokenForms: TalonSpokenForms) { this.customSpokenForms = new CustomSpokenForms(talonSpokenForms); - this.spokenFormGenerator = new SpokenFormGenerator(this.customSpokenForms); + this.spokenFormGenerator = new SpokenFormGenerator( + this.customSpokenForms.spokenFormMap, + ); this.disposer.push( this.customSpokenForms.onDidChangeCustomSpokenForms(() => { this.spokenFormGenerator = new SpokenFormGenerator( - this.customSpokenForms, + this.customSpokenForms.spokenFormMap, ); }), ); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts index bcd3cf36f7..066a71eb44 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts @@ -1,17 +1,32 @@ -import { ScopeSupportInfo, ScopeSupportLevels } from "@cursorless/common"; +import { ScopeType, ScopeTypeInfo } from "@cursorless/common"; import Sinon = require("sinon"); import { assert } from "chai"; +import { sleepWithBackoff } from "../../endToEndTestSetup"; +import { isEqual } from "lodash"; -export function assertCalledWithScopeInfo( - fake: Sinon.SinonSpy<[scopeInfos: ScopeSupportLevels], void>, - expectedScopeInfo: ScopeSupportInfo, +export async function assertCalledWithScopeInfo( + fake: Sinon.SinonSpy<[scopeInfos: T[]], void>, + expectedScopeInfo: T, ) { + await sleepWithBackoff(25); Sinon.assert.called(fake); - const actualScopeInfo = fake.lastCall.args[0].find( - (scopeInfo) => - scopeInfo.scopeType.type === expectedScopeInfo.scopeType.type, + 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>, + scopeType: ScopeType, +) { + await sleepWithBackoff(25); + Sinon.assert.called(fake); + const actualScopeInfo = fake.lastCall.args[0].find((scopeInfo) => + isEqual(scopeInfo.scopeType, scopeType), + ); + assert.isUndefined(actualScopeInfo); + fake.resetHistory(); +} diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts index 276025f88a..c3a3dd93ea 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts @@ -5,8 +5,7 @@ import { ScopeSupportLevels, } from "@cursorless/common"; import Sinon = require("sinon"); -import { sleepWithBackoff } from "../../endToEndTestSetup"; -import { commands } from "vscode"; +import { Position, Range, TextDocument, commands } from "vscode"; import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo"; /** @@ -22,24 +21,35 @@ export async function runBasicScopeInfoTest() { const disposable = scopeProvider.onDidChangeScopeSupport(fake); try { - assertCalledWithScopeInfo(fake, unsupported); + await assertCalledWithScopeInfo(fake, unsupported); - await openNewEditor(contents, { + const editor = await openNewEditor("", { languageId: "typescript", }); - await sleepWithBackoff(25); + await assertCalledWithScopeInfo(fake, supported); - assertCalledWithScopeInfo(fake, present); + await editor.edit((editBuilder) => { + editBuilder.insert(new Position(0, 0), contents); + }); + await assertCalledWithScopeInfo(fake, present); - await commands.executeCommand("workbench.action.closeAllEditors"); - await sleepWithBackoff(25); + await editor.edit((editBuilder) => { + editBuilder.delete(getDocumentRange(editor.document)); + }); + await assertCalledWithScopeInfo(fake, supported); - assertCalledWithScopeInfo(fake, unsupported); + 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() { @@ -50,7 +60,10 @@ function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo { return { humanReadableName: "named function", isLanguageSpecific: true, - iterationScopeSupport: scopeSupport, + iterationScopeSupport: + scopeSupport === ScopeSupport.unsupported + ? ScopeSupport.unsupported + : ScopeSupport.supportedAndPresentInEditor, scopeType: { type: "namedFunction", }, @@ -64,4 +77,5 @@ function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo { } 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..8e83611863 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts @@ -0,0 +1,98 @@ +import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common"; +import { + LATEST_VERSION, + ScopeSupport, + ScopeSupportInfo, + ScopeSupportLevels, + ScopeType, +} from "@cursorless/common"; +import Sinon = require("sinon"); +import { + assertCalledWithScopeInfo, + assertCalledWithoutScopeInfo as assertCalledWithoutScope, +} from "./assertCalledWithScopeInfo"; +import { stat, unlink, writeFile } from "fs/promises"; +import { sleepWithBackoff } from "../../endToEndTestSetup"; + +/** + * 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>(); + + const disposable = scopeProvider.onDidChangeScopeSupport(fake); + + try { + await assertCalledWithoutScope(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 assertCalledWithoutScope(fake, scopeType); + } finally { + disposable.dispose(); + + // Delete spokenFormsJsonPath if it exists + try { + await stat(spokenFormsJsonPath); + await unlink(spokenFormsJsonPath); + } 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/scopeProvider.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts index 2ba9e98136..7c729e34e2 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts @@ -1,6 +1,7 @@ -import { endToEndTestSetup } from "../../endToEndTestSetup"; import { asyncSafety } from "@cursorless/common"; +import { endToEndTestSetup } from "../../endToEndTestSetup"; import { runBasicScopeInfoTest } from "./runBasicScopeInfoTest"; +import { runCustomRegexScopeInfoTest } from "./runCustomRegexScopeInfoTest"; suite("scope provider", async function () { endToEndTestSetup(this); @@ -9,4 +10,8 @@ suite("scope provider", async function () { "basic", asyncSafety(() => runBasicScopeInfoTest()), ); + test( + "custom regex", + asyncSafety(() => runCustomRegexScopeInfoTest()), + ); }); From 00a10aef07b266fd4f8bba2cacb385d0bef899d6 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 20:34:05 +0100 Subject: [PATCH 34/36] Tweak --- .../src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts index 8e83611863..59f18c8fe9 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts @@ -5,6 +5,7 @@ import { ScopeSupportInfo, ScopeSupportLevels, ScopeType, + sleep, } from "@cursorless/common"; import Sinon = require("sinon"); import { @@ -48,6 +49,9 @@ export async function runCustomRegexScopeInfoTest() { 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(50); } catch (e) { // Do nothing } From f1daa5594b4712aff2604d5dd8baaa0900127757 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Wed, 11 Oct 2023 21:42:02 +0100 Subject: [PATCH 35/36] Working custom spoken form tests --- .../cursorless-engine/src/cursorlessEngine.ts | 2 +- ...tries.ts => TalonSpokenFormsJsonReader.ts} | 0 .../assertCalledWithScopeInfo.ts | 76 ++++-- .../scopeProvider/runBasicScopeInfoTest.ts | 4 +- .../runCustomRegexScopeInfoTest.ts | 11 +- .../runCustomSpokenFormScopeInfoTest.ts | 244 ++++++++++++++++++ .../runSurroundingPairScopeInfoTest.ts | 55 ++++ .../scopeProvider.vscode.test.ts | 10 + 8 files changed, 375 insertions(+), 27 deletions(-) rename packages/cursorless-engine/src/scopeProviders/{getSpokenFormEntries.ts => TalonSpokenFormsJsonReader.ts} (100%) create mode 100644 packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts create mode 100644 packages/cursorless-vscode-e2e/src/suite/scopeProvider/runSurroundingPairScopeInfoTest.ts diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 7cf7bf48db..2b5a86b294 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -24,7 +24,7 @@ import { ScopeRangeProvider } from "./scopeProviders/ScopeRangeProvider"; import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher"; import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker"; import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher"; -import { TalonSpokenFormsJsonReader } from "./scopeProviders/getSpokenFormEntries"; +import { TalonSpokenFormsJsonReader } from "./scopeProviders/TalonSpokenFormsJsonReader"; import { injectIde } from "./singletons/ide.singleton"; export function createCursorlessEngine( diff --git a/packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts b/packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts similarity index 100% rename from packages/cursorless-engine/src/scopeProviders/getSpokenFormEntries.ts rename to packages/cursorless-engine/src/scopeProviders/TalonSpokenFormsJsonReader.ts diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts index 066a71eb44..b85850a282 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts @@ -1,32 +1,68 @@ import { ScopeType, ScopeTypeInfo } from "@cursorless/common"; -import Sinon = require("sinon"); +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>, - expectedScopeInfo: T, +async function sleepAndCheck( + fake: sinon.SinonSpy<[scopeInfos: T[]], void>, + check: () => void, ) { await sleepWithBackoff(25); - Sinon.assert.called(fake); - const actualScopeInfo = fake.lastCall.args[0].find((scopeInfo) => - isEqual(scopeInfo.scopeType, expectedScopeInfo.scopeType), - ); - assert.isDefined(actualScopeInfo); - assert.deepEqual(actualScopeInfo, expectedScopeInfo); + sinon.assert.called(fake); + + check(); + fake.resetHistory(); } -export async function assertCalledWithoutScopeInfo( - fake: Sinon.SinonSpy<[scopeInfos: T[]], void>, - scopeType: ScopeType, +export function assertCalled( + fake: sinon.SinonSpy<[scopeInfos: T[]], void>, + expectedScopeInfos: T[], + expectedNotToHaveScopeTypes: ScopeType[], ) { - await sleepWithBackoff(25); - Sinon.assert.called(fake); - const actualScopeInfo = fake.lastCall.args[0].find((scopeInfo) => - isEqual(scopeInfo.scopeType, scopeType), - ); - assert.isUndefined(actualScopeInfo); - fake.resetHistory(); + return sleepAndCheck(fake, () => { + assertCalledWith(expectedScopeInfos, fake); + assertCalledWithout(expectedNotToHaveScopeTypes, fake); + }); +} + +export function assertCalledWithScopeInfo( + fake: sinon.SinonSpy<[scopeInfos: T[]], void>, + ...expectedScopeInfos: T[] +) { + return sleepAndCheck(fake, () => assertCalledWith(expectedScopeInfos, fake)); +} + +export async function assertCalledWithoutScopeType( + fake: sinon.SinonSpy<[scopeInfos: T[]], void>, + ...scopeTypes: ScopeType[] +) { + return sleepAndCheck(fake, () => assertCalledWithout(scopeTypes, fake)); +} + +function assertCalledWith( + expectedScopeInfos: T[], + fake: sinon.SinonSpy<[scopeInfos: T[]], void>, +) { + 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); + } +} + +function assertCalledWithout( + scopeTypes: ScopeType[], + fake: sinon.SinonSpy<[scopeInfos: T[]], void>, +) { + for (const scopeType of scopeTypes) { + assert.isUndefined( + fake.lastCall.args[0].find((scopeInfo) => + isEqual(scopeInfo.scopeType, scopeType), + ), + ); + } } diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts index c3a3dd93ea..71d624db52 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts @@ -4,7 +4,7 @@ import { ScopeSupportInfo, ScopeSupportLevels, } from "@cursorless/common"; -import Sinon = require("sinon"); +import * as sinon from "sinon"; import { Position, Range, TextDocument, commands } from "vscode"; import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo"; @@ -14,7 +14,7 @@ import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo"; */ export async function runBasicScopeInfoTest() { const { scopeProvider } = (await getCursorlessApi()).testHelpers!; - const fake = Sinon.fake<[scopeInfos: ScopeSupportLevels], void>(); + const fake = sinon.fake<[scopeInfos: ScopeSupportLevels], void>(); await commands.executeCommand("workbench.action.closeAllEditors"); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts index 59f18c8fe9..740ca1cc28 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts @@ -7,13 +7,14 @@ import { ScopeType, sleep, } from "@cursorless/common"; -import Sinon = require("sinon"); +import * as sinon from "sinon"; import { assertCalledWithScopeInfo, - assertCalledWithoutScopeInfo as assertCalledWithoutScope, + assertCalledWithoutScopeType as assertCalledWithoutScope, } 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 @@ -22,7 +23,9 @@ import { sleepWithBackoff } from "../../endToEndTestSetup"; export async function runCustomRegexScopeInfoTest() { const { scopeProvider, spokenFormsJsonPath } = (await getCursorlessApi()) .testHelpers!; - const fake = Sinon.fake<[scopeInfos: ScopeSupportLevels], void>(); + const fake = sinon.fake<[scopeInfos: ScopeSupportLevels], void>(); + + await commands.executeCommand("workbench.action.closeAllEditors"); const disposable = scopeProvider.onDidChangeScopeSupport(fake); @@ -51,7 +54,7 @@ export async function runCustomRegexScopeInfoTest() { await unlink(spokenFormsJsonPath); // Sleep to ensure that the scope support provider has time to update // before the next test starts - await sleep(50); + await sleep(250); } catch (e) { // Do nothing } 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..208d765733 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts @@ -0,0 +1,244 @@ +import { getCursorlessApi } from "@cursorless/vscode-common"; +import { LATEST_VERSION, ScopeTypeInfo, sleep } from "@cursorless/common"; +import * as sinon from "sinon"; +import { + assertCalled, + 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 assertCalled( + 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 assertCalled( + 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 index 7c729e34e2..dd0a5ed94e 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts @@ -2,6 +2,8 @@ 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); @@ -10,6 +12,14 @@ suite("scope provider", async function () { "basic", asyncSafety(() => runBasicScopeInfoTest()), ); + test( + "surrounding pair", + asyncSafety(() => runSurroundingPairScopeInfoTest()), + ); + test( + "custom spoken form", + asyncSafety(() => runCustomSpokenFormScopeInfoTest()), + ); test( "custom regex", asyncSafety(() => runCustomRegexScopeInfoTest()), From 357e57da2d64b87c51a56314adcc072c85c8fa91 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Thu, 12 Oct 2023 15:46:31 +0100 Subject: [PATCH 36/36] Cleanup --- .../assertCalledWithScopeInfo.ts | 49 +++++-------------- .../runCustomRegexScopeInfoTest.ts | 6 +-- .../runCustomSpokenFormScopeInfoTest.ts | 39 ++++++--------- 3 files changed, 29 insertions(+), 65 deletions(-) diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts index b85850a282..0697298880 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts @@ -4,47 +4,13 @@ import { assert } from "chai"; import { sleepWithBackoff } from "../../endToEndTestSetup"; import { isEqual } from "lodash"; -async function sleepAndCheck( +export async function assertCalledWithScopeInfo( fake: sinon.SinonSpy<[scopeInfos: T[]], void>, - check: () => void, + ...expectedScopeInfos: T[] ) { await sleepWithBackoff(25); sinon.assert.called(fake); - check(); - - fake.resetHistory(); -} - -export function assertCalled( - fake: sinon.SinonSpy<[scopeInfos: T[]], void>, - expectedScopeInfos: T[], - expectedNotToHaveScopeTypes: ScopeType[], -) { - return sleepAndCheck(fake, () => { - assertCalledWith(expectedScopeInfos, fake); - assertCalledWithout(expectedNotToHaveScopeTypes, fake); - }); -} - -export function assertCalledWithScopeInfo( - fake: sinon.SinonSpy<[scopeInfos: T[]], void>, - ...expectedScopeInfos: T[] -) { - return sleepAndCheck(fake, () => assertCalledWith(expectedScopeInfos, fake)); -} - -export async function assertCalledWithoutScopeType( - fake: sinon.SinonSpy<[scopeInfos: T[]], void>, - ...scopeTypes: ScopeType[] -) { - return sleepAndCheck(fake, () => assertCalledWithout(scopeTypes, fake)); -} - -function assertCalledWith( - expectedScopeInfos: T[], - fake: sinon.SinonSpy<[scopeInfos: T[]], void>, -) { for (const expectedScopeInfo of expectedScopeInfos) { const actualScopeInfo = fake.lastCall.args[0].find((scopeInfo) => isEqual(scopeInfo.scopeType, expectedScopeInfo.scopeType), @@ -52,12 +18,17 @@ function assertCalledWith( assert.isDefined(actualScopeInfo); assert.deepEqual(actualScopeInfo, expectedScopeInfo); } + + fake.resetHistory(); } -function assertCalledWithout( - scopeTypes: ScopeType[], +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) => @@ -65,4 +36,6 @@ function assertCalledWithout( ), ); } + + fake.resetHistory(); } diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts index 740ca1cc28..e5db8c6038 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts @@ -10,7 +10,7 @@ import { import * as sinon from "sinon"; import { assertCalledWithScopeInfo, - assertCalledWithoutScopeType as assertCalledWithoutScope, + assertCalledWithoutScopeInfo, } from "./assertCalledWithScopeInfo"; import { stat, unlink, writeFile } from "fs/promises"; import { sleepWithBackoff } from "../../endToEndTestSetup"; @@ -30,7 +30,7 @@ export async function runCustomRegexScopeInfoTest() { const disposable = scopeProvider.onDidChangeScopeSupport(fake); try { - await assertCalledWithoutScope(fake, scopeType); + await assertCalledWithoutScopeInfo(fake, scopeType); await writeFile( spokenFormsJsonPath, @@ -44,7 +44,7 @@ export async function runCustomRegexScopeInfoTest() { await unlink(spokenFormsJsonPath); await sleepWithBackoff(50); - await assertCalledWithoutScope(fake, scopeType); + await assertCalledWithoutScopeInfo(fake, scopeType); } finally { disposable.dispose(); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts index 208d765733..0afbe0ad8a 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts @@ -1,10 +1,7 @@ import { getCursorlessApi } from "@cursorless/vscode-common"; import { LATEST_VERSION, ScopeTypeInfo, sleep } from "@cursorless/common"; import * as sinon from "sinon"; -import { - assertCalled, - assertCalledWithScopeInfo, -} from "./assertCalledWithScopeInfo"; +import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo"; import { stat, unlink, writeFile } from "fs/promises"; import { sleepWithBackoff } from "../../endToEndTestSetup"; @@ -19,17 +16,14 @@ export async function runCustomSpokenFormScopeInfoTest() { const disposable = scopeProvider.onDidChangeScopeInfo(fake); try { - await assertCalled( + await assertCalledWithScopeInfo( fake, - [ - roundStandard, - namedFunctionStandard, - lambdaStandard, - statementStandard, - squareStandard, - subjectStandard, - ], - [], + roundStandard, + namedFunctionStandard, + lambdaStandard, + statementStandard, + squareStandard, + subjectStandard, ); await writeFile( @@ -49,17 +43,14 @@ export async function runCustomSpokenFormScopeInfoTest() { await unlink(spokenFormsJsonPath); await sleepWithBackoff(50); - await assertCalled( + await assertCalledWithScopeInfo( fake, - [ - roundStandard, - namedFunctionStandard, - lambdaStandard, - statementStandard, - squareStandard, - subjectStandard, - ], - [], + roundStandard, + namedFunctionStandard, + lambdaStandard, + statementStandard, + squareStandard, + subjectStandard, ); } finally { disposable.dispose();