diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4f61638126..f5292a2091 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -11,6 +11,7 @@ export { getKey, splitKey } from "./util/splitKey"; export { hrtimeBigintToSeconds } from "./util/timeUtils"; export * from "./util/walkSync"; export * from "./util/walkAsync"; +export * from "./util/disposableFrom"; export * from "./util/camelCaseToAllDown"; export { Notifier } from "./util/Notifier"; export type { Listener } from "./util/Notifier"; @@ -43,6 +44,7 @@ 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"; diff --git a/packages/cursorless-engine/src/api/ScopeProvider.ts b/packages/common/src/types/ScopeProvider.ts similarity index 69% rename from packages/cursorless-engine/src/api/ScopeProvider.ts rename to packages/common/src/types/ScopeProvider.ts index 40d4bd6916..4855995fd6 100644 --- a/packages/cursorless-engine/src/api/ScopeProvider.ts +++ b/packages/common/src/types/ScopeProvider.ts @@ -3,8 +3,9 @@ import { GeneralizedRange, Range, ScopeType, + SpokenForm, TextEditor, -} from "@cursorless/common"; +} from ".."; export interface ScopeProvider { /** @@ -17,6 +18,7 @@ export interface ScopeProvider { editor: TextEditor, config: ScopeRangeConfig, ) => ScopeRanges[]; + /** * Get the iteration scope ranges for the given editor. * @param editor The editor @@ -75,6 +77,41 @@ 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. + * + * Note that this watcher could be expensive, because it runs all the scope + * handlers for the active editor every time the content of the active editor + * changes. If you only need info about the available scopes, including their + * spoken forms, you should use {@link onDidChangeScopeInfo} instead. + * @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 info changes. The callback + * will be run immediately once with the current scope info. + * + * Includes information about the available scopes, including their custom + * spoken forms, if available. Note that even custom regex scopes will be + * available, as reported to the engine by Talon. + * @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; + + /** + * Get info about {@link scopeType}, including its custom spoken form, if + * available. + * @param editor The editor to check + * @param scopeType The scope type to check + * @returns Info about {@link scopeType} + */ + getScopeInfo: (scopeType: ScopeType) => ScopeTypeInfo; } interface ScopeRangeConfigBase { @@ -108,6 +145,24 @@ export type IterationScopeChangeEventCallback = ( scopeRanges: IterationScopeRanges[], ) => void; +export interface ScopeSupportInfo extends ScopeTypeInfo { + support: ScopeSupport; + iterationScopeSupport: ScopeSupport; +} + +export type ScopeSupportEventCallback = ( + scopeSupportInfos: ScopeSupportInfo[], +) => void; + +export interface ScopeTypeInfo { + scopeType: ScopeType; + spokenForm: SpokenForm; + humanReadableName: string; + isLanguageSpecific: boolean; +} + +export type ScopeTypeInfoEventCallback = (scopeInfos: ScopeTypeInfo[]) => void; + /** * Contains the ranges that define a given scope, eg its {@link domain} and the * ranges for its {@link targets}. diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index ccc45f19ed..270c7b7f6e 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -75,89 +75,108 @@ 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" - | "private.fieldAccess" - | "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", + "private.fieldAccess", + "functionCall", + "functionCallee", + "functionName", + "ifStatement", + "instance", + "list", + "map", + "name", + "namedFunction", + "regularExpression", + "statement", + "string", + "type", + "value", + "condition", + "section", + "sectionLevelOne", + "sectionLevelTwo", + "sectionLevelThree", + "sectionLevelFour", + "sectionLevelFive", + "sectionLevelSix", + "selector", + "switchStatementSubject", + "unit", + "xmlBothTags", + "xmlElement", + "xmlEndTag", + "xmlStartTag", // Latex scope types - | "part" - | "chapter" - | "subSection" - | "subSubSection" - | "namedParagraph" - | "subParagraph" - | "environment" + "part", + "chapter", + "subSection", + "subSubSection", + "namedParagraph", + "subParagraph", + "environment", // Text based scopes - | "character" - | "word" - | "token" - | "identifier" - | "line" - | "sentence" - | "paragraph" - | "document" - | "nonWhitespaceSequence" - | "boundedNonWhitespaceSequence" - | "url" + "character", + "word", + "token", + "identifier", + "line", + "sentence", + "paragraph", + "document", + "nonWhitespaceSequence", + "boundedNonWhitespaceSequence", + "url", + "notebookCell", // Talon - | "command"; + "command", +] as const; + +export function isSimpleScopeType( + scopeType: ScopeType, +): scopeType is SimpleScopeType { + return (simpleScopeTypeTypes as readonly string[]).includes(scopeType.type); +} + +export type SimpleScopeTypeType = (typeof simpleScopeTypeTypes)[number]; export interface SimpleScopeType { type: SimpleScopeTypeType; diff --git a/packages/common/src/util/disposableFrom.ts b/packages/common/src/util/disposableFrom.ts new file mode 100644 index 0000000000..a07da6bf41 --- /dev/null +++ b/packages/common/src/util/disposableFrom.ts @@ -0,0 +1,24 @@ +import { Disposable } from "../ide/types/ide.types"; + +/** + * Construct a disposable that disposes 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 function disposableFrom(...disposables: Disposable[]): Disposable { + return { + dispose(): void { + disposables.forEach(({ dispose }) => { + try { + dispose(); + } catch (e) { + // just log, but don't throw; some of the VSCode disposables misbehave, + // and we don't want that to prevent us from disposing the rest of the + // disposables + console.error(e); + } + }); + }, + }; +} diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index e4219a6897..d7b9491253 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/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 f5e933f599..4da0b9bfe8 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -4,12 +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 { ScopeRangeProvider } from "./ScopeVisualizer/ScopeRangeProvider"; -import { ScopeSupportChecker } from "./ScopeVisualizer/ScopeSupportChecker"; import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; @@ -21,9 +19,13 @@ import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryI import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers"; import { runCommand } from "./runCommand"; import { runIntegrationTests } from "./runIntegrationTests"; +import { ScopeInfoProvider } from "./scopeProviders/ScopeInfoProvider"; +import { ScopeRangeProvider } from "./scopeProviders/ScopeRangeProvider"; +import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher"; +import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker"; +import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher"; import { TalonSpokenFormsJsonReader } from "./nodeCommon/TalonSpokenFormsJsonReader"; import { injectIde } from "./singletons/ide.singleton"; -import { ScopeRangeWatcher } from "./ScopeVisualizer/ScopeRangeWatcher"; export function createCursorlessEngine( treeSitter: TreeSitter, @@ -93,7 +95,11 @@ export function createCursorlessEngine( ); }, }, - scopeProvider: createScopeProvider(languageDefinitions, storedTargets), + scopeProvider: createScopeProvider( + languageDefinitions, + storedTargets, + customSpokenFormGenerator, + ), customSpokenFormGenerator, testCaseRecorder, storedTargets, @@ -108,6 +114,7 @@ export function createCursorlessEngine( function createScopeProvider( languageDefinitions: LanguageDefinitions, storedTargets: StoredTargetMap, + customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, ): ScopeProvider { const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions); @@ -125,6 +132,12 @@ function createScopeProvider( rangeProvider, ); const supportChecker = new ScopeSupportChecker(scopeHandlerFactory); + const infoProvider = new ScopeInfoProvider(customSpokenFormGenerator); + const supportWatcher = new ScopeSupportWatcher( + languageDefinitions, + supportChecker, + infoProvider, + ); return { provideScopeRanges: rangeProvider.provideScopeRanges, @@ -134,5 +147,8 @@ function createScopeProvider( rangeWatcher.onDidChangeIterationScopeRanges, getScopeSupport: supportChecker.getScopeSupport, getIterationScopeSupport: supportChecker.getIterationScopeSupport, + onDidChangeScopeSupport: supportWatcher.onDidChangeScopeSupport, + getScopeInfo: infoProvider.getScopeTypeInfo, + onDidChangeScopeInfo: infoProvider.onDidChangeScopeInfo, }; } diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts index ad3a09f9ab..9485e80c60 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts @@ -274,9 +274,18 @@ function constructSpokenForms(component: SpokenFormComponent): string[] { component.spokenFormType, )} with id ${component.id}`; - const helpInfo = component.spokenForms.isPrivate - ? "this is a private spoken form currently only for internal experimentation" - : "please see https://www.cursorless.org/docs/user/customization/ for more information"; + let helpInfo: string; + + if (component.spokenForms.isPrivate) { + helpInfo = + "this is a private spoken form currently only for internal experimentation"; + } else if (component.spokenForms.requiresTalonUpdate) { + helpInfo = + "please update talon to the latest version (see https://www.cursorless.org/docs/user/updating/)"; + } else { + helpInfo = + "please see https://www.cursorless.org/docs/user/customization/ for more information"; + } throw new NoSpokenFormError( `${componentInfo}; ${helpInfo}`, 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 new file mode 100644 index 0000000000..5646746b07 --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -0,0 +1,187 @@ +import { + Disposable, + ScopeType, + ScopeTypeInfo, + ScopeTypeInfoEventCallback, + SurroundingPairScopeType, + simpleScopeTypeTypes, + surroundingPairNames, +} from "@cursorless/common"; +import { pull } from "lodash"; + +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { scopeTypeToString } from "./scopeTypeToString"; + +/** + * Maintains a list of all scope types and notifies listeners when it changes. + */ +export class ScopeInfoProvider { + private disposable: Disposable; + private listeners: ScopeTypeInfoEventCallback[] = []; + private scopeInfos!: ScopeTypeInfo[]; + + constructor( + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) { + this.disposable = customSpokenFormGenerator.onDidChangeCustomSpokenForms( + () => this.onChange(), + ); + + this.onDidChangeScopeInfo = this.onDidChangeScopeInfo.bind(this); + this.getScopeTypeInfo = this.getScopeTypeInfo.bind(this); + this.updateScopeTypeInfos(); + } + + /** + * Registers a callback to be run when the scope info changes. The callback + * will be run immediately once with the current scope info. + * + * Includes information about the available scopes, including their custom + * spoken forms, if available. Note that even custom regex scopes will be + * available, as reported to the engine by Talon. + * @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 { + callback(this.getScopeTypeInfos()); + + this.listeners.push(callback); + + return { + dispose: () => { + pull(this.listeners, callback); + }, + }; + } + + private async onChange() { + this.updateScopeTypeInfos(); + + this.listeners.forEach((listener) => listener(this.scopeInfos)); + } + + private updateScopeTypeInfos(): void { + const scopeTypes: ScopeType[] = [ + ...simpleScopeTypeTypes + // Ignore instance pseudo-scope because it's not really a scope + .filter((scopeTypeType) => scopeTypeType !== "instance") + .map((scopeTypeType) => ({ + type: scopeTypeType, + })), + + ...surroundingPairNames.map( + (surroundingPairName): SurroundingPairScopeType => ({ + type: "surroundingPair", + delimiter: surroundingPairName, + }), + ), + + ...this.customSpokenFormGenerator.getCustomRegexScopeTypes(), + ]; + + this.scopeInfos = scopeTypes.map((scopeType) => + this.getScopeTypeInfo(scopeType), + ); + } + + getScopeTypeInfos(): ScopeTypeInfo[] { + return this.scopeInfos; + } + + getScopeTypeInfo(scopeType: ScopeType): ScopeTypeInfo { + return { + scopeType, + spokenForm: + this.customSpokenFormGenerator.scopeTypeToSpokenForm(scopeType), + humanReadableName: scopeTypeToString(scopeType), + isLanguageSpecific: isLanguageSpecific(scopeType), + }; + } + + dispose() { + this.disposable.dispose(); + } +} + +/** + * @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 "private.fieldAccess": + case "functionCall": + case "functionCallee": + case "functionName": + case "ifStatement": + case "instance": + case "list": + case "map": + case "name": + case "namedFunction": + case "regularExpression": + case "statement": + case "type": + case "value": + case "condition": + case "section": + case "sectionLevelOne": + case "sectionLevelTwo": + case "sectionLevelThree": + case "sectionLevelFour": + case "sectionLevelFive": + case "sectionLevelSix": + case "selector": + case "switchStatementSubject": + case "unit": + case "xmlBothTags": + case "xmlElement": + case "xmlEndTag": + case "xmlStartTag": + case "part": + case "chapter": + case "subSection": + case "subSubSection": + case "namedParagraph": + case "subParagraph": + case "environment": + return true; + + case "character": + case "word": + case "token": + case "identifier": + case "line": + case "sentence": + case "paragraph": + case "document": + case "nonWhitespaceSequence": + case "boundedNonWhitespaceSequence": + case "url": + case "notebookCell": + case "surroundingPair": + case "customRegex": + return false; + + case "oneOf": + throw Error( + `Can't decide whether scope type ${JSON.stringify( + scopeType, + undefined, + 3, + )} is language-specific`, + ); + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts similarity index 97% rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts rename to packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts index 62f39039f9..0e1ae1d0b8 100644 --- a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts @@ -1,10 +1,11 @@ -import { TextEditor } from "@cursorless/common"; import { IterationScopeRangeConfig, IterationScopeRanges, ScopeRangeConfig, ScopeRanges, -} from ".."; + TextEditor, +} from "@cursorless/common"; + import { ModifierStageFactory } from "../processTargets/ModifierStageFactory"; import { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory"; import { getIterationRange } from "./getIterationRange"; diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts similarity index 96% rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts rename to packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts index 6a4a7971b5..fdc5790f32 100644 --- a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeWatcher.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeRangeWatcher.ts @@ -1,12 +1,14 @@ -import { Disposable, showError } from "@cursorless/common"; -import { pull } from "lodash"; import { + Disposable, IterationScopeChangeEventCallback, IterationScopeRangeConfig, ScopeChangeEventCallback, ScopeRangeConfig, ScopeRanges, -} from ".."; + showError, +} from "@cursorless/common"; +import { pull } from "lodash"; + import { Debouncer } from "../core/Debouncer"; import { LanguageDefinitions } from "../languages/LanguageDefinitions"; import { ide } from "../singletons/ide.singleton"; @@ -25,6 +27,11 @@ export class ScopeRangeWatcher { languageDefinitions: LanguageDefinitions, private scopeRangeProvider: ScopeRangeProvider, ) { + this.onChange = this.onChange.bind(this); + this.onDidChangeScopeRanges = this.onDidChangeScopeRanges.bind(this); + this.onDidChangeIterationScopeRanges = + this.onDidChangeIterationScopeRanges.bind(this); + this.disposables.push( // An Event which fires when the array of visible editors has changed. ide().onDidChangeVisibleTextEditors(this.debouncer.run), @@ -37,13 +44,9 @@ export class ScopeRangeWatcher { // dirty-state changes. ide().onDidChangeTextDocument(this.debouncer.run), ide().onDidChangeTextEditorVisibleRanges(this.debouncer.run), - languageDefinitions.onDidChangeDefinition(this.debouncer.run), + languageDefinitions.onDidChangeDefinition(this.onChange), this.debouncer, ); - - this.onDidChangeScopeRanges = this.onDidChangeScopeRanges.bind(this); - this.onDidChangeIterationScopeRanges = - this.onDidChangeIterationScopeRanges.bind(this); } /** diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts similarity index 98% rename from packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts rename to packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts index d9ee8f1664..894d3b5262 100644 --- a/packages/cursorless-engine/src/ScopeVisualizer/ScopeSupportChecker.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts @@ -1,5 +1,6 @@ import { Position, + ScopeSupport, ScopeType, SimpleScopeTypeType, TextEditor, @@ -9,7 +10,6 @@ import { LegacyLanguageId } from "../languages/LegacyLanguageId"; import { languageMatchers } from "../languages/getNodeMatcher"; import { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory"; import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; -import { ScopeSupport } from "../api/ScopeProvider"; /** * Determines the level of support for a given scope type in a given editor. diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts new file mode 100644 index 0000000000..04c236ac9c --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportWatcher.ts @@ -0,0 +1,121 @@ +import { + Disposable, + ScopeSupport, + ScopeSupportEventCallback, + ScopeSupportInfo, + ScopeType, + disposableFrom, +} from "@cursorless/common"; +import { pull } from "lodash"; + +import { Debouncer } from "../core/Debouncer"; +import { LanguageDefinitions } from "../languages/LanguageDefinitions"; +import { ide } from "../singletons/ide.singleton"; +import { ScopeInfoProvider } from "./ScopeInfoProvider"; +import { ScopeSupportChecker } from "./ScopeSupportChecker"; + +/** + * Watches for changes to the scope support of the active editor and notifies + * listeners when it changes. Watches support for all scopes at the same time. + */ +export class ScopeSupportWatcher { + private disposable: Disposable; + private debouncer = new Debouncer(() => this.onChange()); + private listeners: ScopeSupportEventCallback[] = []; + + constructor( + languageDefinitions: LanguageDefinitions, + private scopeSupportChecker: ScopeSupportChecker, + private scopeInfoProvider: ScopeInfoProvider, + ) { + this.onChange = this.onChange.bind(this); + this.onDidChangeScopeSupport = this.onDidChangeScopeSupport.bind(this); + + this.disposable = disposableFrom( + // An event that fires when a text document opens + ide().onDidOpenTextDocument(this.debouncer.run), + // An Event that fires when a text document closes + ide().onDidCloseTextDocument(this.debouncer.run), + // An Event which fires when the active editor has changed. Note that the event also fires when the active editor changes to undefined. + ide().onDidChangeActiveTextEditor(this.debouncer.run), + // An event that is emitted when a text document is changed. This usually + // happens when the contents changes but also when other things like the + // dirty-state changes. + ide().onDidChangeTextDocument(this.debouncer.run), + languageDefinitions.onDidChangeDefinition(this.debouncer.run), + this.scopeInfoProvider.onDidChangeScopeInfo(this.onChange), + this.debouncer, + ); + } + + /** + * Registers a callback to be run when the scope support changes for the active + * editor. The callback will be run immediately once with the current support + * levels for the active editor. + * + * Note that this watcher could be expensive, because it runs all the scope + * handlers for the active editor every time the content of the active editor + * changes. If you only need info about the available scopes, including their + * spoken forms, you should use {@link onDidChangeScopeInfo} instead. + * @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 { + 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() { + this.disposable.dispose(); + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts b/packages/cursorless-engine/src/scopeProviders/getIterationRange.ts similarity index 100% rename from packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts rename to packages/cursorless-engine/src/scopeProviders/getIterationRange.ts diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts b/packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts similarity index 94% rename from packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts rename to packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts index feb42bffe9..a28b6eb8b8 100644 --- a/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts +++ b/packages/cursorless-engine/src/scopeProviders/getIterationScopeRanges.ts @@ -1,6 +1,5 @@ -import { Range, TextEditor } from "@cursorless/common"; +import { IterationScopeRanges, Range, TextEditor } from "@cursorless/common"; import { map } from "itertools"; -import { IterationScopeRanges } from ".."; import { ModifierStage } from "../processTargets/PipelineStages.types"; import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; import { Target } from "../typings/target.types"; diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts b/packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts similarity index 91% rename from packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts rename to packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts index 56e47dde50..3e36c4f768 100644 --- a/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts +++ b/packages/cursorless-engine/src/scopeProviders/getScopeRanges.ts @@ -1,6 +1,6 @@ -import { Range, TextEditor } from "@cursorless/common"; +import { Range, ScopeRanges, TextEditor } from "@cursorless/common"; import { map } from "itertools"; -import { ScopeRanges } from ".."; + import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; import { getTargetRanges } from "./getTargetRanges"; diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts b/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts similarity index 73% rename from packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts rename to packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts index 5fd843310c..8be5e52e72 100644 --- a/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts +++ b/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts @@ -1,6 +1,9 @@ -import { toCharacterRange, toLineRange } from "@cursorless/common"; +import { + TargetRanges, + toCharacterRange, + toLineRange, +} from "@cursorless/common"; import { Target } from "../typings/target.types"; -import { TargetRanges } from "../api/ScopeProvider"; export function getTargetRanges(target: Target): TargetRanges { return { diff --git a/packages/cursorless-engine/src/scopeProviders/scopeTypeToString.ts b/packages/cursorless-engine/src/scopeProviders/scopeTypeToString.ts new file mode 100644 index 0000000000..5a0136ef62 --- /dev/null +++ b/packages/cursorless-engine/src/scopeProviders/scopeTypeToString.ts @@ -0,0 +1,21 @@ +import { + ScopeType, + camelCaseToAllDown, + isSimpleScopeType, +} from "@cursorless/common"; + +export function scopeTypeToString(scopeType: ScopeType): string { + if (isSimpleScopeType(scopeType)) { + return camelCaseToAllDown(scopeType.type); + } + + if (scopeType.type === "surroundingPair") { + return `Matching pair of ${camelCaseToAllDown(scopeType.delimiter)}`; + } + + if (scopeType.type === "customRegex") { + return `Regex \`${scopeType.regex}\``; + } + + return "Unknown scope type"; +} diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts new file mode 100644 index 0000000000..0697298880 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts @@ -0,0 +1,41 @@ +import { ScopeType, ScopeTypeInfo } from "@cursorless/common"; +import * as sinon from "sinon"; +import { assert } from "chai"; +import { sleepWithBackoff } from "../../endToEndTestSetup"; +import { isEqual } from "lodash"; + +export async function assertCalledWithScopeInfo( + fake: sinon.SinonSpy<[scopeInfos: T[]], void>, + ...expectedScopeInfos: T[] +) { + await sleepWithBackoff(25); + sinon.assert.called(fake); + + for (const expectedScopeInfo of expectedScopeInfos) { + const actualScopeInfo = fake.lastCall.args[0].find((scopeInfo) => + isEqual(scopeInfo.scopeType, expectedScopeInfo.scopeType), + ); + assert.isDefined(actualScopeInfo); + assert.deepEqual(actualScopeInfo, expectedScopeInfo); + } + + fake.resetHistory(); +} + +export async function assertCalledWithoutScopeInfo( + fake: sinon.SinonSpy<[scopeInfos: T[]], void>, + ...scopeTypes: ScopeType[] +) { + await sleepWithBackoff(25); + sinon.assert.called(fake); + + for (const scopeType of scopeTypes) { + assert.isUndefined( + fake.lastCall.args[0].find((scopeInfo) => + isEqual(scopeInfo.scopeType, scopeType), + ), + ); + } + + fake.resetHistory(); +} diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts new file mode 100644 index 0000000000..d26dc3ef79 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts @@ -0,0 +1,73 @@ +import { ScopeSupport, ScopeSupportInfo } from "@cursorless/common"; +import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common"; +import * as sinon from "sinon"; +import { Position, Range, TextDocument, commands } from "vscode"; +import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo"; + +/** + * Tests that the scope provider correctly reports the scope support for a + * simple named function. + */ +export async function runBasicScopeInfoTest() { + const { scopeProvider } = (await getCursorlessApi()).testHelpers!; + const fake = sinon.fake<[scopeInfos: ScopeSupportInfo[]], void>(); + + await commands.executeCommand("workbench.action.closeAllEditors"); + + const disposable = scopeProvider.onDidChangeScopeSupport(fake); + + try { + await assertCalledWithScopeInfo(fake, unsupported); + + const editor = await openNewEditor("", { + languageId: "typescript", + }); + await assertCalledWithScopeInfo(fake, supported); + + await editor.edit((editBuilder) => { + editBuilder.insert(new Position(0, 0), contents); + }); + await assertCalledWithScopeInfo(fake, present); + + await editor.edit((editBuilder) => { + editBuilder.delete(getDocumentRange(editor.document)); + }); + await assertCalledWithScopeInfo(fake, supported); + + await commands.executeCommand("workbench.action.closeAllEditors"); + await assertCalledWithScopeInfo(fake, unsupported); + } finally { + disposable.dispose(); + } +} + +function getDocumentRange(textDocument: TextDocument) { + const { end } = textDocument.lineAt(textDocument.lineCount - 1).range; + return new Range(0, 0, end.line, end.character); +} + +const contents = ` +function helloWorld() { + +} +`; + +function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo { + return { + humanReadableName: "named function", + isLanguageSpecific: true, + iterationScopeSupport: scopeSupport, + scopeType: { + type: "namedFunction", + }, + spokenForm: { + spokenForms: ["funk"], + type: "success", + }, + support: scopeSupport, + }; +} + +const unsupported = getExpectedScope(ScopeSupport.unsupported); +const supported = getExpectedScope(ScopeSupport.supportedButNotPresentInEditor); +const present = getExpectedScope(ScopeSupport.supportedAndPresentInEditor); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts new file mode 100644 index 0000000000..55b598b9c9 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts @@ -0,0 +1,103 @@ +import { + ScopeSupport, + ScopeSupportInfo, + ScopeType, + sleep, +} from "@cursorless/common"; +import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common"; +import { stat, unlink, writeFile } from "fs/promises"; +import * as sinon from "sinon"; +import { commands } from "vscode"; +import { sleepWithBackoff } from "../../endToEndTestSetup"; +import { + assertCalledWithScopeInfo, + assertCalledWithoutScopeInfo, +} from "./assertCalledWithScopeInfo"; + +/** + * Tests that the scope provider correctly reports custom spoken forms for + * custom regex scopes. + */ +export async function runCustomRegexScopeInfoTest() { + const { scopeProvider, cursorlessTalonStateJsonPath } = ( + await getCursorlessApi() + ).testHelpers!; + const fake = sinon.fake<[scopeInfos: ScopeSupportInfo[]], void>(); + + await commands.executeCommand("workbench.action.closeAllEditors"); + + const disposable = scopeProvider.onDidChangeScopeSupport(fake); + + try { + await assertCalledWithoutScopeInfo(fake, scopeType); + + await writeFile( + cursorlessTalonStateJsonPath, + JSON.stringify(spokenFormJsonContents), + ); + await sleepWithBackoff(50); + await assertCalledWithScopeInfo(fake, unsupported); + + await openNewEditor(contents); + await assertCalledWithScopeInfo(fake, present); + + await unlink(cursorlessTalonStateJsonPath); + await sleepWithBackoff(100); + await assertCalledWithoutScopeInfo(fake, scopeType); + } finally { + disposable.dispose(); + + // Delete cursorlessTalonStateJsonPath if it exists + try { + await stat(cursorlessTalonStateJsonPath); + await unlink(cursorlessTalonStateJsonPath); + // Sleep to ensure that the scope support provider has time to update + // before the next test starts + await sleep(250); + } catch (e) { + // Do nothing + } + } +} + +const contents = ` +hello world +`; + +const regex = "[a-zA-Z]+"; + +const spokenFormJsonContents = { + version: 0, + spokenForms: [ + { + 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: { + spokenForms: ["spaghetti"], + type: "success", + }, + support: scopeSupport, + }; +} + +const unsupported = getExpectedScope(ScopeSupport.unsupported); +const present = getExpectedScope(ScopeSupport.supportedAndPresentInEditor); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts new file mode 100644 index 0000000000..29c01ecd12 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts @@ -0,0 +1,228 @@ +import { getCursorlessApi } from "@cursorless/vscode-common"; +import { ScopeTypeInfo, sleep } from "@cursorless/common"; +import * as sinon from "sinon"; +import { assertCalledWithScopeInfo } from "./assertCalledWithScopeInfo"; +import { stat, unlink, writeFile } from "fs/promises"; +import { sleepWithBackoff } from "../../endToEndTestSetup"; + +/** + * Tests that the scope provider correctly reports custom spoken forms + */ +export async function runCustomSpokenFormScopeInfoTest() { + const { scopeProvider, cursorlessTalonStateJsonPath } = ( + await getCursorlessApi() + ).testHelpers!; + const fake = sinon.fake<[scopeInfos: ScopeTypeInfo[]], void>(); + + const disposable = scopeProvider.onDidChangeScopeInfo(fake); + + try { + await assertCalledWithScopeInfo( + fake, + roundStandard, + namedFunctionStandard, + lambdaStandard, + statementStandard, + squareStandard, + subjectStandard, + ); + + await writeFile( + cursorlessTalonStateJsonPath, + JSON.stringify(spokenFormJsonContents), + ); + await sleepWithBackoff(50); + await assertCalledWithScopeInfo( + fake, + subjectCustom, + roundCustom, + namedFunctionCustom, + lambdaCustom, + statementMissing, + squareMissing, + ); + + await unlink(cursorlessTalonStateJsonPath); + await sleepWithBackoff(100); + await assertCalledWithScopeInfo( + fake, + roundStandard, + namedFunctionStandard, + lambdaStandard, + statementStandard, + squareStandard, + subjectStandard, + ); + } finally { + disposable.dispose(); + + // Delete cursorlessTalonStateJsonPath if it exists + try { + await stat(cursorlessTalonStateJsonPath); + await unlink(cursorlessTalonStateJsonPath); + // 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: 0, + spokenForms: [ + { + 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: { + isPrivate: true, + reason: + "simple scope type type with id switchStatementSubject; this is a private spoken form currently only for internal experimentation", + requiresTalonUpdate: false, + type: "error", + }, +}; + +const subjectCustom: ScopeTypeInfo = { + humanReadableName: "switch statement subject", + isLanguageSpecific: true, + scopeType: { type: "switchStatementSubject" }, + spokenForm: { + spokenForms: ["custom subject"], + type: "success", + }, +}; + +const roundStandard: ScopeTypeInfo = { + humanReadableName: "Matching pair of parentheses", + isLanguageSpecific: false, + scopeType: { type: "surroundingPair", delimiter: "parentheses" }, + spokenForm: { + spokenForms: ["round"], + type: "success", + }, +}; + +const roundCustom: ScopeTypeInfo = { + humanReadableName: "Matching pair of parentheses", + isLanguageSpecific: false, + scopeType: { type: "surroundingPair", delimiter: "parentheses" }, + spokenForm: { + spokenForms: ["custom round", "alternate custom round"], + type: "success", + }, +}; + +const squareStandard: ScopeTypeInfo = { + humanReadableName: "Matching pair of square brackets", + isLanguageSpecific: false, + scopeType: { type: "surroundingPair", delimiter: "squareBrackets" }, + spokenForm: { + spokenForms: ["box"], + type: "success", + }, +}; + +const squareMissing: ScopeTypeInfo = { + humanReadableName: "Matching pair of square brackets", + isLanguageSpecific: false, + scopeType: { type: "surroundingPair", delimiter: "squareBrackets" }, + spokenForm: { + isPrivate: false, + reason: + "paired delimiter with id squareBrackets; please update talon to the latest version (see https://www.cursorless.org/docs/user/updating/)", + requiresTalonUpdate: true, + type: "error", + }, +}; + +const namedFunctionStandard: ScopeTypeInfo = { + humanReadableName: "named function", + isLanguageSpecific: true, + scopeType: { type: "namedFunction" }, + spokenForm: { + spokenForms: ["funk"], + type: "success", + }, +}; + +const namedFunctionCustom: ScopeTypeInfo = { + humanReadableName: "named function", + isLanguageSpecific: true, + scopeType: { type: "namedFunction" }, + spokenForm: { + spokenForms: ["custom funk"], + type: "success", + }, +}; + +const lambdaStandard: ScopeTypeInfo = { + humanReadableName: "anonymous function", + isLanguageSpecific: true, + scopeType: { type: "anonymousFunction" }, + spokenForm: { + spokenForms: ["lambda"], + type: "success", + }, +}; + +const lambdaCustom: ScopeTypeInfo = { + humanReadableName: "anonymous function", + isLanguageSpecific: true, + scopeType: { type: "anonymousFunction" }, + spokenForm: { + isPrivate: 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: { + spokenForms: ["state"], + type: "success", + }, +}; + +const statementMissing: ScopeTypeInfo = { + humanReadableName: "statement", + isLanguageSpecific: true, + scopeType: { type: "statement" }, + spokenForm: { + isPrivate: false, + reason: + "simple scope type type with id statement; please update talon to the latest version (see https://www.cursorless.org/docs/user/updating/)", + 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..07024fe605 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runSurroundingPairScopeInfoTest.ts @@ -0,0 +1,50 @@ +import { ScopeSupport, ScopeSupportInfo } from "@cursorless/common"; +import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-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: ScopeSupportInfo[]], 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: { + spokenForms: ["round"], + type: "success", + }, + support: scopeSupport, + }; +} + +const unsupported = getExpectedScope(ScopeSupport.unsupported); +const legacy = getExpectedScope(ScopeSupport.supportedLegacy); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts new file mode 100644 index 0000000000..dd0a5ed94e --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/scopeProvider.vscode.test.ts @@ -0,0 +1,27 @@ +import { asyncSafety } from "@cursorless/common"; +import { endToEndTestSetup } from "../../endToEndTestSetup"; +import { runBasicScopeInfoTest } from "./runBasicScopeInfoTest"; +import { runCustomRegexScopeInfoTest } from "./runCustomRegexScopeInfoTest"; +import { runCustomSpokenFormScopeInfoTest } from "./runCustomSpokenFormScopeInfoTest"; +import { runSurroundingPairScopeInfoTest } from "./runSurroundingPairScopeInfoTest"; + +suite("scope provider", async function () { + endToEndTestSetup(this); + + test( + "basic", + asyncSafety(() => runBasicScopeInfoTest()), + ); + test( + "surrounding pair", + asyncSafety(() => runSurroundingPairScopeInfoTest()), + ); + test( + "custom spoken form", + asyncSafety(() => runCustomSpokenFormScopeInfoTest()), + ); + test( + "custom regex", + asyncSafety(() => runCustomRegexScopeInfoTest()), + ); +}); diff --git a/packages/cursorless-vscode/src/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts index a68539edb0..106a131386 100644 --- a/packages/cursorless-vscode/src/constructTestHelpers.ts +++ b/packages/cursorless-vscode/src/constructTestHelpers.ts @@ -5,6 +5,7 @@ import { HatTokenMap, IDE, NormalizedIDE, + ScopeProvider, SerializedMarks, TargetPlainObject, TestCaseSnapshot, @@ -28,6 +29,8 @@ export function constructTestHelpers( hatTokenMap: HatTokenMap, vscodeIDE: VscodeIDE, normalizedIde: NormalizedIDE, + cursorlessTalonStateJsonPath: string, + scopeProvider: ScopeProvider, injectIde: (ide: IDE) => void, runIntegrationTests: () => Promise, ): TestHelpers | undefined { @@ -35,6 +38,7 @@ export function constructTestHelpers( commandServerApi: commandServerApi!, ide: normalizedIde, injectIde, + scopeProvider, toVscodeEditor, @@ -61,6 +65,8 @@ export function constructTestHelpers( ); }, + cursorlessTalonStateJsonPath, + setStoredTarget( editor: vscode.TextEditor, key: StoredTargetKey, diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 8a5b89844e..ccee23d147 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 { @@ -87,6 +87,7 @@ export async function activate( snippets, injectIde, runIntegrationTests, + customSpokenFormGenerator, } = createCursorlessEngine( treeSitter, normalizedIde, @@ -119,6 +120,8 @@ export async function activate( hatTokenMap, vscodeIDE, normalizedIde as NormalizedIDE, + fileSystem.cursorlessTalonStateJsonPath, + 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 221c8d9ff2..c88b1b0b3d 100644 --- a/packages/vscode-common/src/getExtensionApi.ts +++ b/packages/vscode-common/src/getExtensionApi.ts @@ -5,6 +5,7 @@ import type { HatTokenMap, IDE, NormalizedIDE, + ScopeProvider, SerializedMarks, SnippetMap, TargetPlainObject, @@ -19,6 +20,8 @@ export interface TestHelpers { ide: NormalizedIDE; injectIde: (ide: IDE) => void; + scopeProvider: ScopeProvider; + hatTokenMap: HatTokenMap; commandServerApi: CommandServerApi; @@ -44,6 +47,8 @@ export interface TestHelpers { runIntegrationTests(): Promise; + cursorlessTalonStateJsonPath: string; + /** * A thin wrapper around the VSCode API that allows us to mock it for testing. */