diff --git a/packages/cursorless-engine/src/languages/ruby.ts b/packages/cursorless-engine/src/languages/ruby.ts index 2b3c89ce0e..9bf1c87158 100644 --- a/packages/cursorless-engine/src/languages/ruby.ts +++ b/packages/cursorless-engine/src/languages/ruby.ts @@ -175,7 +175,6 @@ const nodeMatchers: Partial< "argument_list", ), collectionKey: trailingMatcher(["pair[key]"], [":"]), - className: "class[name]", name: [ "assignment[left]", "operator_assignment[left]", diff --git a/packages/cursorless-engine/src/processTargets/modifiers/ContainingScopeStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/ContainingScopeStage.ts index d24247fc32..e24cca3717 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/ContainingScopeStage.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/ContainingScopeStage.ts @@ -1,18 +1,13 @@ -import type { ContainingScopeModifier, Direction } from "@cursorless/common"; import { + ContainingScopeModifier, NoContainingScopeError, - Position, - TextEditor, } from "@cursorless/common"; import type { ProcessedTargetsContext } from "../../typings/Types"; import type { Target } from "../../typings/target.types"; import { ModifierStageFactory } from "../ModifierStageFactory"; import type { ModifierStage } from "../PipelineStages.types"; -import { constructScopeRangeTarget } from "./constructScopeRangeTarget"; -import { getContainingScope } from "./getContainingScope"; import { ScopeHandlerFactory } from "./scopeHandlers/ScopeHandlerFactory"; -import { TargetScope } from "./scopeHandlers/scope.types"; -import { ScopeHandler } from "./scopeHandlers/scopeHandler.types"; +import { getContainingScopeTarget } from "./getContainingScopeTarget"; /** * This modifier stage expands from the input target to the smallest containing @@ -40,11 +35,6 @@ export class ContainingScopeStage implements ModifierStage { ) {} run(context: ProcessedTargetsContext, target: Target): Target[] { - const { - isReversed, - editor, - contentRange: { start, end }, - } = target; const { scopeType, ancestorIndex = 0 } = this.modifier; const scopeHandler = this.scopeHandlerFactory.create( @@ -58,131 +48,16 @@ export class ContainingScopeStage implements ModifierStage { .run(context, target); } - if (end.isEqual(start)) { - // Input target is empty; return the preferred scope touching target - let scope = getPreferredScopeTouchingPosition( - scopeHandler, - editor, - start, - ); - - if (scope == null) { - throw new NoContainingScopeError(this.modifier.scopeType.type); - } - - if (ancestorIndex > 0) { - scope = expandFromPosition( - scopeHandler, - editor, - scope.domain.end, - "forward", - ancestorIndex - 1, - ); - } - - if (scope == null) { - throw new NoContainingScopeError(this.modifier.scopeType.type); - } - - return [scope.getTarget(isReversed)]; - } - - const startScope = expandFromPosition( - scopeHandler, - editor, - start, - "forward", - ancestorIndex, - ); - - if (startScope == null) { - throw new NoContainingScopeError(this.modifier.scopeType.type); - } - - if (startScope.domain.contains(end)) { - return [startScope.getTarget(isReversed)]; - } - - const endScope = expandFromPosition( + const containingScope = getContainingScopeTarget( + target, scopeHandler, - editor, - end, - "backward", ancestorIndex, ); - if (endScope == null) { + if (containingScope == null) { throw new NoContainingScopeError(this.modifier.scopeType.type); } - return [constructScopeRangeTarget(isReversed, startScope, endScope)]; + return [containingScope]; } } - -function expandFromPosition( - scopeHandler: ScopeHandler, - editor: TextEditor, - position: Position, - direction: Direction, - ancestorIndex: number, -): TargetScope | undefined { - let nextAncestorIndex = 0; - for (const scope of scopeHandler.generateScopes(editor, position, direction, { - containment: "required", - })) { - if (nextAncestorIndex === ancestorIndex) { - return scope; - } - - // Because containment is required, and we are moving in a consistent - // direction (ie forward or backward), each scope will be progressively - // larger - nextAncestorIndex += 1; - } - - return undefined; -} - -function getPreferredScopeTouchingPosition( - scopeHandler: ScopeHandler, - editor: TextEditor, - position: Position, -): TargetScope | undefined { - const forwardScope = getContainingScope( - scopeHandler, - editor, - position, - "forward", - ); - - if (forwardScope == null) { - return getContainingScope(scopeHandler, editor, position, "backward"); - } - - if ( - scopeHandler.isPreferredOver == null || - forwardScope.domain.start.isBefore(position) - ) { - return forwardScope; - } - - const backwardScope = getContainingScope( - scopeHandler, - editor, - position, - "backward", - ); - - // If there is no backward scope, or if the backward scope is an ancestor of - // forward scope, return forward scope - if ( - backwardScope == null || - backwardScope.domain.contains(forwardScope.domain) - ) { - return forwardScope; - } - - return scopeHandler.isPreferredOver(backwardScope, forwardScope) ?? false - ? backwardScope - : forwardScope; -} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/EveryScopeStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/EveryScopeStage.ts index 997c75bacc..e1259c0d4e 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/EveryScopeStage.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/EveryScopeStage.ts @@ -4,6 +4,7 @@ import type { ProcessedTargetsContext } from "../../typings/Types"; import type { Target } from "../../typings/target.types"; import { ModifierStageFactory } from "../ModifierStageFactory"; import type { ModifierStage } from "../PipelineStages.types"; +import { getContainingScopeTarget } from "./getContainingScopeTarget"; import { ScopeHandlerFactory } from "./scopeHandlers/ScopeHandlerFactory"; import getScopesOverlappingRange from "./scopeHandlers/getScopesOverlappingRange"; import { TargetScope } from "./scopeHandlers/scope.types"; @@ -77,7 +78,11 @@ export class EveryScopeStage implements ModifierStage { scopes = getScopesOverlappingRange( scopeHandler, editor, - this.getDefaultIterationRange(context, scopeHandler, target), + this.getDefaultIterationRange( + scopeHandler, + this.scopeHandlerFactory, + target, + ), ); } @@ -89,16 +94,30 @@ export class EveryScopeStage implements ModifierStage { } getDefaultIterationRange( - context: ProcessedTargetsContext, scopeHandler: ScopeHandler, + scopeHandlerFactory: ScopeHandlerFactory, target: Target, ): Range { - const containingIterationScopeModifier = this.modifierStageFactory.create({ - type: "containingScope", - scopeType: scopeHandler.iterationScopeType, - }); + const iterationScopeHandler = scopeHandlerFactory.create( + scopeHandler.iterationScopeType, + target.editor.document.languageId, + ); + + if (iterationScopeHandler == null) { + throw Error("Could not find iteration scope handler"); + } + + const iterationScopeTarget = getContainingScopeTarget( + target, + iterationScopeHandler, + ); + + if (iterationScopeTarget == null) { + throw new NoContainingScopeError( + `iteration scope for ${scopeHandler.scopeType!.type}`, + ); + } - return containingIterationScopeModifier.run(context, target)[0] - .contentRange; + return iterationScopeTarget.contentRange; } } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/getContainingScopeTarget.ts b/packages/cursorless-engine/src/processTargets/modifiers/getContainingScopeTarget.ts new file mode 100644 index 0000000000..d1303cc22c --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/getContainingScopeTarget.ts @@ -0,0 +1,149 @@ +import { Direction, Position, TextEditor } from "@cursorless/common"; +import type { Target } from "../../typings/target.types"; +import { constructScopeRangeTarget } from "./constructScopeRangeTarget"; +import { getContainingScope } from "./getContainingScope"; +import { TargetScope } from "./scopeHandlers/scope.types"; +import { ScopeHandler } from "./scopeHandlers/scopeHandler.types"; + +/** + * Finds the containing scope of the target for the given scope handler + * + * @param target The target to find the containing scope of + * @param scopeHandler The scope handler for the scope type to find containing scope of + * @param ancestorIndex How many ancestors to go up. 0 means the immediate containing scope + * @returns A target representing the containing scope, or undefined if no containing scope found + */ +export function getContainingScopeTarget( + target: Target, + scopeHandler: ScopeHandler, + ancestorIndex: number = 0, +): Target | undefined { + const { + isReversed, + editor, + contentRange: { start, end }, + } = target; + + if (end.isEqual(start)) { + // Input target is empty; return the preferred scope touching target + let scope = getPreferredScopeTouchingPosition(scopeHandler, editor, start); + + if (scope == null) { + return undefined; + } + + if (ancestorIndex > 0) { + scope = expandFromPosition( + scopeHandler, + editor, + scope.domain.end, + "forward", + ancestorIndex - 1, + ); + } + + if (scope == null) { + return undefined; + } + + return scope.getTarget(isReversed); + } + + const startScope = expandFromPosition( + scopeHandler, + editor, + start, + "forward", + ancestorIndex, + ); + + if (startScope == null) { + return undefined; + } + + if (startScope.domain.contains(end)) { + return startScope.getTarget(isReversed); + } + + const endScope = expandFromPosition( + scopeHandler, + editor, + end, + "backward", + ancestorIndex, + ); + + if (endScope == null) { + return undefined; + } + + return constructScopeRangeTarget(isReversed, startScope, endScope); +} + +function expandFromPosition( + scopeHandler: ScopeHandler, + editor: TextEditor, + position: Position, + direction: Direction, + ancestorIndex: number, +): TargetScope | undefined { + let nextAncestorIndex = 0; + for (const scope of scopeHandler.generateScopes(editor, position, direction, { + containment: "required", + })) { + if (nextAncestorIndex === ancestorIndex) { + return scope; + } + + // Because containment is required, and we are moving in a consistent + // direction (ie forward or backward), each scope will be progressively + // larger + nextAncestorIndex += 1; + } + + return undefined; +} + +function getPreferredScopeTouchingPosition( + scopeHandler: ScopeHandler, + editor: TextEditor, + position: Position, +): TargetScope | undefined { + const forwardScope = getContainingScope( + scopeHandler, + editor, + position, + "forward", + ); + + if (forwardScope == null) { + return getContainingScope(scopeHandler, editor, position, "backward"); + } + + if ( + scopeHandler.isPreferredOver == null || + forwardScope.domain.start.isBefore(position) + ) { + return forwardScope; + } + + const backwardScope = getContainingScope( + scopeHandler, + editor, + position, + "backward", + ); + + // If there is no backward scope, or if the backward scope is an ancestor of + // forward scope, return forward scope + if ( + backwardScope == null || + backwardScope.domain.contains(forwardScope.domain) + ) { + return forwardScope; + } + + return scopeHandler.isPreferredOver(backwardScope, forwardScope) ?? false + ? backwardScope + : forwardScope; +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts index 6e0c5fbd57..7f607f4bb1 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts @@ -1,8 +1,9 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports -import { Position, Range, TextEditor } from "@cursorless/common"; import type { Direction, ScopeType } from "@cursorless/common"; +import { Position, TextEditor } from "@cursorless/common"; import type { TargetScope } from "./scope.types"; import type { + CustomScopeType, ScopeHandler, ScopeIteratorRequirements, } from "./scopeHandler.types"; @@ -17,8 +18,8 @@ const DEFAULT_REQUIREMENTS: ScopeIteratorRequirements = { * All scope handlers should derive from this base class */ export default abstract class BaseScopeHandler implements ScopeHandler { - public abstract readonly scopeType: ScopeType; - public abstract readonly iterationScopeType: ScopeType; + public abstract readonly scopeType: ScopeType | undefined; + public abstract readonly iterationScopeType: ScopeType | CustomScopeType; /** * Indicates whether scopes are allowed to contain one another. If `false`, we diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/OneOfScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/OneOfScopeHandler.ts index d13eb639b2..75be46d094 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/OneOfScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/OneOfScopeHandler.ts @@ -9,35 +9,58 @@ import { advanceIteratorsUntil, getInitialIteratorInfos } from "./IteratorInfo"; import { ScopeHandlerFactory } from "./ScopeHandlerFactory"; import { compareTargetScopes } from "./compareTargetScopes"; import type { TargetScope } from "./scope.types"; -import { ScopeHandler, ScopeIteratorRequirements } from "./scopeHandler.types"; +import { + CustomScopeType, + ScopeHandler, + ScopeIteratorRequirements, +} from "./scopeHandler.types"; export default class OneOfScopeHandler extends BaseScopeHandler { protected isHierarchical = true; - private scopeHandlers: ScopeHandler[] = this.scopeType.scopeTypes.map( - (scopeType) => { - const handler = this.scopeHandlerFactory.create( - scopeType, - this.languageId, - ); - if (handler == null) { - throw new Error(`No available scope handler for '${scopeType.type}'`); - } - return handler; - }, - ); + static create( + scopeHandlerFactory: ScopeHandlerFactory, + scopeType: OneOfScopeType, + languageId: string, + ): ScopeHandler { + const scopeHandlers: ScopeHandler[] = scopeType.scopeTypes.map( + (scopeType) => { + const handler = scopeHandlerFactory.create(scopeType, languageId); + if (handler == null) { + throw new Error(`No available scope handler for '${scopeType.type}'`); + } + return handler; + }, + ); + + const iterationScopeType = (): CustomScopeType => ({ + type: "custom", + scopeHandler: new OneOfScopeHandler( + undefined, + scopeHandlers.map( + (scopeHandler) => + scopeHandlerFactory.create( + scopeHandler.iterationScopeType, + languageId, + )!, + ), + () => { + throw new Error("Not implemented"); + }, + ), + }); - public iterationScopeType: OneOfScopeType = { - type: "oneOf", - scopeTypes: this.scopeHandlers.map( - ({ iterationScopeType }) => iterationScopeType, - ), - }; + return new OneOfScopeHandler(scopeType, scopeHandlers, iterationScopeType); + } + + get iterationScopeType(): CustomScopeType { + return this.getIterationScopeType(); + } - constructor( - private scopeHandlerFactory: ScopeHandlerFactory, - public readonly scopeType: OneOfScopeType, - private languageId: string, + private constructor( + public readonly scopeType: OneOfScopeType | undefined, + private scopeHandlers: ScopeHandler[], + private getIterationScopeType: () => CustomScopeType, ) { super(); } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactory.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactory.ts index 2aa242f74c..7fe8f9d18a 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactory.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactory.ts @@ -1,6 +1,9 @@ import type { ScopeType } from "@cursorless/common"; -import type { ScopeHandler } from "./scopeHandler.types"; +import type { CustomScopeType, ScopeHandler } from "./scopeHandler.types"; export interface ScopeHandlerFactory { - create(scopeType: ScopeType, languageId: string): ScopeHandler | undefined; + create( + scopeType: ScopeType | CustomScopeType, + languageId: string, + ): ScopeHandler | undefined; } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactoryImpl.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactoryImpl.ts index e11acfbb63..998a096fb6 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactoryImpl.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactoryImpl.ts @@ -11,7 +11,7 @@ import { } from "."; import { LanguageDefinitions } from "../../../languages/LanguageDefinitions"; import { ScopeHandlerFactory } from "./ScopeHandlerFactory"; -import type { ScopeHandler } from "./scopeHandler.types"; +import type { CustomScopeType, ScopeHandler } from "./scopeHandler.types"; /** * Returns a scope handler for the given scope type and language id, or @@ -35,7 +35,10 @@ export class ScopeHandlerFactoryImpl implements ScopeHandlerFactory { this.create = this.create.bind(this); } - create(scopeType: ScopeType, languageId: string): ScopeHandler | undefined { + create( + scopeType: ScopeType | CustomScopeType, + languageId: string, + ): ScopeHandler | undefined { switch (scopeType.type) { case "character": return new CharacterScopeHandler(this, scopeType, languageId); @@ -50,9 +53,11 @@ export class ScopeHandlerFactoryImpl implements ScopeHandlerFactory { case "document": return new DocumentScopeHandler(scopeType, languageId); case "oneOf": - return new OneOfScopeHandler(this, scopeType, languageId); + return OneOfScopeHandler.create(this, scopeType, languageId); case "paragraph": return new ParagraphScopeHandler(scopeType, languageId); + case "custom": + return scopeType.scopeHandler; default: return this.languageDefinitions .get(languageId) diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/BaseTreeSitterScopeHandler.ts similarity index 59% rename from packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler.ts rename to packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/BaseTreeSitterScopeHandler.ts index 44db8e526d..2bfeeb9c0e 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/BaseTreeSitterScopeHandler.ts @@ -1,39 +1,23 @@ import { Direction, Position, - ScopeType, - SimpleScopeType, TextDocument, TextEditor, } from "@cursorless/common"; +import { Query, QueryMatch } from "web-tree-sitter"; +import { TreeSitter } from "../../../.."; +import BaseScopeHandler from "../BaseScopeHandler"; +import { compareTargetScopes } from "../compareTargetScopes"; +import { TargetScope } from "../scope.types"; +import { ScopeIteratorRequirements } from "../scopeHandler.types"; +import { positionToPoint } from "./captureUtils"; -import { Point, Query, QueryMatch } from "web-tree-sitter"; -import { TreeSitter } from "../../.."; -import { getNodeRange } from "../../../util/nodeSelectors"; -import ScopeTypeTarget from "../../targets/ScopeTypeTarget"; -import BaseScopeHandler from "./BaseScopeHandler"; -import { compareTargetScopes } from "./compareTargetScopes"; -import { TargetScope } from "./scope.types"; -import { ScopeIteratorRequirements } from "./scopeHandler.types"; - -/** - * Handles scopes that are implemented using tree-sitter. - */ -export class TreeSitterScopeHandler extends BaseScopeHandler { - protected isHierarchical: boolean = true; - - constructor( - private treeSitter: TreeSitter, - private query: Query, - public scopeType: SimpleScopeType, - ) { +/** Base scope handler to use for both tree-sitter scopes and their iteration scopes */ +export abstract class BaseTreeSitterScopeHandler extends BaseScopeHandler { + constructor(protected treeSitter: TreeSitter, protected query: Query) { super(); } - public get iterationScopeType(): ScopeType { - throw Error("Not implemented"); - } - *generateScopeCandidates( editor: TextEditor, position: Position, @@ -43,7 +27,12 @@ export class TreeSitterScopeHandler extends BaseScopeHandler { const { document } = editor; /** Narrow the range within which tree-sitter searches, for performance */ - const { start, end } = getQueryRange(document, position, direction, hints); + const { start, end } = getQuerySearchRange( + document, + position, + direction, + hints, + ); yield* this.query .matches( @@ -51,35 +40,23 @@ export class TreeSitterScopeHandler extends BaseScopeHandler { positionToPoint(start), positionToPoint(end), ) - .filter(({ captures }) => - captures.some((capture) => capture.name === this.scopeType.type), - ) .map((match) => this.matchToScope(editor, match)) + .filter((scope): scope is TargetScope => scope != null) .sort((a, b) => compareTargetScopes(direction, position, a, b)); } - private matchToScope(editor: TextEditor, match: QueryMatch): TargetScope { - const contentRange = getNodeRange( - match.captures.find((capture) => capture.name === this.scopeType.type)! - .node, - ); - - return { - editor, - // FIXME: Actually get domain - domain: contentRange, - getTarget: (isReversed) => - new ScopeTypeTarget({ - scopeTypeType: this.scopeType.type, - editor, - isReversed, - contentRange, - // FIXME: Actually get removalRange - removalRange: contentRange, - // FIXME: Other fields here - }), - }; - } + /** + * Convert a tree-sitter match to a scope, or undefined if the match is not + * relevant to this scope handler + * @param editor The editor in which the match was found + * @param match The match to convert to a scope + * @returns The scope, or undefined if the match is not relevant to this scope + * handler + */ + protected abstract matchToScope( + editor: TextEditor, + match: QueryMatch, + ): TargetScope | undefined; } /** @@ -91,7 +68,7 @@ export class TreeSitterScopeHandler extends BaseScopeHandler { * * @returns Range to pass to {@link Query.matches} */ -function getQueryRange( +function getQuerySearchRange( document: TextDocument, position: Position, direction: Direction, @@ -135,7 +112,3 @@ function getQueryRange( end: document.positionAt(offset - proximalShift), }; } - -function positionToPoint(start: Position): Point | undefined { - return { row: start.line, column: start.character }; -} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterIterationScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterIterationScopeHandler.ts new file mode 100644 index 0000000000..1a76a611ab --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterIterationScopeHandler.ts @@ -0,0 +1,62 @@ +import { ScopeType, SimpleScopeType, TextEditor } from "@cursorless/common"; +import { Query, QueryMatch } from "web-tree-sitter"; +import { TreeSitter } from "../../../.."; +import { PlainTarget } from "../../../targets"; +import { TargetScope } from "../scope.types"; +import { BaseTreeSitterScopeHandler } from "./BaseTreeSitterScopeHandler"; +import { getRelatedRange, getCaptureRangeByName } from "./captureUtils"; + +/** Scope handler to be used for iteration scopes of tree-sitter scope types */ +export class TreeSitterIterationScopeHandler extends BaseTreeSitterScopeHandler { + protected isHierarchical = true; + + // Doesn't correspond to any scope type + public scopeType = undefined; + + // Doesn't have any iteration scope type itself; that would correspond to + // something like "every every" + public get iterationScopeType(): ScopeType { + throw Error("Not implemented"); + } + + constructor( + treeSitter: TreeSitter, + query: Query, + /** The scope type for which we are the iteration scope */ + private iterateeScopeType: SimpleScopeType, + ) { + super(treeSitter, query); + } + + protected matchToScope( + editor: TextEditor, + match: QueryMatch, + ): TargetScope | undefined { + const scopeTypeType = this.iterateeScopeType.type; + + const contentRange = getRelatedRange(match, scopeTypeType, "iteration")!; + + if (contentRange == null) { + // This capture was for some unrelated scope type + return undefined; + } + + const domain = + getCaptureRangeByName( + match, + `${scopeTypeType}.iteration.domain`, + `_.iteration.domain`, + ) ?? contentRange; + + return { + editor, + domain, + getTarget: (isReversed) => + new PlainTarget({ + editor, + isReversed, + contentRange, + }), + }; + } +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterScopeHandler.ts new file mode 100644 index 0000000000..0d3a7338bb --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterScopeHandler.ts @@ -0,0 +1,88 @@ +import { SimpleScopeType, TextEditor } from "@cursorless/common"; + +import { Query, QueryMatch } from "web-tree-sitter"; +import { TreeSitter } from "../../../.."; +import ScopeTypeTarget from "../../../targets/ScopeTypeTarget"; +import { TargetScope } from "../scope.types"; +import { CustomScopeType } from "../scopeHandler.types"; +import { BaseTreeSitterScopeHandler } from "./BaseTreeSitterScopeHandler"; +import { TreeSitterIterationScopeHandler } from "./TreeSitterIterationScopeHandler"; +import { getCaptureRangeByName, getRelatedRange } from "./captureUtils"; + +/** + * Handles scopes that are implemented using tree-sitter. + */ +export class TreeSitterScopeHandler extends BaseTreeSitterScopeHandler { + protected isHierarchical = true; + + constructor( + treeSitter: TreeSitter, + query: Query, + public scopeType: SimpleScopeType, + ) { + super(treeSitter, query); + } + + // We just create a custom scope handler that doesn't necessarily correspond + // to any well-defined scope type + public get iterationScopeType(): CustomScopeType { + return { + type: "custom", + scopeHandler: new TreeSitterIterationScopeHandler( + this.treeSitter, + this.query, + this.scopeType, + ), + }; + } + + protected matchToScope( + editor: TextEditor, + match: QueryMatch, + ): TargetScope | undefined { + const scopeTypeType = this.scopeType.type; + + const contentRange = getCaptureRangeByName(match, scopeTypeType); + + if (contentRange == null) { + // This capture was for some unrelated scope type + return undefined; + } + + const domain = + getRelatedRange(match, scopeTypeType, "domain") ?? contentRange; + + const removalRange = getRelatedRange(match, scopeTypeType, "removal"); + + const leadingDelimiterRange = getRelatedRange( + match, + scopeTypeType, + "leading", + ); + + const trailingDelimiterRange = getRelatedRange( + match, + scopeTypeType, + "trailing", + ); + + const interiorRange = getRelatedRange(match, scopeTypeType, "interior"); + + return { + editor, + domain, + getTarget: (isReversed) => + new ScopeTypeTarget({ + scopeTypeType, + editor, + isReversed, + contentRange, + removalRange, + leadingDelimiterRange, + trailingDelimiterRange, + interiorRange, + // FIXME: Add delimiter text + }), + }; + } +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/captureUtils.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/captureUtils.ts new file mode 100644 index 0000000000..2a72ac6e49 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/captureUtils.ts @@ -0,0 +1,45 @@ +import { Position } from "@cursorless/common"; +import { Point, QueryMatch } from "web-tree-sitter"; +import { getNodeRange } from "../../../../util/nodeSelectors"; + +/** + * Gets the range of a node that is related to the scope. For example, if the + * scope is "class name", the `domain` node would be the containing class. + * + * @param match The match to get the range from + * @param scopeTypeType The type of the scope + * @param relationship The relationship to get the range for, eg "domain", or "removal" + * @returns A range or undefined if no range was found + */ + +export function getRelatedRange( + match: QueryMatch, + scopeTypeType: string, + relationship: string, +) { + return getCaptureRangeByName( + match, + `${scopeTypeType}.${relationship}`, + `_.${relationship}`, + ); +} + +/** + * Looks in the captures of a match for a capture with one of the given names, and + * returns the range of that capture, or undefined if no matching capture was found + * + * @param match The match to get the range from + * @param names The possible names of the capture to get the range for + * @returns A range or undefined if no matching capture was found + */ +export function getCaptureRangeByName(match: QueryMatch, ...names: string[]) { + const relatedNode = match.captures.find((capture) => + names.some((name) => capture.name === name), + )?.node; + + return relatedNode == null ? undefined : getNodeRange(relatedNode); +} + +export function positionToPoint(start: Position): Point | undefined { + return { row: start.line, column: start.character }; +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/index.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/index.ts new file mode 100644 index 0000000000..6da17ca3f6 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/index.ts @@ -0,0 +1 @@ +export * from "./TreeSitterScopeHandler"; diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts index a827edada0..d53317430a 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts @@ -2,6 +2,16 @@ import type { Position, TextEditor } from "@cursorless/common"; import type { Direction, ScopeType } from "@cursorless/common"; import type { TargetScope } from "./scope.types"; +/** + * Used to handle a scope internally that doesn't have a well-defined scope + * type. Primarily used for iteration scopes, where the iteration scope doesn't + * correspond to a scope type that can be used directly by the user as a scope. + */ +export interface CustomScopeType { + type: "custom"; + scopeHandler: ScopeHandler; +} + /** * Represents a scope type. The functions in this interface allow us to find * specific instances of the given scope type in a document. These functions are @@ -24,16 +34,17 @@ import type { TargetScope } from "./scope.types"; */ export interface ScopeHandler { /** - * The scope type handled by this scope handler + * The scope type handled by this scope handler, or `undefined` if this scope + * handler doesn't have a well-defined scope type. */ - readonly scopeType: ScopeType; + readonly scopeType: ScopeType | undefined; /** * The scope type of the default iteration scope of this scope type. This * scope type will be used when the input target has no explicit range (ie * {@link Target.hasExplicitRange} is `false`). */ - readonly iterationScopeType: ScopeType; + readonly iterationScopeType: ScopeType | CustomScopeType; /** * Returns an iterable of scopes meeting the requirements in diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/languages/ruby/clearEveryFunk.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/languages/ruby/clearEveryFunk.yml new file mode 100644 index 0000000000..9ade6f3428 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/languages/ruby/clearEveryFunk.yml @@ -0,0 +1,27 @@ +languageId: ruby +command: + version: 5 + spokenForm: clear every funk + action: {name: clearAndSetSelection} + targets: + - type: primitive + modifiers: + - type: everyScope + scopeType: {type: namedFunction} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + def aaa; end + def bbb; end + selections: + - anchor: {line: 1, character: 12} + active: {line: 1, character: 12} + marks: {} +finalState: + documentContents: |+ + + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/queryBasedMatchers/clearEveryClassName.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/queryBasedMatchers/clearEveryClassName.yml new file mode 100644 index 0000000000..86c40c763b --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/queryBasedMatchers/clearEveryClassName.yml @@ -0,0 +1,28 @@ +languageId: ruby +command: + version: 5 + spokenForm: clear every class name + action: {name: clearAndSetSelection} + targets: + - type: primitive + modifiers: + - type: everyScope + scopeType: {type: className} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa; end + class Bbb; end + selections: + - anchor: {line: 1, character: 14} + active: {line: 1, character: 14} + marks: {} +finalState: + documentContents: |- + class ; end + class ; end + selections: + - anchor: {line: 0, character: 6} + active: {line: 0, character: 6} + - anchor: {line: 1, character: 6} + active: {line: 1, character: 6} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/queryBasedMatchers/clearEveryFunk.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/queryBasedMatchers/clearEveryFunk.yml new file mode 100644 index 0000000000..0412b15702 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/queryBasedMatchers/clearEveryFunk.yml @@ -0,0 +1,38 @@ +languageId: ruby +command: + version: 5 + spokenForm: clear every funk + action: {name: clearAndSetSelection} + targets: + - type: primitive + modifiers: + - type: everyScope + scopeType: {type: namedFunction} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Sample + def function + statement + end + + def function + statement + end + end + selections: + - anchor: {line: 8, character: 4} + active: {line: 8, character: 4} + marks: {} +finalState: + documentContents: |- + class Sample + + + + end + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + - anchor: {line: 3, character: 4} + active: {line: 3, character: 4} diff --git a/queries/ruby.scm b/queries/ruby.scm index 19773d755b..d3d066cfd2 100644 --- a/queries/ruby.scm +++ b/queries/ruby.scm @@ -1,8 +1,7 @@ (comment) @comment -(if) @ifStatement -(call) @functionCall -[(method) (singleton_method)] @namedFunction (hash) @map +(regex) @regularExpression +(call) @functionCall [ (array) @@ -10,6 +9,21 @@ (symbol_array) ] @list -(regex) @regularExpression +(_ + (if) @ifStatement +) @_.iteration + + +(_ + [(method) (singleton_method)] @namedFunction +) @_.iteration + +(_ + (class) @class +) @_.iteration -(class) @class +(_ + (class + name: (_) @className + ) @_.domain +) @_.iteration