diff --git a/cursorless-talon/src/actions/wrap.py b/cursorless-talon/src/actions/wrap.py index 5ad75b6f1..75856121d 100644 --- a/cursorless-talon/src/actions/wrap.py +++ b/cursorless-talon/src/actions/wrap.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Literal +from typing import Literal, Union from talon import Module, actions @@ -9,7 +9,7 @@ from ..paired_delimiter import paired_delimiters_map @dataclass class Wrapper: type: Literal["pairedDelimiter", "snippet"] - extra_args: list[str] + extra_args: list[Union[str, dict]] mod = Module() @@ -30,7 +30,26 @@ def cursorless_wrapper(m) -> Wrapper: extra_args=[paired_delimiter_info.left, paired_delimiter_info.right], ) except AttributeError: - return Wrapper(type="snippet", extra_args=[m.cursorless_wrapper_snippet]) + snippet_name, variable_name = parse_snippet_location( + m.cursorless_wrapper_snippet + ) + return Wrapper( + type="snippet", + extra_args=[ + { + "type": "named", + "name": snippet_name, + "variableName": variable_name, + } + ], + ) + + +def parse_snippet_location(snippet_location: str) -> tuple[str, str]: + [snippet_name, variable_name] = snippet_location.split(".") + if snippet_name is None or variable_name is None: + raise Exception("Snippet location missing '.'") + return (snippet_name, variable_name) # Maps from (action_type, wrapper_type) to action name diff --git a/cursorless-talon/src/command.py b/cursorless-talon/src/command.py index 2d26c9833..8efb045e1 100644 --- a/cursorless-talon/src/command.py +++ b/cursorless-talon/src/command.py @@ -12,7 +12,7 @@ from .primitive_target import IMPLICIT_TARGET mod = Module() CURSORLESS_COMMAND_ID = "cursorless.command" -CURSORLESS_COMMAND_VERSION = 4 +CURSORLESS_COMMAND_VERSION = 5 last_phrase = None diff --git a/cursorless-talon/src/cursorless_snippets.talon b/cursorless-talon/src/cursorless_snippets.talon index f182cb5b9..c9e428680 100644 --- a/cursorless-talon/src/cursorless_snippets.talon +++ b/cursorless-talon/src/cursorless_snippets.talon @@ -8,4 +8,4 @@ tag: user.cursorless user.cursorless_single_target_command(cursorless_insert_snippet_action, cursorless_positional_target, cursorless_insertion_snippet) {user.cursorless_insert_snippet_action} {user.cursorless_insertion_snippet_single_phrase} [{user.cursorless_phrase_terminator}]: - user.cursorless_insert_snippet_with_phrase(cursorless_insert_snippet_action, cursorless_insertion_snippet_single_phrase, text) + user.private_cursorless_insert_snippet_with_phrase(cursorless_insert_snippet_action, cursorless_insertion_snippet_single_phrase, text) diff --git a/cursorless-talon/src/snippets.py b/cursorless-talon/src/snippets.py index c83ecbb4c..a9f100f58 100644 --- a/cursorless-talon/src/snippets.py +++ b/cursorless-talon/src/snippets.py @@ -1,3 +1,5 @@ +from typing import Any, Optional + from talon import Module, actions, app from .csv_overrides import init_csv_and_watch_changes @@ -27,13 +29,13 @@ mod.list("cursorless_phrase_terminator", "Contains term used to terminate a phra @mod.capture( rule="{user.cursorless_insertion_snippet_no_phrase} | {user.cursorless_insertion_snippet_single_phrase}" ) -def cursorless_insertion_snippet(m) -> str: +def cursorless_insertion_snippet(m) -> dict: try: - return m.cursorless_insertion_snippet_no_phrase + name = m.cursorless_insertion_snippet_no_phrase except AttributeError: - pass + name = m.cursorless_insertion_snippet_single_phrase.split(".")[0] - return m.cursorless_insertion_snippet_single_phrase.split(".")[0] + return {"type": "named", "name": name} # NOTE: Please do not change these dicts. Use the CSVs for customization. @@ -65,13 +67,76 @@ insertion_snippets_single_phrase = { @mod.action_class class Actions: - def cursorless_insert_snippet_with_phrase( + def private_cursorless_insert_snippet_with_phrase( action: str, snippet_description: str, text: str ): """Perform cursorless wrap action""" snippet_name, snippet_variable = snippet_description.split(".") actions.user.cursorless_implicit_target_command( - action, snippet_name, {snippet_variable: text} + action, + { + "type": "named", + "name": snippet_name, + "substitutions": {snippet_variable: text}, + }, + ) + + def cursorless_insert_snippet_by_name(name: str): + """Inserts a named snippet""" + actions.user.cursorless_implicit_target_command( + "insertSnippet", + { + "type": "named", + "name": name, + }, + ) + + def cursorless_insert_snippet(body: str): + """Inserts a custom snippet""" + actions.user.cursorless_implicit_target_command( + "insertSnippet", + { + "type": "custom", + "body": body, + }, + ) + + def cursorless_wrap_with_snippet_by_name( + name: str, variable_name: str, target: dict + ): + """Wrap target with a named snippet""" + actions.user.cursorless_single_target_command_with_arg_list( + "wrapWithSnippet", + target, + [ + { + "type": "named", + "name": name, + "variableName": variable_name, + } + ], + ) + + def cursorless_wrap_with_snippet( + body: str, + target: dict, + variable_name: Optional[str] = None, + scope: Optional[str] = None, + ): + """Wrap target with a custom snippet""" + snippet_arg: dict[str, Any] = { + "type": "custom", + "body": body, + } + if scope is not None: + snippet_arg["scopeType"] = {"type": scope} + if variable_name is not None: + snippet_arg["variableName"] = variable_name + + actions.user.cursorless_single_target_command_with_arg_list( + "wrapWithSnippet", + target, + [snippet_arg], ) diff --git a/docs/user/customization.md b/docs/user/customization.md index 0474f9dea..df0d45de3 100644 --- a/docs/user/customization.md +++ b/docs/user/customization.md @@ -119,6 +119,15 @@ Cursorless exposes a couple talon actions and captures that you can use to defin Performs a built-in IDE command on the given target eg: `user.cursorless_ide_command("editor.action.addCommentLine", cursorless_target)` +#### Snippet actions + +See [snippets](./experimental/snippets.md) for more information about Cursorless snippets. + +- `user.cursorless_insert_snippet_by_name(name: str)`: Insert a snippet with the given name, eg `functionDeclaration` +- `user.cursorless_insert_snippet(body: str)`: Insert a snippet with the given body defined using our snippet body syntax (see the [snippet format docs](./experimental/snippet-format.md)). The body should be a single string, which could contain newline `\n` characters, rather than a list of strings as is expected in our snippet json representation. +- `user.cursorless_wrap_with_snippet_by_name(name: str, variable_name: str, target: dict)`: Wrap the given target with a snippet with the given name, eg `functionDeclaration`. Note that `variable_name` should be one of the variables defined in the named snippet. Eg, if the named snippet has a variable `$foo`, you can pass in `"foo"` for `variable_name`, and `target` will be inserted into the position of `$foo` in the given named snippet. +- `user.cursorless_wrap_with_snippet(body, target, variable_name, scope)`: Wrap the given target with a snippet with the given body defined using our snippet body syntax (see the [snippet format docs](./experimental/snippet-format.md)). The body should be a single string, which could contain newline `\n` characters, rather than a list of strings as is expected in our snippet json representation. Note that `variable_name` should be one of the variables defined in `body`. Eg, if `body` has a variable `$foo`, you can pass in `"foo"` for `variable_name`, and `target` will be inserted into the position of `$foo` in the given named snippet. The `scope` variable can be used to automatically expand the target to the given scope type, eg `"line"`. + ### Example of combining capture and action ```talon diff --git a/docs/user/experimental/snippet-format.md b/docs/user/experimental/snippet-format.md index e5d575f15..7539881bc 100644 --- a/docs/user/experimental/snippet-format.md +++ b/docs/user/experimental/snippet-format.md @@ -3,3 +3,5 @@ Cursorless has experimental support for snippets. Currently these snippets are just used for wrapping targets. The best place to start is to look at the [core cursorless snippets](../../../cursorless-snippets). Additionally, there is autocomplete with documentation as you're writing a snippet. + +Note that for `body`, we support [the full textmate syntax supported by VSCode](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax), but we prefer to use variable names (eg `$foo`) instead of placeholders (eg `$1`) so that it is easy to use snippets for wrapping. diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 085b97576..c17d8db54 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -69,8 +69,9 @@ export * from "./types/command/ActionCommand"; export * from "./types/command/legacy/CommandV0V1.types"; export * from "./types/command/legacy/CommandV2.types"; export * from "./types/command/legacy/CommandV3.types"; +export * from "./types/command/legacy/CommandV4.types"; export * from "./types/command/legacy/targetDescriptorV2.types"; -export * from "./types/command/CommandV4.types"; +export * from "./types/command/CommandV5.types"; export * from "./types/command/legacy/PartialTargetDescriptorV3.types"; export * from "./types/CommandServerApi"; export * from "./util/itertools"; diff --git a/packages/common/src/types/command/CommandV4.types.ts b/packages/common/src/types/command/CommandV5.types.ts similarity index 94% rename from packages/common/src/types/command/CommandV4.types.ts rename to packages/common/src/types/command/CommandV5.types.ts index c50e08c35..33bd6c2d7 100644 --- a/packages/common/src/types/command/CommandV4.types.ts +++ b/packages/common/src/types/command/CommandV5.types.ts @@ -1,11 +1,11 @@ import type { PartialTargetDescriptor } from "./PartialTargetDescriptor.types"; import type { ActionCommand } from "./ActionCommand"; -export interface CommandV4 { +export interface CommandV5 { /** * The version number of the command API */ - version: 4; + version: 5; /** * The spoken form of the command if issued from a voice command system diff --git a/packages/common/src/types/command/command.types.ts b/packages/common/src/types/command/command.types.ts index 4c01f6c40..665a9baca 100644 --- a/packages/common/src/types/command/command.types.ts +++ b/packages/common/src/types/command/command.types.ts @@ -1,16 +1,23 @@ import type { ActionCommand } from "./ActionCommand"; +import type { CommandV5 } from "./CommandV5.types"; import type { CommandV0, CommandV1 } from "./legacy/CommandV0V1.types"; import type { CommandV2 } from "./legacy/CommandV2.types"; import type { CommandV3 } from "./legacy/CommandV3.types"; -import type { CommandV4 } from "./CommandV4.types"; +import type { CommandV4 } from "./legacy/CommandV4.types"; export type CommandComplete = Required> & Pick & { action: Required }; -export const LATEST_VERSION = 4 as const; +export const LATEST_VERSION = 5 as const; export type CommandLatest = Command & { version: typeof LATEST_VERSION; }; -export type Command = CommandV0 | CommandV1 | CommandV2 | CommandV3 | CommandV4; +export type Command = + | CommandV0 + | CommandV1 + | CommandV2 + | CommandV3 + | CommandV4 + | CommandV5; diff --git a/packages/common/src/types/command/legacy/CommandV4.types.ts b/packages/common/src/types/command/legacy/CommandV4.types.ts new file mode 100644 index 000000000..31d839e76 --- /dev/null +++ b/packages/common/src/types/command/legacy/CommandV4.types.ts @@ -0,0 +1,95 @@ +import { PartialTargetDescriptorV4 } from "./PartialTargetDescriptorV4.types"; + +type ActionType = + | "callAsFunction" + | "clearAndSetSelection" + | "copyToClipboard" + | "cutToClipboard" + | "deselect" + | "editNew" + | "editNewLineAfter" + | "editNewLineBefore" + | "executeCommand" + | "extractVariable" + | "findInWorkspace" + | "foldRegion" + | "followLink" + | "generateSnippet" + | "getText" + | "highlight" + | "indentLine" + | "insertCopyAfter" + | "insertCopyBefore" + | "insertEmptyLineAfter" + | "insertEmptyLineBefore" + | "insertEmptyLinesAround" + | "insertSnippet" + | "moveToTarget" + | "outdentLine" + | "pasteFromClipboard" + | "randomizeTargets" + | "remove" + | "rename" + | "replace" + | "replaceWithTarget" + | "revealDefinition" + | "revealTypeDefinition" + | "reverseTargets" + | "rewrapWithPairedDelimiter" + | "scrollToBottom" + | "scrollToCenter" + | "scrollToTop" + | "setSelection" + | "setSelectionAfter" + | "setSelectionBefore" + | "showDebugHover" + | "showHover" + | "showQuickFix" + | "showReferences" + | "sortTargets" + | "swapTargets" + | "toggleLineBreakpoint" + | "toggleLineComment" + | "unfoldRegion" + | "wrapWithPairedDelimiter" + | "wrapWithSnippet"; + +export interface ActionCommandV4 { + /** + * The action to run + */ + name: ActionType; + + /** + * A list of arguments expected by the given action. + */ + args?: unknown[]; +} + +export interface CommandV4 { + /** + * The version number of the command API + */ + version: 4; + + /** + * The spoken form of the command if issued from a voice command system + */ + spokenForm?: string; + + /** + * If the command is issued from a voice command system, this boolean indicates + * whether we should use the pre phrase snapshot. Only set this to true if the + * voice command system issues a pre phrase signal at the start of every + * phrase. + */ + usePrePhraseSnapshot: boolean; + + action: ActionCommandV4; + + /** + * A list of targets expected by the action. Inference will be run on the + * targets + */ + targets: PartialTargetDescriptorV4[]; +} diff --git a/packages/common/src/types/command/legacy/PartialTargetDescriptorV4.types.ts b/packages/common/src/types/command/legacy/PartialTargetDescriptorV4.types.ts new file mode 100644 index 000000000..79a941c10 --- /dev/null +++ b/packages/common/src/types/command/legacy/PartialTargetDescriptorV4.types.ts @@ -0,0 +1,351 @@ +interface CursorMark { + type: "cursor"; +} + +interface ThatMark { + type: "that"; +} + +interface SourceMark { + type: "source"; +} + +interface NothingMark { + type: "nothing"; +} + +interface DecoratedSymbolMark { + type: "decoratedSymbol"; + symbolColor: string; + character: string; +} + +type LineNumberType = "absolute" | "relative" | "modulo100"; + +interface LineNumberMark { + type: "lineNumber"; + lineNumberType: LineNumberType; + lineNumber: number; +} + +/** + * Constructs a range between {@link anchor} and {@link active} + */ +interface RangeMark { + type: "range"; + anchor: Mark; + active: Mark; + excludeAnchor?: boolean; + excludeActive?: boolean; +} + +type Mark = + | CursorMark + | ThatMark + | SourceMark + | DecoratedSymbolMark + | NothingMark + | LineNumberMark + | RangeMark; + +type SimpleSurroundingPairName = + | "angleBrackets" + | "backtickQuotes" + | "curlyBrackets" + | "doubleQuotes" + | "escapedDoubleQuotes" + | "escapedParentheses" + | "escapedSquareBrackets" + | "escapedSingleQuotes" + | "parentheses" + | "singleQuotes" + | "squareBrackets"; +type ComplexSurroundingPairName = "string" | "any" | "collectionBoundary"; +type SurroundingPairName = + | SimpleSurroundingPairName + | ComplexSurroundingPairName; + +type SimpleScopeTypeType = + | "argumentOrParameter" + | "anonymousFunction" + | "attribute" + | "branch" + | "class" + | "className" + | "collectionItem" + | "collectionKey" + | "comment" + | "functionCall" + | "functionCallee" + | "functionName" + | "ifStatement" + | "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" + // Text based scopes + | "token" + | "line" + | "notebookCell" + | "paragraph" + | "document" + | "character" + | "word" + | "identifier" + | "nonWhitespaceSequence" + | "boundedNonWhitespaceSequence" + | "url"; + +interface SimpleScopeType { + type: SimpleScopeTypeType; +} + +interface CustomRegexScopeType { + type: "customRegex"; + regex: string; +} + +type SurroundingPairDirection = "left" | "right"; +interface SurroundingPairScopeType { + type: "surroundingPair"; + delimiter: SurroundingPairName; + forceDirection?: SurroundingPairDirection; + + /** + * If `true`, then only accept pairs where the pair completely contains the + * selection, ie without the edges touching. + */ + requireStrongContainment?: boolean; +} + +interface OneOfScopeType { + type: "oneOf"; + scopeTypes: ScopeType[]; +} + +type ScopeType = + | SimpleScopeType + | SurroundingPairScopeType + | CustomRegexScopeType + | OneOfScopeType; + +interface InteriorOnlyModifier { + type: "interiorOnly"; +} + +interface ExcludeInteriorModifier { + type: "excludeInterior"; +} + +interface ContainingScopeModifier { + type: "containingScope"; + scopeType: ScopeType; + ancestorIndex?: number; +} + +interface EveryScopeModifier { + type: "everyScope"; + scopeType: ScopeType; +} + +/** + * Refer to scopes by absolute index relative to iteration scope, eg "first + * funk" to refer to the first function in a class. + */ +interface OrdinalScopeModifier { + type: "ordinalScope"; + + scopeType: ScopeType; + + /** The start of the range. Start from end of iteration scope if `start` is negative */ + start: number; + + /** The number of scopes to include. Will always be positive. If greater than 1, will include scopes after {@link start} */ + length: number; +} + +type Direction = "forward" | "backward"; + +/** + * Refer to scopes by offset relative to input target, eg "next + * funk" to refer to the first function after the function containing the target input. + */ +interface RelativeScopeModifier { + type: "relativeScope"; + + scopeType: ScopeType; + + /** Indicates how many scopes away to start relative to the input target. + * Note that if {@link direction} is `"backward"`, then this scope will be the + * end of the output range. */ + offset: number; + + /** The number of scopes to include. Will always be positive. If greater + * than 1, will include scopes in the direction of {@link direction} */ + length: number; + + /** Indicates which direction both {@link offset} and {@link length} go + * relative to input target */ + direction: Direction; +} + +/** + * Converts its input to a raw selection with no type information so for + * example if it is the destination of a bring or move it should inherit the + * type information such as delimiters from its source. + */ +interface RawSelectionModifier { + type: "toRawSelection"; +} + +interface LeadingModifier { + type: "leading"; +} + +interface TrailingModifier { + type: "trailing"; +} + +interface KeepContentFilterModifier { + type: "keepContentFilter"; +} + +interface KeepEmptyFilterModifier { + type: "keepEmptyFilter"; +} + +interface InferPreviousMarkModifier { + type: "inferPreviousMark"; +} + +type TargetPosition = "before" | "after" | "start" | "end"; + +interface PositionModifier { + type: "position"; + position: TargetPosition; +} + +interface PartialPrimitiveTargetDescriptor { + type: "primitive"; + mark?: Mark; + modifiers?: Modifier[]; +} + +interface HeadTailModifier { + type: "extendThroughStartOf" | "extendThroughEndOf"; + modifiers?: Modifier[]; +} + +/** + * Runs {@link modifier} if the target has no explicit scope type, ie if + * {@link Target.hasExplicitScopeType} is `false`. + */ +interface ModifyIfUntypedModifier { + type: "modifyIfUntyped"; + + /** + * The modifier to apply if the target is untyped + */ + modifier: Modifier; +} + +/** + * Tries each of the modifiers in {@link modifiers} in turn until one of them + * doesn't throw an error, returning the output from the first modifier not + * throwing an error. + */ +interface CascadingModifier { + type: "cascading"; + + /** + * The modifiers to try in turn + */ + modifiers: Modifier[]; +} + +/** + * First applies {@link anchor} to input, then independently applies + * {@link active}, and forms a range between the two resulting targets + */ +interface RangeModifier { + type: "range"; + anchor: Modifier; + active: Modifier; + excludeAnchor?: boolean; + excludeActive?: boolean; +} + +type Modifier = + | PositionModifier + | InteriorOnlyModifier + | ExcludeInteriorModifier + | ContainingScopeModifier + | EveryScopeModifier + | OrdinalScopeModifier + | RelativeScopeModifier + | HeadTailModifier + | LeadingModifier + | TrailingModifier + | RawSelectionModifier + | ModifyIfUntypedModifier + | CascadingModifier + | RangeModifier + | KeepContentFilterModifier + | KeepEmptyFilterModifier + | InferPreviousMarkModifier; + +// continuous is one single continuous selection between the two targets +// vertical puts a selection on each line vertically between the two targets +type RangeType = "continuous" | "vertical"; + +interface PartialRangeTargetDescriptor { + type: "range"; + anchor: PartialPrimitiveTargetDescriptor | ImplicitTargetDescriptor; + active: PartialPrimitiveTargetDescriptor; + excludeAnchor: boolean; + excludeActive: boolean; + rangeType?: RangeType; +} + +interface PartialListTargetDescriptor { + type: "list"; + elements: (PartialPrimitiveTargetDescriptor | PartialRangeTargetDescriptor)[]; +} + +interface ImplicitTargetDescriptor { + type: "implicit"; +} + +export type PartialTargetDescriptorV4 = + | PartialPrimitiveTargetDescriptor + | PartialRangeTargetDescriptor + | PartialListTargetDescriptor + | ImplicitTargetDescriptor; diff --git a/packages/cursorless-engine/src/actions/InsertSnippet.ts b/packages/cursorless-engine/src/actions/InsertSnippet.ts index 24013b4bd..4ccaee5d6 100644 --- a/packages/cursorless-engine/src/actions/InsertSnippet.ts +++ b/packages/cursorless-engine/src/actions/InsertSnippet.ts @@ -1,5 +1,6 @@ import { RangeExpansionBehavior, + ScopeType, Snippet, SnippetDefinition, textFormatters, @@ -20,57 +21,113 @@ import { Target } from "../typings/target.types"; import { ensureSingleEditor } from "../util/targetUtils"; import { Action, ActionReturnValue } from "./actions.types"; +interface NamedSnippetArg { + type: "named"; + name: string; + substitutions?: Record; +} +interface CustomSnippetArg { + type: "custom"; + body: string; + scopeType?: ScopeType; + substitutions?: Record; +} +type InsertSnippetArg = NamedSnippetArg | CustomSnippetArg; + export default class InsertSnippet implements Action { private snippetParser = new SnippetParser(); - getPrePositionStages(snippetName: string) { - const snippet = this.graph.snippets.getSnippetStrict(snippetName); - - const defaultScopeTypes = snippet.insertionScopeTypes; - - if (defaultScopeTypes == null) { - return []; - } - - return [ - new ModifyIfUntypedExplicitStage({ - type: "cascading", - modifiers: defaultScopeTypes.map((scopeType) => ({ - type: "containingScope", - scopeType: { - type: scopeType, - }, - })), - }), - ]; - } - constructor(private graph: Graph) { this.run = this.run.bind(this); } + getPrePositionStages(snippetDescription: InsertSnippetArg) { + const defaultScopeTypes = this.getScopeTypes(snippetDescription); + + return defaultScopeTypes.length === 0 + ? [] + : [ + new ModifyIfUntypedExplicitStage({ + type: "cascading", + modifiers: defaultScopeTypes.map((scopeType) => ({ + type: "containingScope", + scopeType, + })), + }), + ]; + } + + private getScopeTypes(snippetDescription: InsertSnippetArg): ScopeType[] { + if (snippetDescription.type === "named") { + const { name } = snippetDescription; + + const snippet = this.graph.snippets.getSnippetStrict(name); + + const scopeTypeTypes = snippet.insertionScopeTypes; + return scopeTypeTypes == null + ? [] + : scopeTypeTypes.map((scopeTypeType) => ({ + type: scopeTypeType, + })); + } else { + return snippetDescription.scopeType == null + ? [] + : [snippetDescription.scopeType]; + } + } + + private getSnippetInfo( + snippetDescription: InsertSnippetArg, + targets: Target[], + ) { + if (snippetDescription.type === "named") { + const { name } = snippetDescription; + + const snippet = this.graph.snippets.getSnippetStrict(name); + + const definition = findMatchingSnippetDefinitionStrict( + targets, + snippet.definitions, + ); + + return { + body: definition.body.join("\n"), + + formatSubstitutions(substitutions: Record | undefined) { + return substitutions == null + ? undefined + : formatSubstitutions(snippet, definition, substitutions); + }, + }; + } else { + return { + body: snippetDescription.body, + + formatSubstitutions(substitutions: Record | undefined) { + return substitutions; + }, + }; + } + } + async run( [targets]: [Target[]], - snippetName: string, - substitutions: Record, + snippetDescription: InsertSnippetArg, ): Promise { - const snippet = this.graph.snippets.getSnippetStrict(snippetName); - const editor = ide().getEditableTextEditor(ensureSingleEditor(targets)); - const definition = findMatchingSnippetDefinitionStrict( + const { body, formatSubstitutions } = this.getSnippetInfo( + snippetDescription, targets, - snippet.definitions, ); - const parsedSnippet = this.snippetParser.parse(definition.body.join("\n")); + const parsedSnippet = this.snippetParser.parse(body); - const formattedSubstitutions = - substitutions == null - ? undefined - : formatSubstitutions(snippet, definition, substitutions); - - transformSnippetVariables(parsedSnippet, null, formattedSubstitutions); + transformSnippetVariables( + parsedSnippet, + null, + formatSubstitutions(snippetDescription.substitutions), + ); const snippetString = parsedSnippet.toTextmateString(); diff --git a/packages/cursorless-engine/src/actions/WrapWithSnippet.ts b/packages/cursorless-engine/src/actions/WrapWithSnippet.ts index 43dddcca3..e6307b664 100644 --- a/packages/cursorless-engine/src/actions/WrapWithSnippet.ts +++ b/packages/cursorless-engine/src/actions/WrapWithSnippet.ts @@ -1,5 +1,6 @@ -import { FlashStyle } from "@cursorless/common"; +import { FlashStyle, ScopeType } from "@cursorless/common"; import { callFunctionAndUpdateSelections } from "../core/updateSelections/updateSelections"; +import { Graph } from "../index"; import { ModifyIfUntypedStage } from "../processTargets/modifiers/ConditionalModifierStages"; import { ide } from "../singletons/ide.singleton"; import { @@ -8,21 +9,31 @@ import { } from "../snippets/snippet"; import { SnippetParser } from "../snippets/vendor/vscodeSnippet/snippetParser"; import { Target } from "../typings/target.types"; -import { Graph } from "../typings/Graph"; import { ensureSingleEditor, flashTargets } from "../util/targetUtils"; import { Action, ActionReturnValue } from "./actions.types"; +interface NamedSnippetArg { + type: "named"; + name: string; + variableName: string; +} +interface CustomSnippetArg { + type: "custom"; + body: string; + variableName?: string; + scopeType?: ScopeType; +} +type WrapWithSnippetArg = NamedSnippetArg | CustomSnippetArg; + export default class WrapWithSnippet implements Action { private snippetParser = new SnippetParser(); - getFinalStages(snippetLocation: string) { - const [snippetName, placeholderName] = - parseSnippetLocation(snippetLocation); + constructor(private graph: Graph) { + this.run = this.run.bind(this); + } - const snippet = this.graph.snippets.getSnippetStrict(snippetName); - - const variables = snippet.variables ?? {}; - const defaultScopeType = variables[placeholderName]?.wrapperScopeType; + getFinalStages(snippet: WrapWithSnippetArg) { + const defaultScopeType = this.getScopeType(snippet); if (defaultScopeType == null) { return []; @@ -33,37 +44,63 @@ export default class WrapWithSnippet implements Action { type: "modifyIfUntyped", modifier: { type: "containingScope", - scopeType: { - type: defaultScopeType, - }, + scopeType: defaultScopeType, }, }), ]; } - constructor(private graph: Graph) { - this.run = this.run.bind(this); + private getScopeType( + snippetDescription: WrapWithSnippetArg, + ): ScopeType | undefined { + if (snippetDescription.type === "named") { + const { name, variableName } = snippetDescription; + + const snippet = this.graph.snippets.getSnippetStrict(name); + + const variables = snippet.variables ?? {}; + const scopeTypeType = variables[variableName]?.wrapperScopeType; + return scopeTypeType == null + ? undefined + : { + type: scopeTypeType, + }; + } else { + return snippetDescription.scopeType; + } + } + + private getBody( + snippetDescription: WrapWithSnippetArg, + targets: Target[], + ): string { + if (snippetDescription.type === "named") { + const { name } = snippetDescription; + + const snippet = this.graph.snippets.getSnippetStrict(name); + + const definition = findMatchingSnippetDefinitionStrict( + targets, + snippet.definitions, + ); + + return definition.body.join("\n"); + } else { + return snippetDescription.body; + } } async run( [targets]: [Target[]], - snippetLocation: string, + snippetDescription: WrapWithSnippetArg, ): Promise { - const [snippetName, placeholderName] = - parseSnippetLocation(snippetLocation); - - const snippet = this.graph.snippets.getSnippetStrict(snippetName); - const editor = ide().getEditableTextEditor(ensureSingleEditor(targets)); - const definition = findMatchingSnippetDefinitionStrict( - targets, - snippet.definitions, - ); + const body = this.getBody(snippetDescription, targets); - const parsedSnippet = this.snippetParser.parse(definition.body.join("\n")); + const parsedSnippet = this.snippetParser.parse(body); - transformSnippetVariables(parsedSnippet, placeholderName); + transformSnippetVariables(parsedSnippet, snippetDescription.variableName); const snippetString = parsedSnippet.toTextmateString(); @@ -88,11 +125,3 @@ export default class WrapWithSnippet implements Action { }; } } - -function parseSnippetLocation(snippetLocation: string): [string, string] { - const [snippetName, placeholderName] = snippetLocation.split("."); - if (snippetName == null || placeholderName == null) { - throw new Error("Snippet location missing '.'"); - } - return [snippetName, placeholderName]; -} diff --git a/packages/cursorless-engine/src/core/commandVersionUpgrades/canonicalizeAndValidateCommand.ts b/packages/cursorless-engine/src/core/commandVersionUpgrades/canonicalizeAndValidateCommand.ts index 707567df6..8232ad1fa 100644 --- a/packages/cursorless-engine/src/core/commandVersionUpgrades/canonicalizeAndValidateCommand.ts +++ b/packages/cursorless-engine/src/core/commandVersionUpgrades/canonicalizeAndValidateCommand.ts @@ -1,29 +1,26 @@ import { + ActionType, + Command, + CommandComplete, + CommandLatest, EnforceUndefined, + LATEST_VERSION, + Modifier, OutdatedExtensionError, + PartialTargetDescriptor, showWarning, + SimpleScopeTypeType, } from "@cursorless/common"; import { ide } from "../../singletons/ide.singleton"; import { Graph } from "../../typings/Graph"; import { getPartialPrimitiveTargets } from "../../util/getPrimitiveTargets"; -import { ActionType } from "@cursorless/common"; -import { - Command, - CommandComplete, - CommandLatest, - LATEST_VERSION, -} from "@cursorless/common"; -import { - Modifier, - PartialTargetDescriptor, - SimpleScopeTypeType, -} from "@cursorless/common"; import canonicalizeActionName from "./canonicalizeActionName"; import canonicalizeTargets from "./canonicalizeTargets"; import { upgradeV0ToV1 } from "./upgradeV0ToV1"; import { upgradeV1ToV2 } from "./upgradeV1ToV2"; import { upgradeV2ToV3 } from "./upgradeV2ToV3"; import { upgradeV3ToV4 } from "./upgradeV3ToV4"; +import { upgradeV4ToV5 } from "./upgradeV4ToV5/upgradeV4ToV5"; /** * Given a command argument which comes from the client, normalize it so that it @@ -79,6 +76,9 @@ function upgradeCommand(command: Command): CommandLatest { case 3: command = upgradeV3ToV4(command); break; + case 4: + command = upgradeV4ToV5(command); + break; default: throw new Error( `Can't upgrade from unknown version ${command.version}`, diff --git a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV3ToV4/upgradeV3ToV4.ts b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV3ToV4/upgradeV3ToV4.ts index c616711ce..5e184e6aa 100644 --- a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV3ToV4/upgradeV3ToV4.ts +++ b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV3ToV4/upgradeV3ToV4.ts @@ -1,14 +1,12 @@ -import { CommandV4 } from "@cursorless/common"; -import { CommandV3 } from "@cursorless/common"; -import { - PartialPrimitiveTargetDescriptorV3, - PartialTargetDescriptorV3, -} from "@cursorless/common"; import { + CommandV3, + CommandV4, ImplicitTargetDescriptor, PartialPrimitiveTargetDescriptor, + PartialPrimitiveTargetDescriptorV3, PartialRangeTargetDescriptor, PartialTargetDescriptor, + PartialTargetDescriptorV3, } from "@cursorless/common"; export function upgradeV3ToV4(command: CommandV3): CommandV4 { diff --git a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV4ToV5/index.ts b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV4ToV5/index.ts new file mode 100644 index 000000000..708baf7b3 --- /dev/null +++ b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV4ToV5/index.ts @@ -0,0 +1 @@ +export * from "./upgradeV4ToV5"; diff --git a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV4ToV5/upgradeV4ToV5.ts b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV4ToV5/upgradeV4ToV5.ts new file mode 100644 index 000000000..0c2c74aa5 --- /dev/null +++ b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV4ToV5/upgradeV4ToV5.ts @@ -0,0 +1,57 @@ +import { + ActionCommand, + ActionCommandV4, + CommandV4, + CommandV5, +} from "@cursorless/common"; + +export function upgradeV4ToV5(command: CommandV4): CommandV5 { + return { + ...command, + version: 5, + action: upgradeAction(command.action), + }; +} + +function upgradeAction(action: ActionCommandV4): ActionCommand { + switch (action.name) { + case "wrapWithSnippet": { + const [name, variableName] = parseSnippetLocation( + action.args![0] as string, + ); + return { + name: "wrapWithSnippet", + args: [ + { + type: "named", + name, + variableName, + }, + ], + }; + } + case "insertSnippet": { + const [name, substitutions] = action.args!; + return { + name: "insertSnippet", + args: [ + { + type: "named", + name, + substitutions, + }, + ], + }; + } + default: + return action; + } +} + +function parseSnippetLocation(snippetLocation: string): [string, string] { + const [snippetName, placeholderName] = snippetLocation.split("."); + if (snippetName == null || placeholderName == null) { + throw new Error("Snippet location missing '.'"); + } + return [snippetName, placeholderName]; +} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsert.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsert.yml new file mode 100644 index 000000000..be919a348 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsert.yml @@ -0,0 +1,29 @@ +languageId: plaintext +command: + version: 5 + spokenForm: custom insert + action: + name: insertSnippet + args: + - {type: custom, body: 'dummy snippet hole1: ($hole1), hole2: ($hole2)'} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: "dummy snippet hole1: (), hole2: ()" + selections: + - anchor: {line: 0, character: 22} + active: {line: 0, character: 22} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 34} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertAfterWhale.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertAfterWhale.yml new file mode 100644 index 000000000..b721a7fca --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertAfterWhale.yml @@ -0,0 +1,35 @@ +languageId: plaintext +command: + version: 5 + spokenForm: custom insert after whale + action: + name: insertSnippet + args: + - {type: custom, body: 'dummy snippet hole1: ($hole1), hole2: ($hole2)'} + targets: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + modifiers: + - {type: position, position: after} + usePrePhraseSnapshot: true +initialState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.w: + start: {line: 0, character: 6} + end: {line: 0, character: 11} +finalState: + documentContents: "hello world dummy snippet hole1: (), hole2: ()" + selections: + - anchor: {line: 0, character: 34} + active: {line: 0, character: 34} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 12} + end: {line: 0, character: 46} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertAfterWhale2.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertAfterWhale2.yml new file mode 100644 index 000000000..41fced853 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertAfterWhale2.yml @@ -0,0 +1,39 @@ +languageId: plaintext +command: + version: 5 + spokenForm: custom insert after whale + action: + name: insertSnippet + args: + - type: custom + body: "dummy snippet hole1: ($hole1), hole2: ($hole2)" + scopeType: {type: line} + targets: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + modifiers: + - {type: position, position: after} + usePrePhraseSnapshot: true +initialState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.w: + start: {line: 0, character: 6} + end: {line: 0, character: 11} +finalState: + documentContents: |- + hello world + dummy snippet hole1: (), hole2: () + selections: + - anchor: {line: 1, character: 22} + active: {line: 1, character: 22} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 34} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertHelloWorld.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertHelloWorld.yml new file mode 100644 index 000000000..79a86204a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customInsertHelloWorld.yml @@ -0,0 +1,31 @@ +languageId: plaintext +command: + version: 5 + spokenForm: custom insert hello world + action: + name: insertSnippet + args: + - type: custom + body: "dummy snippet hole1: ($hole1), hole2: ($hole2)" + substitutions: {hole2: hello world} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: "dummy snippet hole1: (), hole2: (hello world)" + selections: + - anchor: {line: 0, character: 22} + active: {line: 0, character: 22} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 45} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapHarp.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapHarp.yml new file mode 100644 index 000000000..72ab0da8a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapHarp.yml @@ -0,0 +1,38 @@ +languageId: plaintext +command: + version: 5 + spokenForm: custom wrap harp + action: + name: wrapWithSnippet + args: + - type: custom + body: "dummy snippet hole1: ($hole1), hole2: ($hole2)" + scopeType: {type: line} + variableName: hole1 + targets: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: h} + usePrePhraseSnapshot: true +initialState: + documentContents: | + hello world + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.h: + start: {line: 0, character: 0} + end: {line: 0, character: 5} +finalState: + documentContents: | + dummy snippet hole1: (hello world), hole2: () + selections: + - anchor: {line: 0, character: 44} + active: {line: 0, character: 44} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 45} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapLine.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapLine.yml new file mode 100644 index 000000000..821dfac44 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapLine.yml @@ -0,0 +1,32 @@ +languageId: plaintext +command: + version: 5 + spokenForm: custom wrap line + action: + name: wrapWithSnippet + args: + - {type: custom, body: 'dummy snippet hole1: ($hole1), hole2: ($hole2)', variableName: hole1} + targets: + - type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + usePrePhraseSnapshot: true +initialState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: "dummy snippet hole1: (hello world), hole2: ()" + selections: + - anchor: {line: 0, character: 44} + active: {line: 0, character: 44} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 45} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapLine2.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapLine2.yml new file mode 100644 index 000000000..ed4b31bc5 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/customWrapLine2.yml @@ -0,0 +1,32 @@ +languageId: plaintext +command: + version: 5 + spokenForm: custom wrap line + action: + name: wrapWithSnippet + args: + - {type: custom, body: 'dummy snippet hole1: ($TM_SELECTED_TEXT), hole2: ($hole2)'} + targets: + - type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + usePrePhraseSnapshot: true +initialState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: "dummy snippet hole1: (hello world), hole2: ()" + selections: + - anchor: {line: 0, character: 44} + active: {line: 0, character: 44} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 45} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk2.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk2.yml new file mode 100644 index 000000000..54dcaad17 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk2.yml @@ -0,0 +1,32 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: |- + function () { + + } + selections: + - anchor: {line: 0, character: 9} + active: {line: 0, character: 9} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 2, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld3.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld3.yml new file mode 100644 index 000000000..f22765c00 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld3.yml @@ -0,0 +1,34 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk hello world + action: + name: insertSnippet + args: + - type: named + name: functionDeclaration + substitutions: {name: hello world} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: |- + function helloWorld() { + + } + selections: + - anchor: {line: 0, character: 20} + active: {line: 0, character: 20} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 2, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/tryWrapThis.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/tryWrapThis.yml new file mode 100644 index 000000000..e50bfbd81 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/tryWrapThis.yml @@ -0,0 +1,35 @@ +languageId: typescript +command: + version: 5 + spokenForm: try wrap this + action: + name: wrapWithSnippet + args: + - {type: named, name: tryCatchStatement, variableName: body} + targets: + - type: primitive + mark: {type: cursor} + usePrePhraseSnapshot: true +initialState: + documentContents: const foo = "bar"; + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: |- + try { + const foo = "bar"; + } catch (err) { + + } + selections: + - anchor: {line: 3, character: 4} + active: {line: 3, character: 4} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 4, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/testCaseRecorder/takeHarp.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/testCaseRecorder/takeHarp.yml index 58eb086aa..03f592207 100644 --- a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/testCaseRecorder/takeHarp.yml +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/testCaseRecorder/takeHarp.yml @@ -1,6 +1,6 @@ languageId: plaintext command: - version: 4 + version: 5 spokenForm: take harp action: {name: setSelection} targets: