diff --git a/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts b/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts new file mode 100644 index 0000000000..62f39039f9 --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/ScopeRangeProvider.ts @@ -0,0 +1,78 @@ +import { TextEditor } from "@cursorless/common"; +import { + IterationScopeRangeConfig, + IterationScopeRanges, + ScopeRangeConfig, + ScopeRanges, +} from ".."; +import { ModifierStageFactory } from "../processTargets/ModifierStageFactory"; +import { ScopeHandlerFactory } from "../processTargets/modifiers/scopeHandlers/ScopeHandlerFactory"; +import { getIterationRange } from "./getIterationRange"; +import { getIterationScopeRanges } from "./getIterationScopeRanges"; +import { getScopeRanges } from "./getScopeRanges"; + +/** + * Provides scope ranges for a given editor to use eg for visualizing scopes + */ +export class ScopeRangeProvider { + constructor( + private scopeHandlerFactory: ScopeHandlerFactory, + private modifierStageFactory: ModifierStageFactory, + ) { + this.provideScopeRanges = this.provideScopeRanges.bind(this); + this.provideIterationScopeRanges = + this.provideIterationScopeRanges.bind(this); + } + + provideScopeRanges( + editor: TextEditor, + { scopeType, visibleOnly }: ScopeRangeConfig, + ): ScopeRanges[] { + const scopeHandler = this.scopeHandlerFactory.create( + scopeType, + editor.document.languageId, + ); + + if (scopeHandler == null) { + return []; + } + + return getScopeRanges( + editor, + scopeHandler, + getIterationRange(editor, scopeHandler, visibleOnly), + ); + } + + provideIterationScopeRanges( + editor: TextEditor, + { scopeType, visibleOnly, includeNestedTargets }: IterationScopeRangeConfig, + ): IterationScopeRanges[] { + const { languageId } = editor.document; + const scopeHandler = this.scopeHandlerFactory.create(scopeType, languageId); + + if (scopeHandler == null) { + return []; + } + + const iterationScopeHandler = this.scopeHandlerFactory.create( + scopeHandler.iterationScopeType, + languageId, + ); + + if (iterationScopeHandler == null) { + return []; + } + + return getIterationScopeRanges( + editor, + iterationScopeHandler, + this.modifierStageFactory.create({ + type: "everyScope", + scopeType, + }), + getIterationRange(editor, scopeHandler, visibleOnly), + includeNestedTargets, + ); + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts b/packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts new file mode 100644 index 0000000000..38ccc0215c --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/getIterationRange.ts @@ -0,0 +1,64 @@ +import { Range, TextEditor } from "@cursorless/common"; +import { last } from "lodash"; +import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; + +/** + * Get the range to iterate over for the given editor. + * + * - If {@link visibleOnly} is `false`, just return the full document range. + * - Otherwise, we + * 1. take the union of all visible ranges, then + * 2. add 10 lines either side to make scrolling a bit smoother, and then + * 3. expand to the largest ancestor of the start and end of the visible + * range, so that we properly show nesting. + * @param editor The editor to get the iteration range for + * @param scopeHandler The scope handler to use + * @param visibleOnly Whether to only iterate over visible ranges + * @returns The range to iterate over + */ +export function getIterationRange( + editor: TextEditor, + scopeHandler: ScopeHandler, + visibleOnly: boolean, +): Range { + if (!visibleOnly) { + return editor.document.range; + } + + let visibleRange = editor.visibleRanges.reduce((acc, range) => + acc.union(range), + ); + + visibleRange = editor.document.range.intersection( + visibleRange.with( + visibleRange.start.translate(-10), + visibleRange.end.translate(10), + ), + )!; + + // Expand to largest ancestor of start of visible range FIXME: It's + // possible that the removal range will be bigger than the domain range, + // in which case we'll miss a scope if its removal range is visible but + // its domain range is not. I don't think we care that much; they can + // scroll, and we have the extra 10 lines on either side which might help. + const expandedStart = + last( + Array.from( + scopeHandler.generateScopes(editor, visibleRange.start, "forward", { + containment: "required", + }), + ), + )?.domain ?? visibleRange; + + // Expand to largest ancestor of end of visible range + const expandedEnd = + last( + Array.from( + scopeHandler.generateScopes(editor, visibleRange.end, "forward", { + containment: "required", + }), + ), + )?.domain ?? visibleRange; + + return expandedStart.union(expandedEnd); +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts b/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts new file mode 100644 index 0000000000..feb42bffe9 --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/getIterationScopeRanges.ts @@ -0,0 +1,61 @@ +import { 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"; +import { getTargetRanges } from "./getTargetRanges"; + +/** + * Returns a list of teration scope ranges of type {@link iterationScopeHandler} + * within {@link iterationRange} in {@link editor}. + * @param editor The editor to check + * @param iterationScopeHandler The scope handler to use + * @param everyStage An every stage for use in determining nested targets + * @param iterationRange The range to iterate over + * @param includeIterationNestedTargets Whether to include nested targets in the + * iteration scope ranges + * @returns A list of iteration scope ranges for the given editor + */ +export function getIterationScopeRanges( + editor: TextEditor, + iterationScopeHandler: ScopeHandler, + everyStage: ModifierStage, + iterationRange: Range, + includeIterationNestedTargets: boolean, +): IterationScopeRanges[] { + return map( + iterationScopeHandler.generateScopes( + editor, + iterationRange.start, + "forward", + { + includeDescendantScopes: true, + distalPosition: iterationRange.end, + }, + ), + (scope) => { + return { + domain: scope.domain, + ranges: scope.getTargets(false).map((target) => ({ + range: target.contentRange, + targets: includeIterationNestedTargets + ? getEveryScopeLenient(everyStage, target).map(getTargetRanges) + : undefined, + })), + }; + }, + ); +} + +function getEveryScopeLenient(everyStage: ModifierStage, target: Target) { + try { + return everyStage.run(target); + } catch (err) { + if ((err as Error).name === "NoContainingScopeError") { + return []; + } + + throw err; + } +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts b/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts new file mode 100644 index 0000000000..56e47dde50 --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/getScopeRanges.ts @@ -0,0 +1,30 @@ +import { Range, TextEditor } from "@cursorless/common"; +import { map } from "itertools"; +import { ScopeRanges } from ".."; +import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types"; +import { getTargetRanges } from "./getTargetRanges"; + +/** + * Returns a list of scope ranges of type {@link scopeHandler} within + * {@link iterationRange} in {@link editor}. + * @param editor The editor to check + * @param scopeHandler The scope handler to use + * @param iterationRange The range to iterate over + * @returns A list of scope ranges for the given editor + */ +export function getScopeRanges( + editor: TextEditor, + scopeHandler: ScopeHandler, + iterationRange: Range, +): ScopeRanges[] { + return map( + scopeHandler.generateScopes(editor, iterationRange.start, "forward", { + includeDescendantScopes: true, + distalPosition: iterationRange.end, + }), + (scope) => ({ + domain: scope.domain, + targets: scope.getTargets(false).map(getTargetRanges), + }), + ); +} diff --git a/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts b/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts new file mode 100644 index 0000000000..5fd843310c --- /dev/null +++ b/packages/cursorless-engine/src/ScopeVisualizer/getTargetRanges.ts @@ -0,0 +1,12 @@ +import { toCharacterRange, toLineRange } from "@cursorless/common"; +import { Target } from "../typings/target.types"; +import { TargetRanges } from "../api/ScopeProvider"; + +export function getTargetRanges(target: Target): TargetRanges { + return { + contentRange: target.contentRange, + removalHighlightRange: target.isLine + ? toLineRange(target.getRemovalHighlightRange()) + : toCharacterRange(target.getRemovalHighlightRange()), + }; +} diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts new file mode 100644 index 0000000000..4dd323bf27 --- /dev/null +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -0,0 +1,30 @@ +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"; + +export interface CursorlessEngine { + commandApi: CommandApi; + scopeProvider: ScopeProvider; + testCaseRecorder: TestCaseRecorder; + storedTargets: StoredTargetMap; + hatTokenMap: HatTokenMap; + snippets: Snippets; + injectIde: (ide: IDE | undefined) => void; + runIntegrationTests: () => Promise; +} + +export interface CommandApi { + /** + * Runs a command. This is the core of the Cursorless engine. + * @param command The command to run + */ + runCommand(command: Command): Promise; + + /** + * Designed to run commands that come directly from the user. Ensures that + * the command args are of the correct shape. + */ + runCommandSafe(...args: unknown[]): Promise; +} diff --git a/packages/cursorless-engine/src/api/ScopeProvider.ts b/packages/cursorless-engine/src/api/ScopeProvider.ts new file mode 100644 index 0000000000..2274955681 --- /dev/null +++ b/packages/cursorless-engine/src/api/ScopeProvider.ts @@ -0,0 +1,96 @@ +import { + GeneralizedRange, + Range, + ScopeType, + TextEditor, +} from "@cursorless/common"; + +export interface ScopeProvider { + /** + * Get the scope ranges for the given editor. + * @param editor The editor + * @param config The configuration for the scope ranges + * @returns A list of scope ranges for the given editor + */ + provideScopeRanges: ( + editor: TextEditor, + config: ScopeRangeConfig, + ) => ScopeRanges[]; + /** + * Get the iteration scope ranges for the given editor. + * @param editor The editor + * @param config The configuration for the scope ranges + * @returns A list of scope ranges for the given editor + */ + provideIterationScopeRanges: ( + editor: TextEditor, + config: IterationScopeRangeConfig, + ) => IterationScopeRanges[]; +} + +interface ScopeRangeConfigBase { + /** + * Whether to only include visible scopes + */ + visibleOnly: boolean; + + /** + * The scope type to use + */ + scopeType: ScopeType; +} + +export type ScopeRangeConfig = ScopeRangeConfigBase; + +export interface IterationScopeRangeConfig extends ScopeRangeConfigBase { + /** + * Whether to include nested targets in each iteration scope range + */ + includeNestedTargets: boolean; +} + +/** + * Contains the ranges that define a given scope, eg its {@link domain} and the + * ranges for its {@link targets}. + */ +export interface ScopeRanges { + domain: Range; + targets: TargetRanges[]; +} + +/** + * Contains the ranges that define a given target, eg its {@link contentRange} + * and the ranges for its {@link removalHighlightRange}. + */ +export interface TargetRanges { + contentRange: Range; + removalHighlightRange: GeneralizedRange; +} + +/** + * Contains the ranges that define a given iteration scope, eg its + * {@link domain}. + */ +export interface IterationScopeRanges { + domain: Range; + + /** + * A list of ranges within within which iteration will happen. There is + * almost always a single range here. There will be more than one if the + * iteration scope handler returns a scope whose `getTargets` method returns + * multiple targets. As of this writing, no scope handler returns multiple + * targets. + */ + ranges: { + /** + * The range within which iteration will happen, ie the content range for + * the target returned by the iteration scope handler. + */ + range: Range; + + /** + * The defining ranges for all targets within this iteration range. + */ + targets?: TargetRanges[]; + }[]; +} diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index ffcb7f7b3b..09455c81ec 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -1,14 +1,19 @@ import { Command, CommandServerApi, Hats, IDE } from "@cursorless/common"; import { StoredTargetMap, TestCaseRecorder, TreeSitter } from "."; +import { CursorlessEngine } from "./api/CursorlessEngineApi"; +import { ScopeProvider } from "./api/ScopeProvider"; +import { ScopeRangeProvider } from "./ScopeVisualizer/ScopeRangeProvider"; import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; +import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; import { LanguageDefinitions } from "./languages/LanguageDefinitions"; +import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl"; +import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers"; +import { runCommand } from "./runCommand"; import { runIntegrationTests } from "./runIntegrationTests"; import { injectIde } from "./singletons/ide.singleton"; -import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; -import { runCommand } from "./runCommand"; export function createCursorlessEngine( treeSitter: TreeSitter, @@ -68,6 +73,7 @@ export function createCursorlessEngine( ); }, }, + scopeProvider: createScopeProvider(languageDefinitions, storedTargets), testCaseRecorder, storedTargets, hatTokenMap, @@ -78,26 +84,23 @@ export function createCursorlessEngine( }; } -export interface CommandApi { - /** - * Runs a command. This is the core of the Cursorless engine. - * @param command The command to run - */ - runCommand(command: Command): Promise; +function createScopeProvider( + languageDefinitions: LanguageDefinitions, + storedTargets: StoredTargetMap, +): ScopeProvider { + const scopeHandlerFactory = new ScopeHandlerFactoryImpl(languageDefinitions); - /** - * Designed to run commands that come directly from the user. Ensures that - * the command args are of the correct shape. - */ - runCommandSafe(...args: unknown[]): Promise; -} + const rangeProvider = new ScopeRangeProvider( + scopeHandlerFactory, + new ModifierStageFactoryImpl( + languageDefinitions, + storedTargets, + scopeHandlerFactory, + ), + ); -export interface CursorlessEngine { - commandApi: CommandApi; - testCaseRecorder: TestCaseRecorder; - storedTargets: StoredTargetMap; - hatTokenMap: HatTokenMapImpl; - snippets: Snippets; - injectIde: (ide: IDE | undefined) => void; - runIntegrationTests: () => Promise; + return { + provideScopeRanges: rangeProvider.provideScopeRanges, + provideIterationScopeRanges: rangeProvider.provideIterationScopeRanges, + }; } diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 2327870435..45b5881824 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -5,3 +5,5 @@ export * from "./testCaseRecorder/TestCaseRecorder"; export * from "./core/StoredTargets"; export * from "./typings/TreeSitter"; export * from "./cursorlessEngine"; +export * from "./api/CursorlessEngineApi"; +export * from "./api/ScopeProvider";