diff --git a/cursorless-snippets/functionDeclaration.cursorless-snippets b/cursorless-snippets/functionDeclaration.cursorless-snippets new file mode 100644 index 0000000000..c57443bd35 --- /dev/null +++ b/cursorless-snippets/functionDeclaration.cursorless-snippets @@ -0,0 +1,53 @@ +{ + "functionDeclaration": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact" + ] + }, + "body": [ + "function $name($parameterList) {", + "\t$body", + "}" + ], + "variables": { + "name": { + "formatter": "camelCase" + } + } + }, + { + "scope": { + "langIds": [ + "python" + ] + }, + "body": [ + "def $name($parameterList):", + "\t$body" + ], + "variables": { + "name": { + "formatter": "snakeCase" + } + } + } + ], + "description": "Function declaration", + "variables": { + "body": { + "wrapperScopeType": "statement" + } + }, + "insertionScopeTypes": [ + "namedFunction", + "statement", + "line" + ] + } +} diff --git a/cursorless-snippets/ifElseStatement.cursorless-snippets b/cursorless-snippets/ifElseStatement.cursorless-snippets index 09e2b5f4b1..ef198cfd2f 100644 --- a/cursorless-snippets/ifElseStatement.cursorless-snippets +++ b/cursorless-snippets/ifElseStatement.cursorless-snippets @@ -45,6 +45,9 @@ "alternative": { "wrapperScopeType": "statement" } - } + }, + "insertionScopeTypes": [ + "statement" + ] } } diff --git a/cursorless-snippets/ifStatement.cursorless-snippets b/cursorless-snippets/ifStatement.cursorless-snippets index e33c948e0c..6b2029b6a2 100644 --- a/cursorless-snippets/ifStatement.cursorless-snippets +++ b/cursorless-snippets/ifStatement.cursorless-snippets @@ -38,6 +38,9 @@ "consequence": { "wrapperScopeType": "statement" } - } + }, + "insertionScopeTypes": [ + "statement" + ] } } diff --git a/cursorless-snippets/tryCatchStatement.cursorless-snippets b/cursorless-snippets/tryCatchStatement.cursorless-snippets index b6cddd4def..427ad229bb 100644 --- a/cursorless-snippets/tryCatchStatement.cursorless-snippets +++ b/cursorless-snippets/tryCatchStatement.cursorless-snippets @@ -45,6 +45,9 @@ "exceptBody": { "wrapperScopeType": "statement" } - } + }, + "insertionScopeTypes": [ + "statement" + ] } } diff --git a/cursorless-talon/src/actions/actions.py b/cursorless-talon/src/actions/actions.py index 56bdeb0192..060eb05707 100644 --- a/cursorless-talon/src/actions/actions.py +++ b/cursorless-talon/src/actions/actions.py @@ -86,6 +86,7 @@ def vscode_command_no_wait(command_id: str, target: dict, command_options: dict "swap_action": {"swap": "swapTargets"}, "move_bring_action": {"bring": "replaceWithTarget", "move": "moveToTarget"}, "wrap_action": {"wrap": "wrapWithPairedDelimiter", "repack": "rewrap"}, + "insert_snippet_action": {"snippet": "insertSnippet"}, "reformat_action": {"format": "applyFormatter"}, } diff --git a/cursorless-talon/src/actions/wrap.py b/cursorless-talon/src/actions/wrap.py index feaecfbfbf..a7d4fac53b 100644 --- a/cursorless-talon/src/actions/wrap.py +++ b/cursorless-talon/src/actions/wrap.py @@ -1,35 +1,12 @@ from typing import Any -from talon import Context, Module, actions, app +from talon import Module, actions -from ..csv_overrides import init_csv_and_watch_changes from ..paired_delimiter import paired_delimiters_map mod = Module() -mod.tag( - "cursorless_experimental_snippets", - desc="tag for enabling experimental snippet support", -) - mod.list("cursorless_wrap_action", desc="Cursorless wrap action") -mod.list("cursorless_wrapper_snippet", desc="Cursorless wrapper snippet") - -experimental_snippets_ctx = Context() -experimental_snippets_ctx.matches = r""" -tag: user.cursorless_experimental_snippets -""" - - -# NOTE: Please do not change these dicts. Use the CSVs for customization. -# See https://www.cursorless.org/docs/user/customization/ -wrapper_snippets = { - "else": "ifElseStatement.alternative", - "if else": "ifElseStatement.consequence", - "if": "ifStatement.consequence", - "try": "tryCatchStatement.body", - "link": "link.text", -} @mod.capture( @@ -72,18 +49,3 @@ def cursorless_wrap(action_type: str, targets: dict, cursorless_wrapper: dict): actions.user.cursorless_single_target_command_with_arg_list( action, targets, cursorless_wrapper["extra_args"] ) - - -def on_ready(): - init_csv_and_watch_changes( - "experimental/wrapper_snippets", - { - "wrapper_snippet": wrapper_snippets, - }, - allow_unknown_values=True, - default_list_name="wrapper_snippet", - ctx=experimental_snippets_ctx, - ) - - -app.register("ready", on_ready) diff --git a/cursorless-talon/src/command.py b/cursorless-talon/src/command.py index f596fa8006..edbe1bfc0f 100644 --- a/cursorless-talon/src/command.py +++ b/cursorless-talon/src/command.py @@ -2,6 +2,8 @@ from talon import Module, actions, speech_system +from .primitive_target import IMPLICIT_TARGET + mod = Module() last_phrase = None @@ -47,17 +49,7 @@ def cursorless_single_target_command_no_wait( ) def cursorless_single_target_command_with_arg_list( - action: str, target: str, args: list[Any] - ): - """Execute single-target cursorless command with argument list""" - actions.user.cursorless_single_target_command( - action, - target, - *args, - ) - - def cursorless_single_target_command_with_arg_list( - action: str, target: str, args: list[Any] + action: str, target: dict, args: list[Any] ): """Execute single-target cursorless command with argument list""" actions.user.cursorless_single_target_command( @@ -83,12 +75,23 @@ def cursorless_single_target_command_get( ), ) + def cursorless_implicit_target_command( + action: str, + arg1: Any = NotSet, + arg2: Any = NotSet, + arg3: Any = NotSet, + ): + """Execute cursorless command with implicit target""" + actions.user.cursorless_single_target_command( + action, IMPLICIT_TARGET, arg1, arg2, arg3 + ) + def cursorless_multiple_target_command( action: str, targets: list[dict], - arg1: any = NotSet, - arg2: any = NotSet, - arg3: any = NotSet, + arg1: Any = NotSet, + arg2: Any = NotSet, + arg3: Any = NotSet, ): """Execute multi-target cursorless command""" actions.user.vscode_with_plugin_and_wait( @@ -103,9 +106,9 @@ def cursorless_multiple_target_command( def cursorless_multiple_target_command_no_wait( action: str, targets: list[dict], - arg1: any = NotSet, - arg2: any = NotSet, - arg3: any = NotSet, + arg1: Any = NotSet, + arg2: Any = NotSet, + arg3: Any = NotSet, ): """Execute multi-target cursorless command""" actions.user.vscode_with_plugin( diff --git a/cursorless-talon/src/cursorless-snippets.talon b/cursorless-talon/src/cursorless-snippets.talon new file mode 100644 index 0000000000..52099bbea5 --- /dev/null +++ b/cursorless-talon/src/cursorless-snippets.talon @@ -0,0 +1,12 @@ +app: vscode +tag: user.cursorless_experimental_snippets +- + +{user.cursorless_insert_snippet_action} : + user.cursorless_implicit_target_command(cursorless_insert_snippet_action, cursorless_insertion_snippet) + +{user.cursorless_insert_snippet_action} : + 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) diff --git a/cursorless-talon/src/snippets.py b/cursorless-talon/src/snippets.py new file mode 100644 index 0000000000..5e60826c23 --- /dev/null +++ b/cursorless-talon/src/snippets.py @@ -0,0 +1,119 @@ +from talon import Context, Module, actions, app + +from .csv_overrides import init_csv_and_watch_changes + +mod = Module() +mod.list("cursorless_insert_snippet_action", desc="Cursorless insert snippet action") + +mod.tag( + "cursorless_experimental_snippets", + desc="tag for enabling experimental snippet support", +) + +mod.list("cursorless_wrapper_snippet", desc="Cursorless wrapper snippet") +mod.list( + "cursorless_insertion_snippet_no_phrase", + desc="Cursorless insertion snippets that don't accept a phrase", +) +mod.list( + "cursorless_insertion_snippet_single_phrase", + desc="Cursorless insertion snippet that can accept a single phrase", +) +mod.list("cursorless_phrase_terminator", "Contains term used to terminate a phrase") + + +@mod.capture( + rule="{user.cursorless_insertion_snippet_no_phrase} | {user.cursorless_insertion_snippet_single_phrase}" +) +def cursorless_insertion_snippet(m) -> str: + try: + return m.cursorless_insertion_snippet_no_phrase + except AttributeError: + pass + + return m.cursorless_insertion_snippet_single_phrase.split(".")[0] + + +experimental_snippets_ctx = Context() +experimental_snippets_ctx.matches = r""" +tag: user.cursorless_experimental_snippets +""" + + +# NOTE: Please do not change these dicts. Use the CSVs for customization. +# See https://www.cursorless.org/docs/user/customization/ +wrapper_snippets = { + "else": "ifElseStatement.alternative", + "funk": "functionDeclaration.body", + "if else": "ifElseStatement.consequence", + "if": "ifStatement.consequence", + "try": "tryCatchStatement.body", + "link": "link.text", +} + +# NOTE: Please do not change these dicts. Use the CSVs for customization. +# See https://www.cursorless.org/docs/user/customization/ +insertion_snippets_no_phrase = { + "if": "ifStatement", + "if else": "ifElseStatement", + "try": "tryCatchStatement", +} + +# NOTE: Please do not change these dicts. Use the CSVs for customization. +# See https://www.cursorless.org/docs/user/customization/ +insertion_snippets_single_phrase = { + "funk": "functionDeclaration.name", + "link": "link.text", +} + + +@mod.action_class +class Actions: + def 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} + ) + + +def on_ready(): + init_csv_and_watch_changes( + "experimental/wrapper_snippets", + { + "wrapper_snippet": wrapper_snippets, + }, + allow_unknown_values=True, + default_list_name="wrapper_snippet", + ctx=experimental_snippets_ctx, + ) + init_csv_and_watch_changes( + "experimental/insertion_snippets", + { + "insertion_snippet_no_phrase": insertion_snippets_no_phrase, + }, + allow_unknown_values=True, + default_list_name="insertion_snippet_no_phrase", + ctx=experimental_snippets_ctx, + ) + init_csv_and_watch_changes( + "experimental/insertion_snippets_single_phrase", + { + "insertion_snippet_single_phrase": insertion_snippets_single_phrase, + }, + allow_unknown_values=True, + default_list_name="insertion_snippet_single_phrase", + ctx=experimental_snippets_ctx, + ) + init_csv_and_watch_changes( + "experimental/miscellaneous", + { + "phrase_terminator": {"over": "phraseTerminator"}, + }, + ctx=experimental_snippets_ctx, + ) + + +app.register("ready", on_ready) diff --git a/docs/user/README.md b/docs/user/README.md index e67d5fb9b1..04d4b5a0f2 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -412,9 +412,9 @@ The rewrap command, mapped to `"repack"` by default, can be used to swap a given See [paired delimiters](#paired-delimiters) for a list of possible wrappers. -#### \[experimental\] Wrap with snippet +### \[experimental\] Snippets -See [experimental documentation](experimental/wrapper-snippets.md). +See [experimental documentation](experimental/snippets.md). ### Show definition/reference/quick fix diff --git a/docs/user/experimental/snippets.md b/docs/user/experimental/snippets.md new file mode 100644 index 0000000000..53d3af38d9 --- /dev/null +++ b/docs/user/experimental/snippets.md @@ -0,0 +1,154 @@ +# Snippets + +![Wrapper snippet demo](images/tryWrapFine.gif) +![Link wrap](images/linkWrap.gif) + +Cursorless has its own experimental snippet engine that allows you to both insert snippets and wrap targets with snippets. Cursorless ships with a few built-in snippets, but users can also use their own snippets. + +## Enabling snippets + +Add the following line to the end of your `settings.talon` (or any other `.talon` file that will be active when vscode is focused): + +``` +tag(): user.cursorless_experimental_snippets +``` + +## Using snippets + +### Wrapping a target with snippets + +#### Command syntax + +The command syntax is as follows: + +``` +" wrap " +``` + +#### Examples + +- `"try wrap air"`: Wrap the statement containing the marked `a` in a try-catch statement +- `"try wrap air past bat"`: Wrap the sequence of statements from the marked `a` to the marked `b` in a try-catch statement + +#### Default scope types + +Each snippet wrapper has a default scope type. When you refer to a target, by default it will expand to the given scope type. This way, for example, when you say `"try wrap air"`, it will refer to the statement containing `a` rather than just the token. + +### Built-in wrapper snippets + +| Default spoken form | Snippet | Default target scope type | +| ------------------- | --------------------------------------------- | ------------------------- | +| `"if wrap"` | If statement | Statement | +| `"else wrap"` | If-else statement; target goes in else branch | Statement | +| `"if else wrap"` | If-else statement; target goes in if branch | Statement | +| `"try wrap"` | Try-catch statement | Statement | +| `"link wrap"` | Markdown link | | +| `"funk wrap"` | Function | Statement | + +### Inserting a snippet + +The same snippet definitions that allow for wrapping targets can also be used for insertion. You can either insert a snippet at the current cursor position, or use a positional target to insert before / after / replace something. + +#### Command syntax + +The command syntax options are as follows. In its simplest form, you can just say + +``` +"snippet " +``` + +This command will insert a snippet at the current position. For example: + +- `"snippet funk"` +- `"snippet if"` + +For some snippets, you can include a phrase, that will automatically fill a particular snippet variable with the given phrase, formatted properly: + +``` +"snippet " +``` + +For example: + +- `"snippet funk hello world"`: Insert function with name `helloWorld` + +Finally, we support inserting a snippet onto, before or after a Cursorless target: + +``` +"snippet before " +"snippet after " +"snippet to " +``` + +For example: + +- `"snippet if after air"`: Insert `if` statement after the statement with a hat over the `a` + +Note that each snippet can use `insertionScopeTypes` to indicate that it will auto-expand the target. So, for example, `"snip if after this"` will insert an `if` statement after the current statement. + +### Built-in insertion snippets + +| Default spoken form | Snippet | Default insertion scope type | Accepts optional phrase? | +| ------------------- | --------------------------------------- | ---------------------------- | ------------------------ | +| `"snippet if"` | If statement | Statement | ❌ | +| `"snippet if else"` | If-else statement | Statement | ❌ | +| `"snippet try"` | Try-catch statement | Statement | ❌ | +| `"snippet funk"` | Function; phrase becomes name | Function | ✅ | +| `"snippet link"` | Markdown link; phrase becomes link text | | ✅ | + +## Customizing spoken forms + +As usual, the spoken forms for these snippets can be [customized by csv](../customization.md). The csvs are in the files in `cursorless-settings/experimental` with `snippet` in their name. + +## Adding your own snippets + +To define your own wrapper snippets, proceed as follows: + +### Define snippets in vscode + +1. Set the `cursorless.experimental.snippetsDir` setting to a directory in which you'd like to create your snippets. +2. Add snippets to the directory in files ending in `.cursorless-snippets`. See the [documentation](snippet-format.md) for the cursorless snippet format. + +### 2. Add snippet to spoken forms csvs + +Snippets can be used for wrapping or insertion or both. + +#### For wrapping + +For each snippet that you'd like to be able to use as a wrapper snippet, add a line to the `cursorless-settings/experimental/wrapper_snippets.csv` csv overrides file. The first column is the desired spoken form, and the second column is of the form `.`, where `name` is the name of the snippet (ie the key in your snippet json file), and `variable` is one of the placeholder variables in your snippet where the target should go. + +#### For insertion + +For each snippet that you'd like to be able to use for insertion, add a line to one of the following files: + +- Use `cursorless-settings/experimental/insertion_snippets.csv` if you **don't** need an optional trailing phrase (eg for `"snippet funk hello world"` to provide a function name). In this case, the first column is the spoken form, and the second column is the snippet name. +- Use `cursorless-settings/experimental/insertion_snippets_single_phrase.csv` if you want to be able to include an optional extra phrase. In this csv, the first column is the desired spoken form, and the second column is of the form `.`, where `name` is the name of the snippet (ie the key in your snippet json file), and `variable` is one of the placeholder variables in your snippet where the extra phrase should go. + +## Customizing built-in snippets + +To customize a built-in snippet, just define a custom snippet (as above), but +use the same name as the cursorless core snippet you'd like to change, and give +definitions along with scopes where you'd like your override to be active. Here +is an example: + +```json +{ + "tryCatchStatement": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact" + ] + }, + "body": ["try {", "\t$body", "} catch (err) {", "\t$exceptBody", "}"] + } + ] + } +} +``` + +The above will change the definition of the try-catch statement in typescript. diff --git a/docs/user/experimental/wrapper-snippets.md b/docs/user/experimental/wrapper-snippets.md deleted file mode 100644 index aa038fe809..0000000000 --- a/docs/user/experimental/wrapper-snippets.md +++ /dev/null @@ -1,89 +0,0 @@ -# Wrapper snippets - -![Wrapper snippet demo](images/tryWrapFine.gif) -![Link wrap](images/linkWrap.gif) - -In addition to wrapping with paired delimiters (eg `"square wrap"`, `"round wrap"`, etc), we experimentally support wrapping with snippets. Cursorless ships with a few built-in snippets, but users can also use their own snippets. - -## Enabling wrapper snippets - -Add the following line to the end of your `settings.talon` (or any other `.talon` file that will be active when vscode is focused): - -``` -tag(): user.cursorless_experimental_snippets -``` - -## Using wrapper snippets - -### Command syntax - -The command syntax is as follows: - -``` -" wrap " -``` - -### Examples - -- `"try wrap air"`: Wrap the statement containing the marked `a` in a try-catch statement -- `"try wrap air past bat"`: Wrap the sequence of statements from the marked `a` to the marked `b` in a try-catch statement - -### Default scope types - -Each snippet wrapper has a default scope type. When you refer to a target, by default it will expand to the given scope type. This way, for example, when you say `"try wrap air"`, it will refer to the statement containing `a` rather than just the token. - -## Built-in wrapper snippets - -| Default spoken form | Snippet | Default target scope type | -| ------------------- | --------------------------------------------- | ------------------------- | -| `"if wrap"` | If statement | Statement | -| `"else wrap"` | If-else statement; target goes in else branch | Statement | -| `"if else wrap"` | If-else statement; target goes in if branch | Statement | -| `"try wrap"` | Try-catch statement | Statement | -| `"link wrap"` | Markdown link | | - -## Customizing spoken forms - -As usual, the spoken forms for these wrapper snippets can be [customized by csv](../customization.md). The csvs are in the file `cursorless-settings/experimental/wrapper_snippets.csv`. - -## Adding your own snippets - -To define your own wrapper snippets, proceed as follows: - -### Define snippets in vscode - -1. Set the `cursorless.experimental.snippetsDir` setting to a directory in which you'd like to create your snippets. -2. Add snippets to the directory in files ending in `.cursorless-snippets`. See the [documentation](snippet-format.md) for the cursorless snippet format. - -### 2. Add snippet to spoken forms csv - -For each snippet that you'd like to be able to use as a wrapper snippet, add a line to the `cursorless-settings/experimental/wrapper_snippets.csv` csv overrides file. The first column is the desired spoken form, and the second column is of the form `.`, where `name` is the name of the snippet (ie the key in your snippet json file), and `variable` is one of the placeholder variables in your snippet where the target should go. - -## Customizing built-in snippets - -To customize a built-in snippet, just define a custom snippet (as above), but -use the same name as the cursorless core snippet you'd like to change, and give -definitions along with scopes where you'd like your override to be active. Here -is an example: - -```json -{ - "tryCatchStatement": { - "definitions": [ - { - "scope": { - "langIds": [ - "typescript", - "typescriptreact", - "javascript", - "javascriptreact" - ] - }, - "body": ["try {", "\t$body", "} catch (err) {", "\t$exceptBody", "}"] - } - ] - } -} -``` - -The above will change the definition of the try-catch statement in typescript. diff --git a/schemas/cursorless-snippets.json b/schemas/cursorless-snippets.json index 625eb383de..c7941f70c7 100644 --- a/schemas/cursorless-snippets.json +++ b/schemas/cursorless-snippets.json @@ -19,10 +19,14 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "VSCode language ids where this snippet definition should be active" }, - "scopeType": { - "$ref": "#/$defs/scopeType", + "scopeTypes": { + "type": "array", + "items": { + "$ref": "#/$defs/scopeType" + }, "description": "Cursorless scopes in which this snippet is active. Allows, for example, to have different snippets to define a function if you're in a class or at global scope." } } @@ -32,32 +36,30 @@ "items": { "type": "string" }, - "description": "Inline snippet text using VSCode snippet syntax; entries joined by newline. Named variables of the form $foo can be used as wrappers" + "description": "Inline snippet text using VSCode snippet syntax; entries joined by newline. Named variables of the form `$foo` can be used as placeholders" + }, + "variables": { + "$ref": "#/$defs/variables", + "description": "Scope-specific overrides for the variables defined in the snippet" } }, "required": ["body"] } }, "variables": { - "type": "object", - "description": "For each named variable in the snippet, provides extra information about the variable.", - "additionalProperties": { - "type": "object", - "properties": { - "wrapperScopeType": { - "$ref": "#/$defs/scopeType", - "description": "Default to this scope type when wrapping a target without scope type specified" - }, - "description": { - "type": "string", - "description": "Description of the snippet variable" - } - } - } + "$ref": "#/$defs/variables", + "description": "For each named variable in the snippet, provides extra information about the variable." }, "description": { "type": "string", "description": "Description of the snippet" + }, + "insertionScopeTypes": { + "type": "array", + "items": { + "$ref": "#/$defs/scopeType" + }, + "description": "Try to expand target to this scope type when inserting this snippet before/after a target without scope type specified. If multiple scope types are specified try them each in order until one of them matches." } } }, @@ -85,11 +87,51 @@ "string", "type", "value", + "condition", + "section", + "sectionLevelOne", + "sectionLevelTwo", + "sectionLevelThree", + "sectionLevelFour", + "sectionLevelFive", + "sectionLevelSix", + "selector", "xmlBothTags", "xmlElement", "xmlEndTag", - "xmlStartTag" + "xmlStartTag", + "token", + "line", + "notebookCell", + "paragraph", + "document", + "character", + "word", + "nonWhitespaceSequence", + "url" ] + }, + "variables": { + "type": "object", + "description": "For each named variable in the snippet, provides extra information about the variable.", + "additionalProperties": { + "type": "object", + "properties": { + "wrapperScopeType": { + "$ref": "#/$defs/scopeType", + "description": "Default to this scope type when wrapping a target without scope type specified" + }, + "description": { + "type": "string", + "description": "Description of the snippet variable" + }, + "formatter": { + "type": "string", + "description": "Format text inserted into this variable using the given formatter", + "enum": ["camelCase", "pascalCase", "snakeCase", "upperSnakeCase"] + } + } + } } } } diff --git a/src/actions/Actions.ts b/src/actions/Actions.ts index 6f12013464..068de65224 100644 --- a/src/actions/Actions.ts +++ b/src/actions/Actions.ts @@ -38,6 +38,7 @@ import { Random, Reverse, Sort } from "./Sort"; import ToggleBreakpoint from "./ToggleBreakpoint"; import Wrap from "./Wrap"; import WrapWithSnippet from "./WrapWithSnippet"; +import InsertSnippet from "./InsertSnippet"; class Actions implements ActionRecord { constructor(private graph: Graph) {} @@ -63,6 +64,7 @@ class Actions implements ActionRecord { insertEmptyLineAfter = new InsertEmptyLineAfter(this.graph); insertEmptyLineBefore = new InsertEmptyLineBefore(this.graph); insertEmptyLinesAround = new InsertEmptyLinesAround(this.graph); + insertSnippet = new InsertSnippet(this.graph); moveToTarget = new Move(this.graph); outdentLine = new OutdentLines(this.graph); pasteFromClipboard = new Paste(this.graph); diff --git a/src/actions/InsertSnippet.ts b/src/actions/InsertSnippet.ts new file mode 100644 index 0000000000..b3fa8886db --- /dev/null +++ b/src/actions/InsertSnippet.ts @@ -0,0 +1,145 @@ +import { commands, DecorationRangeBehavior } from "vscode"; +import textFormatters from "../core/textFormatters"; +import { + callFunctionAndUpdateSelectionInfos, + getSelectionInfo, +} from "../core/updateSelections/updateSelections"; +import ModifyIfWeakStage from "../processTargets/modifiers/ModifyIfWeakStage"; +import { Snippet, SnippetDefinition } from "../typings/snippet"; +import { Target } from "../typings/target.types"; +import { Graph } from "../typings/Types"; +import { + findMatchingSnippetDefinitionStrict, + transformSnippetVariables, +} from "../util/snippet"; +import { ensureSingleEditor } from "../util/targetUtils"; +import { SnippetParser } from "../vendor/snippet/snippetParser"; +import { Action, ActionReturnValue } from "./actions.types"; + +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 ModifyIfWeakStage({ + type: "modifyIfWeak", + modifier: { + type: "cascading", + modifiers: defaultScopeTypes.map((scopeType) => ({ + type: "containingScope", + scopeType: { + type: scopeType, + }, + })), + }, + }), + ]; + } + + constructor(private graph: Graph) { + this.run = this.run.bind(this); + } + + async run( + [targets]: [Target[]], + snippetName: string, + substitutions: Record + ): Promise { + const snippet = this.graph.snippets.getSnippetStrict(snippetName); + + const editor = ensureSingleEditor(targets); + + const definition = findMatchingSnippetDefinitionStrict( + targets, + snippet.definitions + ); + + const parsedSnippet = this.snippetParser.parse(definition.body.join("\n")); + + const formattedSubstitutions = + substitutions == null + ? undefined + : formatSubstitutions(snippet, definition, substitutions); + + transformSnippetVariables(parsedSnippet, null, formattedSubstitutions); + + const snippetString = parsedSnippet.toTextmateString(); + + await this.graph.actions.editNew.run([targets]); + + const targetSelectionInfos = editor.selections.map((selection) => + getSelectionInfo( + editor.document, + selection, + DecorationRangeBehavior.OpenOpen + ) + ); + + // NB: We used the command "editor.action.insertSnippet" instead of calling editor.insertSnippet + // because the latter doesn't support special variables like CLIPBOARD + const [updatedTargetSelections] = await callFunctionAndUpdateSelectionInfos( + this.graph.rangeUpdater, + () => + commands.executeCommand("editor.action.insertSnippet", { + snippet: snippetString, + }), + editor.document, + [targetSelectionInfos] + ); + + return { + thatMark: updatedTargetSelections.map((selection) => ({ + editor, + selection, + })), + }; + } +} + +/** + * Applies the appropriate formatters to the given variable substitution values + * in {@link substitutions} based on the formatter specified for the given + * variables as defined in {@link snippet} and {@link definition}. + * @param snippet The full snippet info + * @param definition The specific definition chosen for the given target context + * @param substitutions The original unformatted substitution strings + * @returns A new map of substitution strings with the values formatted + */ +function formatSubstitutions( + snippet: Snippet, + definition: SnippetDefinition, + substitutions: Record +): Record { + return Object.fromEntries( + Object.entries(substitutions).map(([variableName, value]) => { + // We prefer the variable formatters from the contextually relevant + // snippet definition if they exist, otherwise we fall back to the + // global definitions for the given snippet. + const formatterName = + (definition.variables ?? {})[variableName]?.formatter ?? + (snippet.variables ?? {})[variableName]?.formatter; + + if (formatterName == null) { + return [variableName, value]; + } + + const formatter = textFormatters[formatterName]; + + if (formatter == null) { + throw new Error( + `Couldn't find formatter ${formatterName} for variable ${variableName}` + ); + } + + return [variableName, formatter(value.split(" "))]; + }) + ); +} diff --git a/src/actions/Replace.ts b/src/actions/Replace.ts index c96a306a77..46f755ec3a 100644 --- a/src/actions/Replace.ts +++ b/src/actions/Replace.ts @@ -7,7 +7,7 @@ import { Action, ActionReturnValue } from "./actions.types"; type RangeGenerator = { start: number }; -export default class implements Action { +export default class Replace implements Action { constructor(private graph: Graph) { this.run = this.run.bind(this); } diff --git a/src/actions/WrapWithSnippet.ts b/src/actions/WrapWithSnippet.ts index 6596fc04da..52a0c4ba64 100644 --- a/src/actions/WrapWithSnippet.ts +++ b/src/actions/WrapWithSnippet.ts @@ -1,17 +1,14 @@ import { commands } from "vscode"; import { callFunctionAndUpdateSelections } from "../core/updateSelections/updateSelections"; import ModifyIfWeakStage from "../processTargets/modifiers/ModifyIfWeakStage"; -import { SnippetDefinition } from "../typings/snippet"; import { Target } from "../typings/target.types"; import { Graph } from "../typings/Types"; -import { ensureSingleEditor } from "../util/targetUtils"; import { - Placeholder, - SnippetParser, - TextmateSnippet, - Variable, -} from "../vendor/snippet/snippetParser"; -import { KnownSnippetVariableNames } from "../vendor/snippet/snippetVariables"; + findMatchingSnippetDefinitionStrict, + transformSnippetVariables, +} from "../util/snippet"; +import { ensureSingleEditor } from "../util/targetUtils"; +import { SnippetParser } from "../vendor/snippet/snippetParser"; import { Action, ActionReturnValue } from "./actions.types"; export default class WrapWithSnippet implements Action { @@ -21,11 +18,7 @@ export default class WrapWithSnippet implements Action { const [snippetName, placeholderName] = parseSnippetLocation(snippetLocation); - const snippet = this.graph.snippets.getSnippet(snippetName); - - if (snippet == null) { - throw new Error(`Couldn't find snippet ${snippetName}`); - } + const snippet = this.graph.snippets.getSnippetStrict(snippetName); const variables = snippet.variables ?? {}; const defaultScopeType = variables[placeholderName]?.wrapperScopeType; @@ -36,9 +29,12 @@ export default class WrapWithSnippet implements Action { return [ new ModifyIfWeakStage({ - type: "containingScope", - scopeType: { - type: defaultScopeType, + type: "modifyIfWeak", + modifier: { + type: "containingScope", + scopeType: { + type: defaultScopeType, + }, }, }), ]; @@ -55,24 +51,15 @@ export default class WrapWithSnippet implements Action { const [snippetName, placeholderName] = parseSnippetLocation(snippetLocation); - const snippet = this.graph.snippets.getSnippet(snippetName)!; + const snippet = this.graph.snippets.getSnippetStrict(snippetName); const editor = ensureSingleEditor(targets); - // Find snippet definition matching context. - // NB: We only look at the first target to create our context. This means - // that if there are two snippets that match two different contexts, and - // the two targets match those two different contexts, we will just use the - // snippet that matches the first context for both targets - const definition = findMatchingSnippetDefinition( - targets[0], + const definition = findMatchingSnippetDefinitionStrict( + targets, snippet.definitions ); - if (definition == null) { - throw new Error("Couldn't find matching snippet definition"); - } - const parsedSnippet = this.snippetParser.parse(definition.body.join("\n")); transformSnippetVariables(parsedSnippet, placeholderName); @@ -109,46 +96,6 @@ export default class WrapWithSnippet implements Action { } } -/** - * Replaces the snippet variable with name `placeholderName` with TM_SELECTED_TEXT - * - * Also replaces any unknown variables with placeholders. We do this so it's - * easier to leave one of the placeholders blank. We may make it so that you - * can disable this with a setting in the future - * @param parsedSnippet The parsed textmate snippet to operate on - * @param placeholderName The variable name to replace with TM_SELECTED_TEXT - */ -function transformSnippetVariables( - parsedSnippet: TextmateSnippet, - placeholderName: string -) { - var placeholderIndex = getMaxPlaceholderIndex(parsedSnippet) + 1; - - parsedSnippet.walk((candidate) => { - if (candidate instanceof Variable) { - if (candidate.name === placeholderName) { - candidate.name = "TM_SELECTED_TEXT"; - } else if (!KnownSnippetVariableNames[candidate.name]) { - const placeholder = new Placeholder(placeholderIndex++); - candidate.children.forEach((child) => placeholder.appendChild(child)); - candidate.parent.replace(candidate, [placeholder]); - } - } - return true; - }); -} - -function getMaxPlaceholderIndex(parsedSnippet: TextmateSnippet) { - var placeholderIndex = 0; - parsedSnippet.walk((candidate) => { - if (candidate instanceof Placeholder) { - placeholderIndex = Math.max(placeholderIndex, candidate.index); - } - return true; - }); - return placeholderIndex; -} - function parseSnippetLocation(snippetLocation: string): [string, string] { const [snippetName, placeholderName] = snippetLocation.split("."); if (snippetName == null || placeholderName == null) { @@ -156,29 +103,3 @@ function parseSnippetLocation(snippetLocation: string): [string, string] { } return [snippetName, placeholderName]; } - -function findMatchingSnippetDefinition( - target: Target, - definitions: SnippetDefinition[] -) { - const languageId = target.editor.document.languageId; - - return definitions.find(({ scope }) => { - if (scope == null) { - return true; - } - - const { langIds, scopeType } = scope; - - if (langIds != null && !langIds.includes(languageId)) { - return false; - } - - if (scopeType != null) { - // TODO: Implement scope types by refactoring code out of processScopeType - throw new Error("Scope types not yet implemented"); - } - - return true; - }); -} diff --git a/src/actions/actions.types.ts b/src/actions/actions.types.ts index 1b460c802d..1d87f73a0d 100644 --- a/src/actions/actions.types.ts +++ b/src/actions/actions.types.ts @@ -24,6 +24,7 @@ export type ActionType = | "insertEmptyLineAfter" | "insertEmptyLineBefore" | "insertEmptyLinesAround" + | "insertSnippet" | "moveToTarget" | "outdentLine" | "pasteFromClipboard" @@ -56,6 +57,12 @@ export interface ActionReturnValue { export interface Action { run(targets: Target[][], ...args: any[]): Promise; + /** + * Used to define stages that should be run before the final positional stage, if there is one + * @param args Extra args to command + */ + getPrePositionStages?(...args: any[]): ModifierStage[]; + /** * Used to define final stages that should be run at the end of the pipeline before the action * @param args Extra args to command diff --git a/src/core/Snippets.ts b/src/core/Snippets.ts index 12457bcad7..f08492a5b8 100644 --- a/src/core/Snippets.ts +++ b/src/core/Snippets.ts @@ -1,14 +1,21 @@ import { readFile, stat } from "fs/promises"; import { cloneDeep, max, merge } from "lodash"; import { join } from "path"; -import { workspace } from "vscode"; +import { window, workspace } from "vscode"; +import isTesting from "../testUtil/isTesting"; import { walkFiles } from "../testUtil/walkAsync"; import { Snippet, SnippetMap } from "../typings/snippet"; import { Graph } from "../typings/Types"; import { mergeStrict } from "../util/object"; +const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000; +interface DirectoryErrorMessage { + directory: string; + errorMessage: string; +} + /** * Handles all cursorless snippets, including core, third-party and * user-defined. Merges these collections and allows looking up snippets by @@ -34,6 +41,15 @@ export class Snippets { */ private maxSnippetMtimeMs: number = -1; + /** + * If the user has misconfigured their snippet dir, then we keep track of it + * so that we can show them the error message if we can't find a snippet + * later, and so that we don't show them the same error message every time + * we try to poll the directory. + */ + private directoryErrorMessage: DirectoryErrorMessage | null | undefined = + null; + constructor(private graph: Graph) { this.updateUserSnippetsPath(); @@ -63,7 +79,7 @@ export class Snippets { async init() { const extensionPath = this.graph.extensionContext.extensionPath; const snippetsDir = join(extensionPath, "cursorless-snippets"); - const snippetFiles = await walkFiles(snippetsDir); + const snippetFiles = await getSnippetPaths(snippetsDir); this.coreSnippets = mergeStrict( ...(await Promise.all( snippetFiles.map(async (path) => @@ -81,9 +97,22 @@ export class Snippets { * @returns Boolean indicating whether path has changed */ private updateUserSnippetsPath(): boolean { - const newUserSnippetsDir = workspace - .getConfiguration("cursorless.experimental") - .get("snippetsDir"); + let newUserSnippetsDir: string | undefined; + + if (isTesting()) { + newUserSnippetsDir = join( + this.graph.extensionContext.extensionPath, + "src", + "test", + "suite", + "fixtures", + "cursorless-snippets" + ); + } else { + newUserSnippetsDir = workspace + .getConfiguration("cursorless.experimental") + .get("snippetsDir"); + } if (newUserSnippetsDir === this.userSnippetsDir) { return false; @@ -98,9 +127,35 @@ export class Snippets { } async updateUserSnippets() { - const snippetFiles = this.userSnippetsDir - ? await walkFiles(this.userSnippetsDir) - : []; + let snippetFiles: string[]; + try { + snippetFiles = this.userSnippetsDir + ? await getSnippetPaths(this.userSnippetsDir) + : []; + } catch (err) { + if (this.directoryErrorMessage?.directory !== this.userSnippetsDir) { + // NB: We suppress error messages once we've shown it the first time + // because we poll the directory every second and want to make sure we + // don't show the same error message repeatedly + const errorMessage = `Error with cursorless snippets dir "${ + this.userSnippetsDir + }": ${(err as Error).message}`; + + window.showErrorMessage(errorMessage); + + this.directoryErrorMessage = { + directory: this.userSnippetsDir!, + errorMessage, + }; + } + + this.userSnippets = {}; + this.mergeSnippets(); + + return; + } + + this.directoryErrorMessage = null; const maxSnippetMtime = max( @@ -117,9 +172,29 @@ export class Snippets { this.userSnippets = mergeStrict( ...(await Promise.all( - snippetFiles.map(async (path) => - JSON.parse(await readFile(path, "utf8")) - ) + snippetFiles.map(async (path) => { + try { + const content = await readFile(path, "utf8"); + + if (content.length === 0) { + // Gracefully handle an empty file + return {}; + } + + return JSON.parse(content); + } catch (err) { + window.showErrorMessage( + `Error with cursorless snippets file "${path}": ${ + (err as Error).message + }` + ); + + // We don't want snippets from all files to stop working if there is + // a parse error in one file, so we just effectively ignore this file + // once we've shown an error message + return {}; + } + }) )) ); @@ -177,11 +252,30 @@ export class Snippets { /** * Looks in merged collection of snippets for a snippet with key - * `snippetName` + * `snippetName`. Throws an exception if the snippet of the given name could + * not be found * @param snippetName The name of the snippet to look up - * @returns The named snippet, or undefined if not found + * @returns The named snippet */ - getSnippet(snippetName: string): Snippet | undefined { - return this.mergedSnippets[snippetName]; + getSnippetStrict(snippetName: string): Snippet { + const snippet = this.mergedSnippets[snippetName]; + + if (snippet == null) { + let errorMessage = `Couldn't find snippet ${snippetName}. `; + + if (this.directoryErrorMessage != null) { + errorMessage += `This could be due to: ${this.directoryErrorMessage.errorMessage}.`; + } + + throw Error(errorMessage); + } + + return snippet; } } + +async function getSnippetPaths(snippetsDir: string) { + return (await walkFiles(snippetsDir)).filter((path) => + path.endsWith(CURSORLESS_SNIPPETS_SUFFIX) + ); +} diff --git a/src/core/commandRunner/CommandRunner.ts b/src/core/commandRunner/CommandRunner.ts index ff4e901cdf..0abde818e3 100644 --- a/src/core/commandRunner/CommandRunner.ts +++ b/src/core/commandRunner/CommandRunner.ts @@ -88,13 +88,19 @@ export default class CommandRunner { this.graph.debug.log(JSON.stringify(targetDescriptors, null, 3)); } - const finalStages = + const actionPrePositionStages = + action.getPrePositionStages != null + ? action.getPrePositionStages(...actionArgs) + : []; + + const actionFinalStages = action.getFinalStages != null ? action.getFinalStages(...actionArgs) : []; const processedTargetsContext: ProcessedTargetsContext = { - finalStages, + actionPrePositionStages, + actionFinalStages, currentSelections: vscode.window.activeTextEditor?.selections.map((selection) => ({ selection, diff --git a/src/core/inferFullTargets.ts b/src/core/inferFullTargets.ts index 8def1fb35c..ea1d8a4cf9 100644 --- a/src/core/inferFullTargets.ts +++ b/src/core/inferFullTargets.ts @@ -97,39 +97,33 @@ function inferPrimitiveTarget( }; } - const ownPositionalModifier = getPositionalModifier(target); - const ownNonPositionalModifiers = getNonPositionalModifiers(target); + const ownPositionModifier = getPositionModifier(target); + const ownNonPositionModifiers = getNonPositionModifiers(target); // Position without a mark can be something like "take air past end of line" // We will remove this case when we implement #736 const mark = target.mark ?? - (ownPositionalModifier == null - ? null - : getPreviousMark(previousTargets)) ?? { + (ownPositionModifier == null ? null : getPreviousMark(previousTargets)) ?? { type: "cursor", }; - const nonPositionalModifiers = - ownNonPositionalModifiers ?? - getPreviousNonPositionalModifiers(previousTargets) ?? + const modifiers = + ownNonPositionModifiers ?? + getPreviousNonPositionModifiers(previousTargets) ?? []; - const positionalModifier = - ownPositionalModifier ?? getPreviousPositionalModifier(previousTargets); - - const modifiers = [ - ...(positionalModifier == null ? [] : [positionalModifier]), - ...nonPositionalModifiers, - ]; + const positionModifier = + ownPositionModifier ?? getPreviousPositionModifier(previousTargets); return { type: target.type, mark, modifiers, + positionModifier, }; } -function getPositionalModifier( +function getPositionModifier( target: PartialPrimitiveTargetDescriptor ): PositionModifier | undefined { if (target.modifiers == null) { @@ -156,15 +150,15 @@ function getPositionalModifier( * @param target The target from which to get the non-positional modifiers * @returns A list of non-positional modifiers or `undefined` if there are none */ -function getNonPositionalModifiers( +function getNonPositionModifiers( target: PartialPrimitiveTargetDescriptor ): Modifier[] | undefined { - const nonPositionalModifiers = target.modifiers?.filter( + const nonPositionModifiers = target.modifiers?.filter( (modifier) => modifier.type !== "position" ); - return nonPositionalModifiers == null || nonPositionalModifiers.length === 0 + return nonPositionModifiers == null || nonPositionModifiers.length === 0 ? undefined - : nonPositionalModifiers; + : nonPositionModifiers; } function getPreviousMark( @@ -176,16 +170,16 @@ function getPreviousMark( ); } -function getPreviousNonPositionalModifiers( +function getPreviousNonPositionModifiers( previousTargets: PartialTargetDescriptor[] ): Modifier[] | undefined { - return getPreviousTargetAttribute(previousTargets, getNonPositionalModifiers); + return getPreviousTargetAttribute(previousTargets, getNonPositionModifiers); } -function getPreviousPositionalModifier( +function getPreviousPositionModifier( previousTargets: PartialTargetDescriptor[] ): PositionModifier | undefined { - return getPreviousTargetAttribute(previousTargets, getPositionalModifier); + return getPreviousTargetAttribute(previousTargets, getPositionModifier); } /** diff --git a/src/core/textFormatters.ts b/src/core/textFormatters.ts new file mode 100644 index 0000000000..b0fbd56ef4 --- /dev/null +++ b/src/core/textFormatters.ts @@ -0,0 +1,32 @@ +import { TextFormatterName } from "../typings/Types"; + +type TextFormatter = (tokens: string[]) => string; +const textFormatters: Record = { + camelCase(tokens: string[]) { + if (tokens.length === 0) { + return ""; + } + + const [first, ...rest] = tokens; + + return first + rest.map(capitalizeToken).join(""); + }, + + snakeCase(tokens: string[]) { + return tokens.join("_"); + }, + + upperSnakeCase(tokens: string[]) { + return tokens.map((token) => token.toUpperCase()).join("_"); + }, + + pascalCase(tokens: string[]) { + return tokens.map(capitalizeToken).join(""); + }, +}; + +function capitalizeToken(token: string): string { + return token.length === 0 ? "" : token[0].toUpperCase() + token.substr(1); +} + +export default textFormatters; diff --git a/src/core/updateSelections/updateSelections.ts b/src/core/updateSelections/updateSelections.ts index 5650958f0a..1afd7abc24 100644 --- a/src/core/updateSelections/updateSelections.ts +++ b/src/core/updateSelections/updateSelections.ts @@ -189,7 +189,7 @@ export async function callFunctionAndUpdateRanges( * @param selectionInfoMatrix A matrix of selection info objects to update * @returns The initial selections updated based upon what happened in the function */ -async function callFunctionAndUpdateSelectionInfos( +export async function callFunctionAndUpdateSelectionInfos( rangeUpdater: RangeUpdater, func: () => Thenable, document: TextDocument, diff --git a/src/processTargets/getModifierStage.ts b/src/processTargets/getModifierStage.ts index 5d40653d5b..e4f8eb7876 100644 --- a/src/processTargets/getModifierStage.ts +++ b/src/processTargets/getModifierStage.ts @@ -4,12 +4,14 @@ import { EveryScopeModifier, Modifier, } from "../typings/targetDescriptor.types"; +import CascadingStage from "./modifiers/CascadingStage"; import { HeadStage, TailStage } from "./modifiers/HeadTailStage"; import { ExcludeInteriorStage, InteriorOnlyStage, } from "./modifiers/InteriorStage"; import { LeadingStage, TrailingStage } from "./modifiers/LeadingTrailingStages"; +import ModifyIfWeakStage from "./modifiers/ModifyIfWeakStage"; import OrdinalRangeSubTokenStage, { OrdinalRangeSubTokenModifier, } from "./modifiers/OrdinalRangeSubTokenStage"; @@ -62,6 +64,10 @@ export default (modifier: Modifier): ModifierStage => { return new OrdinalRangeSubTokenStage( modifier as OrdinalRangeSubTokenModifier ); + case "cascading": + return new CascadingStage(modifier); + case "modifyIfWeak": + return new ModifyIfWeakStage(modifier); } }; diff --git a/src/processTargets/modifiers/CascadingStage.ts b/src/processTargets/modifiers/CascadingStage.ts new file mode 100644 index 0000000000..3e9ec16dba --- /dev/null +++ b/src/processTargets/modifiers/CascadingStage.ts @@ -0,0 +1,35 @@ +import { Target } from "../../typings/target.types"; +import { CascadingModifier } from "../../typings/targetDescriptor.types"; +import { ProcessedTargetsContext } from "../../typings/Types"; +import getModifierStage from "../getModifierStage"; +import { ModifierStage } from "../PipelineStages.types"; + +/** + * Tries each of the given modifiers in turn until one of them doesn't throw an + * error, returning the output from the first modifier not throwing an error. + */ +export default class CascadingStage implements ModifierStage { + private nestedStages_?: ModifierStage[]; + + constructor(private modifier: CascadingModifier) {} + + private get nestedStages() { + if (this.nestedStages_ == null) { + this.nestedStages_ = this.modifier.modifiers.map(getModifierStage); + } + + return this.nestedStages_; + } + + run(context: ProcessedTargetsContext, target: Target): Target[] { + for (const nestedStage of this.nestedStages) { + try { + return nestedStage.run(context, target); + } catch (error) { + continue; + } + } + + throw new Error("No modifier could be applied"); + } +} diff --git a/src/processTargets/modifiers/ModifyIfWeakStage.ts b/src/processTargets/modifiers/ModifyIfWeakStage.ts index 6ac3289956..0ff66d834f 100644 --- a/src/processTargets/modifiers/ModifyIfWeakStage.ts +++ b/src/processTargets/modifiers/ModifyIfWeakStage.ts @@ -1,17 +1,20 @@ import { Target } from "../../typings/target.types"; -import { Modifier } from "../../typings/targetDescriptor.types"; +import { ModifyIfWeakModifier } from "../../typings/targetDescriptor.types"; import { ProcessedTargetsContext } from "../../typings/Types"; import getModifierStage from "../getModifierStage"; import { ModifierStage } from "../PipelineStages.types"; +/** + * Runs {@link ModifyIfWeakModifier.modifier} if the target is weak. + */ export default class ModifyIfWeakStage implements ModifierStage { private nestedStage_?: ModifierStage; - constructor(private nestedModifier: Modifier) {} + constructor(private modifier: ModifyIfWeakModifier) {} private get nestedStage() { if (this.nestedStage_ == null) { - this.nestedStage_ = getModifierStage(this.nestedModifier); + this.nestedStage_ = getModifierStage(this.modifier.modifier); } return this.nestedStage_; diff --git a/src/processTargets/modifiers/commonWeakContainingScopeStages.ts b/src/processTargets/modifiers/commonWeakContainingScopeStages.ts index 22ed334c4e..7f6d45e6f9 100644 --- a/src/processTargets/modifiers/commonWeakContainingScopeStages.ts +++ b/src/processTargets/modifiers/commonWeakContainingScopeStages.ts @@ -1,11 +1,17 @@ import ModifyIfWeakStage from "./ModifyIfWeakStage"; export const weakContainingSurroundingPairStage = new ModifyIfWeakStage({ - type: "containingScope", - scopeType: { type: "surroundingPair", delimiter: "any" }, + type: "modifyIfWeak", + modifier: { + type: "containingScope", + scopeType: { type: "surroundingPair", delimiter: "any" }, + }, }); export const weakContainingLineStage = new ModifyIfWeakStage({ - type: "containingScope", - scopeType: { type: "line" }, + type: "modifyIfWeak", + modifier: { + type: "containingScope", + scopeType: { type: "line" }, + }, }); diff --git a/src/processTargets/processTargets.ts b/src/processTargets/processTargets.ts index 24bdeb0264..ec51362e61 100644 --- a/src/processTargets/processTargets.ts +++ b/src/processTargets/processTargets.ts @@ -195,10 +195,19 @@ function processPrimitiveTarget( const markStage = getMarkStage(targetDescriptor.mark); const markOutputTargets = markStage.run(context); - /** The modifier pipeline that will be applied to construct our final targets */ + const positionModifierStages = + targetDescriptor.positionModifier == null + ? [] + : [getModifierStage(targetDescriptor.positionModifier)]; + + /** + * The modifier pipeline that will be applied to construct our final targets + */ const modifierStages = [ ...getModifierStagesFromTargetModifiers(targetDescriptor.modifiers), - ...context.finalStages, + ...context.actionPrePositionStages, + ...positionModifierStages, + ...context.actionFinalStages, ]; // Run all targets through the modifier stages diff --git a/src/test/suite/fixtures/cursorless-snippets/duplicatedVariableTest.cursorless-snippets b/src/test/suite/fixtures/cursorless-snippets/duplicatedVariableTest.cursorless-snippets new file mode 100644 index 0000000000..351f37c0ca --- /dev/null +++ b/src/test/suite/fixtures/cursorless-snippets/duplicatedVariableTest.cursorless-snippets @@ -0,0 +1,25 @@ +{ + "duplicatedVariableTest": { + "definitions": [ + { + "scope": { + "langIds": [ + "plaintext" + ] + }, + "body": [ + "This variable: '$duplicated' is duplicated here: '$duplicated', but '$unique' is unique!" + ], + "variables": { + "duplicated": { + "formatter": "snakeCase" + }, + "unique": { + "formatter": "camelCase" + } + } + } + ], + "description": "Snippet for testing snippets with duplicated variables" + } +} diff --git a/src/test/suite/fixtures/cursorless-snippets/spaghetti.cursorless-snippets b/src/test/suite/fixtures/cursorless-snippets/spaghetti.cursorless-snippets new file mode 100644 index 0000000000..d31e9e2fed --- /dev/null +++ b/src/test/suite/fixtures/cursorless-snippets/spaghetti.cursorless-snippets @@ -0,0 +1,22 @@ +{ + "spaghetti": { + "definitions": [ + { + "scope": { + "langIds": [ + "plaintext" + ] + }, + "body": [ + "My friend $foo likes to eat spaghetti!" + ], + "variables": { + "foo": { + "formatter": "snakeCase" + } + } + } + ], + "description": "Snippet just for testing user adding snippets" + } +} diff --git a/src/test/suite/fixtures/cursorless-snippets/tryCatchStatement.cursorless-snippets b/src/test/suite/fixtures/cursorless-snippets/tryCatchStatement.cursorless-snippets new file mode 100644 index 0000000000..8b578745da --- /dev/null +++ b/src/test/suite/fixtures/cursorless-snippets/tryCatchStatement.cursorless-snippets @@ -0,0 +1,23 @@ +{ + "tryCatchStatement": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact" + ] + }, + "body": [ + "try {", + "\t$body", + "} catch (err) {", + "\t$exceptBody", + "}" + ] + } + ] + } +} diff --git a/src/test/suite/fixtures/recorded/actions/snippets/duplicatedDuplicatedWrapThis.yml b/src/test/suite/fixtures/recorded/actions/snippets/duplicatedDuplicatedWrapThis.yml new file mode 100644 index 0000000000..45c063fec1 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/duplicatedDuplicatedWrapThis.yml @@ -0,0 +1,28 @@ +languageId: plaintext +command: + spokenForm: duplicated duplicated wrap this + version: 2 + targets: + - type: primitive + mark: {type: cursor} + usePrePhraseSnapshot: true + action: + name: wrapWithSnippet + args: [duplicatedVariableTest.duplicated] +initialState: + documentContents: Hello world + selections: + - anchor: {line: 0, character: 11} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: >- + This variable: 'Hello world' is duplicated here: 'Hello world', but '' is + unique! + selections: + - anchor: {line: 0, character: 69} + active: {line: 0, character: 69} + thatMark: + - anchor: {line: 0, character: 81} + active: {line: 0, character: 0} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: []}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/duplicatedUniqueWrapThis.yml b/src/test/suite/fixtures/recorded/actions/snippets/duplicatedUniqueWrapThis.yml new file mode 100644 index 0000000000..d9c52449c4 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/duplicatedUniqueWrapThis.yml @@ -0,0 +1,28 @@ +languageId: plaintext +command: + spokenForm: duplicated unique wrap this + version: 2 + targets: + - type: primitive + mark: {type: cursor} + usePrePhraseSnapshot: true + action: + name: wrapWithSnippet + args: [duplicatedVariableTest.unique] +initialState: + documentContents: Hello world + selections: + - anchor: {line: 0, character: 11} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: "This variable: '' is duplicated here: '', but 'Hello world' is unique!" + selections: + - anchor: {line: 0, character: 16} + active: {line: 0, character: 16} + - anchor: {line: 0, character: 39} + active: {line: 0, character: 39} + thatMark: + - anchor: {line: 0, character: 70} + active: {line: 0, character: 0} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: []}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedDuplicated.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedDuplicated.yml new file mode 100644 index 0000000000..6877312c4c --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedDuplicated.yml @@ -0,0 +1,27 @@ +languageId: plaintext +command: + spokenForm: snip duplicated duplicated + version: 2 + targets: + - {type: primitive, isImplicit: true} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [duplicatedVariableTest] +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: "This variable: '' is duplicated here: '', but '' is unique!" + selections: + - anchor: {line: 0, character: 16} + active: {line: 0, character: 16} + - anchor: {line: 0, character: 39} + active: {line: 0, character: 39} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 59} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: toRawSelection}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedDuplicatedHelloWorld.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedDuplicatedHelloWorld.yml new file mode 100644 index 0000000000..4fb1c39ee5 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedDuplicatedHelloWorld.yml @@ -0,0 +1,29 @@ +languageId: plaintext +command: + spokenForm: snip duplicated duplicated hello world + version: 2 + targets: + - {type: primitive, isImplicit: true} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: + - duplicatedVariableTest + - {duplicated: hello world} +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: >- + This variable: 'hello_world' is duplicated here: 'hello_world', but '' is + unique! + selections: + - anchor: {line: 0, character: 69} + active: {line: 0, character: 69} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 81} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: toRawSelection}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedUniqueHelloWorld.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedUniqueHelloWorld.yml new file mode 100644 index 0000000000..2b30a29eff --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipDuplicatedUniqueHelloWorld.yml @@ -0,0 +1,29 @@ +languageId: plaintext +command: + spokenForm: snip duplicated unique hello world + version: 2 + targets: + - {type: primitive, isImplicit: true} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: + - duplicatedVariableTest + - {unique: hello world} +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: "This variable: '' is duplicated here: '', but 'helloWorld' is unique!" + selections: + - anchor: {line: 0, character: 16} + active: {line: 0, character: 16} + - anchor: {line: 0, character: 39} + active: {line: 0, character: 39} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 69} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: toRawSelection}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunk.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunk.yml new file mode 100644 index 0000000000..17d633b9ea --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunk.yml @@ -0,0 +1,25 @@ +languageId: typescript +command: + version: 1 + spokenForm: snip funk + action: insertSnippet + targets: + - {type: primitive, isImplicit: true} + extraArgs: [functionDeclaration] +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} +finalState: + documentContents: |- + function () { + + } + selections: + - anchor: {line: 0, character: 9} + active: {line: 0, character: 9} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 2, character: 1} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: identity}}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterFineAndZip.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterFineAndZip.yml new file mode 100644 index 0000000000..372ff6ebd6 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterFineAndZip.yml @@ -0,0 +1,52 @@ +languageId: typescript +command: + spokenForm: snip funk after fine and zip + version: 2 + targets: + - type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: f} + modifiers: + - {type: position, position: after} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: z} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: |- + const foo = "bar"; + const baz = "bongo"; + selections: + - anchor: {line: 1, character: 18} + active: {line: 1, character: 18} + marks: + default.f: + start: {line: 0, character: 6} + end: {line: 0, character: 9} + default.z: + start: {line: 1, character: 6} + end: {line: 1, character: 9} +finalState: + documentContents: |- + const foo = "bar"; + function () { + + } + const baz = "bongo"; + function () { + + } + selections: + - anchor: {line: 1, character: 9} + active: {line: 1, character: 9} + - anchor: {line: 5, character: 9} + active: {line: 5, character: 9} + thatMark: + - anchor: {line: 1, character: 0} + active: {line: 3, character: 1} + - anchor: {line: 5, character: 0} + active: {line: 7, character: 1} +fullTargets: [{type: list, elements: [{type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: f}, modifiers: [{type: position, position: after}]}, {type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: z}, modifiers: [{type: position, position: after}]}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterMadeAndBeforeFineAndZip.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterMadeAndBeforeFineAndZip.yml new file mode 100644 index 0000000000..638091808a --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterMadeAndBeforeFineAndZip.yml @@ -0,0 +1,68 @@ +languageId: typescript +command: + spokenForm: snip funk after made and before fine and zip + version: 2 + targets: + - type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: m} + modifiers: + - {type: position, position: after} + - type: primitive + modifiers: + - {type: position, position: before} + mark: {type: decoratedSymbol, symbolColor: default, character: f} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: z} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: | + const foo = "bar"; + const baz = "bongo"; + const bazman = "bongo"; + selections: + - anchor: {line: 2, character: 12} + active: {line: 2, character: 12} + marks: + default.m: + start: {line: 2, character: 6} + end: {line: 2, character: 12} + default.f: + start: {line: 0, character: 6} + end: {line: 0, character: 9} + default.z: + start: {line: 1, character: 6} + end: {line: 1, character: 9} +finalState: + documentContents: | + function () { + + } + const foo = "bar"; + function () { + + } + const baz = "bongo"; + const bazman = "bongo"; + function () { + + } + selections: + - anchor: {line: 9, character: 9} + active: {line: 9, character: 9} + - anchor: {line: 0, character: 9} + active: {line: 0, character: 9} + - anchor: {line: 4, character: 9} + active: {line: 4, character: 9} + thatMark: + - anchor: {line: 9, character: 0} + active: {line: 11, character: 1} + - anchor: {line: 0, character: 0} + active: {line: 2, character: 1} + - anchor: {line: 4, character: 0} + active: {line: 6, character: 1} +fullTargets: [{type: list, elements: [{type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: m}, modifiers: [{type: position, position: after}]}, {type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: f}, modifiers: [{type: position, position: before}]}, {type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: z}, modifiers: [{type: position, position: before}]}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis.yml new file mode 100644 index 0000000000..45b502e0d0 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis.yml @@ -0,0 +1,32 @@ +languageId: typescript +command: + spokenForm: snip funk after this + version: 2 + targets: + - type: primitive + mark: {type: cursor} + modifiers: + - {type: position, position: after} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: " const foo = \"bar\";" + selections: + - anchor: {line: 0, character: 21} + active: {line: 0, character: 21} + marks: {} +finalState: + documentContents: |2- + const foo = "bar"; + function () { + + } + selections: + - anchor: {line: 1, character: 13} + active: {line: 1, character: 13} + thatMark: + - anchor: {line: 1, character: 4} + active: {line: 3, character: 5} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: position, position: after}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis2.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis2.yml new file mode 100644 index 0000000000..af08aeb105 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis2.yml @@ -0,0 +1,36 @@ +languageId: typescript +command: + spokenForm: snip funk after this + version: 2 + targets: + - type: primitive + mark: {type: cursor} + modifiers: + - {type: position, position: after} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: const foo = "bar"; + selections: + - anchor: {line: 0, character: 7} + active: {line: 0, character: 7} + - anchor: {line: 0, character: 13} + active: {line: 0, character: 13} + - anchor: {line: 0, character: 10} + active: {line: 0, character: 10} + marks: {} +finalState: + documentContents: |- + const foo = "bar"; + function () { + + } + selections: + - anchor: {line: 1, character: 9} + active: {line: 1, character: 9} + thatMark: + - anchor: {line: 1, character: 0} + active: {line: 3, character: 1} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: position, position: after}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis3.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis3.yml new file mode 100644 index 0000000000..058d47c3b0 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis3.yml @@ -0,0 +1,38 @@ +languageId: typescript +command: + spokenForm: snip funk after this + version: 2 + targets: + - type: primitive + mark: {type: cursor} + modifiers: + - {type: position, position: after} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: |- + function helloWorld() { + const whatever = "hello"; + } + selections: + - anchor: {line: 1, character: 19} + active: {line: 1, character: 19} + marks: {} +finalState: + documentContents: |- + function helloWorld() { + const whatever = "hello"; + } + + function () { + + } + selections: + - anchor: {line: 4, character: 9} + active: {line: 4, character: 9} + thatMark: + - anchor: {line: 4, character: 0} + active: {line: 6, character: 1} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [], positionModifier: {type: position, position: after}}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis4.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis4.yml new file mode 100644 index 0000000000..80fef762bd --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis4.yml @@ -0,0 +1,28 @@ +languageId: typescript +command: + version: 1 + spokenForm: snip funk after this + action: insertSnippet + targets: + - type: primitive + position: after + mark: {type: cursor} + extraArgs: [functionDeclaration] +initialState: + documentContents: const foo = "bar"; + selections: + - anchor: {line: 0, character: 12} + active: {line: 0, character: 12} +finalState: + documentContents: |- + const foo = "bar"; + function () { + + } + selections: + - anchor: {line: 1, character: 9} + active: {line: 1, character: 9} + thatMark: + - anchor: {line: 1, character: 0} + active: {line: 3, character: 1} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: after, insideOutsideType: outside, modifier: {type: containingScope, scopeType: statement, includeSiblings: false}}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterZipAndBeforeFine.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterZipAndBeforeFine.yml new file mode 100644 index 0000000000..9daf5daead --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkAfterZipAndBeforeFine.yml @@ -0,0 +1,54 @@ +languageId: typescript +command: + spokenForm: snip funk after zip and before fine + version: 2 + targets: + - type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: z} + modifiers: + - {type: position, position: after} + - type: primitive + modifiers: + - {type: position, position: before} + mark: {type: decoratedSymbol, symbolColor: default, character: f} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: |- + const foo = "bar"; + const baz = "bongo"; + selections: + - anchor: {line: 1, character: 20} + active: {line: 1, character: 20} + marks: + default.z: + start: {line: 1, character: 6} + end: {line: 1, character: 9} + default.f: + start: {line: 0, character: 6} + end: {line: 0, character: 9} +finalState: + documentContents: |- + function () { + + } + const foo = "bar"; + const baz = "bongo"; + function () { + + } + selections: + - anchor: {line: 5, character: 9} + active: {line: 5, character: 9} + - anchor: {line: 0, character: 9} + active: {line: 0, character: 9} + thatMark: + - anchor: {line: 5, character: 0} + active: {line: 7, character: 1} + - anchor: {line: 0, character: 0} + active: {line: 2, character: 1} +fullTargets: [{type: list, elements: [{type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: z}, modifiers: [{type: position, position: after}]}, {type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: f}, modifiers: [{type: position, position: before}]}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis.yml new file mode 100644 index 0000000000..6325f08388 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis.yml @@ -0,0 +1,32 @@ +languageId: typescript +command: + spokenForm: snip funk before this + version: 2 + targets: + - type: primitive + mark: {type: cursor} + modifiers: + - {type: position, position: before} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: const foo = "bar"; + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: |- + function () { + + } + const foo = "bar"; + selections: + - anchor: {line: 0, character: 9} + active: {line: 0, character: 9} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 2, character: 1} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: position, position: before}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis2.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis2.yml new file mode 100644 index 0000000000..a2d4538d45 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis2.yml @@ -0,0 +1,32 @@ +languageId: typescript +command: + spokenForm: snip funk before this + version: 2 + targets: + - type: primitive + mark: {type: cursor} + modifiers: + - {type: position, position: before} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: " const foo = \"bar\";" + selections: + - anchor: {line: 0, character: 4} + active: {line: 0, character: 4} + marks: {} +finalState: + documentContents: |2- + function () { + + } + const foo = "bar"; + selections: + - anchor: {line: 0, character: 13} + active: {line: 0, character: 13} + thatMark: + - anchor: {line: 0, character: 4} + active: {line: 2, character: 5} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: position, position: before}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis3.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis3.yml new file mode 100644 index 0000000000..b535ba5378 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis3.yml @@ -0,0 +1,36 @@ +languageId: typescript +command: + spokenForm: snip funk before this + version: 2 + targets: + - type: primitive + mark: {type: cursor} + modifiers: + - {type: position, position: before} + usePrePhraseSnapshot: true + action: + name: insertSnippet + args: [functionDeclaration] +initialState: + documentContents: const foo = "bar"; + selections: + - anchor: {line: 0, character: 7} + active: {line: 0, character: 7} + - anchor: {line: 0, character: 13} + active: {line: 0, character: 13} + - anchor: {line: 0, character: 10} + active: {line: 0, character: 10} + marks: {} +finalState: + documentContents: |- + function () { + + } + const foo = "bar"; + selections: + - anchor: {line: 0, character: 9} + active: {line: 0, character: 9} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 2, character: 1} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: position, position: before}]}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld.yml new file mode 100644 index 0000000000..417fc6b5aa --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld.yml @@ -0,0 +1,27 @@ +languageId: typescript +command: + version: 1 + spokenForm: snip funk hello world + action: insertSnippet + targets: + - {type: primitive, isImplicit: true} + extraArgs: + - functionDeclaration + - {name: hello world} +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} +finalState: + documentContents: |- + function helloWorld() { + + } + selections: + - anchor: {line: 0, character: 20} + active: {line: 0, character: 20} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 2, character: 1} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: identity}}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld2.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld2.yml new file mode 100644 index 0000000000..e73e00e0d1 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipFunkHelloWorld2.yml @@ -0,0 +1,26 @@ +languageId: python +command: + version: 1 + spokenForm: snip funk hello world + action: insertSnippet + targets: + - {type: primitive, isImplicit: true} + extraArgs: + - functionDeclaration + - {name: hello world} +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} +finalState: + documentContents: |- + def hello_world(): + + selections: + - anchor: {line: 0, character: 16} + active: {line: 0, character: 16} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 1, character: 4} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: identity}}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipIf.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipIf.yml new file mode 100644 index 0000000000..23fd3d96dd --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipIf.yml @@ -0,0 +1,31 @@ +languageId: typescript +command: + version: 1 + spokenForm: snip if + action: insertSnippet + targets: + - {type: primitive, isImplicit: true} + extraArgs: [ifStatement] +initialState: + documentContents: |- + function whatever() { + + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + marks: {} +finalState: + documentContents: |- + function whatever() { + if () { + + } + } + selections: + - anchor: {line: 1, character: 8} + active: {line: 1, character: 8} + thatMark: + - anchor: {line: 1, character: 4} + active: {line: 3, character: 5} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: identity}, isImplicit: true}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipSpaghetti.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipSpaghetti.yml new file mode 100644 index 0000000000..3393f857b6 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipSpaghetti.yml @@ -0,0 +1,23 @@ +languageId: plaintext +command: + version: 1 + spokenForm: snip spaghetti + action: insertSnippet + targets: + - {type: primitive, isImplicit: true} + extraArgs: [spaghetti] +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: My friend likes to eat spaghetti! + selections: + - anchor: {line: 0, character: 10} + active: {line: 0, character: 10} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 34} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: identity}}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/snipSpaghettiGraceHopper.yml b/src/test/suite/fixtures/recorded/actions/snippets/snipSpaghettiGraceHopper.yml new file mode 100644 index 0000000000..adeecc78d3 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/snipSpaghettiGraceHopper.yml @@ -0,0 +1,25 @@ +languageId: plaintext +command: + version: 1 + spokenForm: snip spaghetti grace hopper + action: insertSnippet + targets: + - {type: primitive, isImplicit: true} + extraArgs: + - spaghetti + - {foo: grace hopper} +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: My friend grace_hopper likes to eat spaghetti! + selections: + - anchor: {line: 0, character: 46} + active: {line: 0, character: 46} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 46} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: identity}}] diff --git a/src/test/suite/fixtures/recorded/actions/snippets/spaghettiWrapPastGust.yml b/src/test/suite/fixtures/recorded/actions/snippets/spaghettiWrapPastGust.yml new file mode 100644 index 0000000000..3f6142bcb9 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snippets/spaghettiWrapPastGust.yml @@ -0,0 +1,32 @@ +languageId: plaintext +command: + version: 1 + spokenForm: spaghetti wrap past gust + action: wrapWithSnippet + targets: + - type: range + start: {type: primitive} + end: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: g} + excludeStart: false + excludeEnd: false + extraArgs: [spaghetti.foo] +initialState: + documentContents: grace hopper + selections: + - anchor: {line: 0, character: 12} + active: {line: 0, character: 12} + marks: + default.g: + start: {line: 0, character: 0} + end: {line: 0, character: 5} +finalState: + documentContents: My friend grace hopper likes to eat spaghetti! + selections: + - anchor: {line: 0, character: 46} + active: {line: 0, character: 46} + thatMark: + - anchor: {line: 0, character: 46} + active: {line: 0, character: 0} +fullTargets: [{type: range, excludeAnchor: false, excludeActive: false, anchor: {type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: identity}}, active: {type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: g}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: identity}}}] diff --git a/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndHarpToEndOfSecondCarWhaleAndEndOfJustWhaleTakeWhale.yml b/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndHarpToEndOfSecondCarWhaleAndEndOfJustWhaleTakeWhale.yml index a71f9edab7..972e004ca2 100644 --- a/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndHarpToEndOfSecondCarWhaleAndEndOfJustWhaleTakeWhale.yml +++ b/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndHarpToEndOfSecondCarWhaleAndEndOfJustWhaleTakeWhale.yml @@ -1,6 +1,8 @@ languageId: plaintext command: - spokenForm: bring point and harp to end of second car whale and end of just whale take whale + spokenForm: >- + bring point and harp to end of second car whale and end of just whale take + whale version: 2 targets: - type: list diff --git a/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndHarpToStartOfSecondCarWhaleAndStartOfJustWhaleTakeWhale.yml b/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndHarpToStartOfSecondCarWhaleAndStartOfJustWhaleTakeWhale.yml index 98685ec465..ec39d17d8c 100644 --- a/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndHarpToStartOfSecondCarWhaleAndStartOfJustWhaleTakeWhale.yml +++ b/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndHarpToStartOfSecondCarWhaleAndStartOfJustWhaleTakeWhale.yml @@ -1,8 +1,8 @@ languageId: plaintext command: spokenForm: >- - bring point and harp to start of second car whale and start of just whale take - whale + bring point and harp to start of second car whale and start of just whale + take whale version: 2 targets: - type: list diff --git a/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndPointToEndOfSecondCarWhaleAndEndOfJustWhaleTakeWhale.yml b/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndPointToEndOfSecondCarWhaleAndEndOfJustWhaleTakeWhale.yml index 5533531e76..8d52268586 100644 --- a/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndPointToEndOfSecondCarWhaleAndEndOfJustWhaleTakeWhale.yml +++ b/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndPointToEndOfSecondCarWhaleAndEndOfJustWhaleTakeWhale.yml @@ -1,6 +1,8 @@ languageId: plaintext command: - spokenForm: bring point and point to end of second car whale and end of just whale take whale + spokenForm: >- + bring point and point to end of second car whale and end of just whale take + whale version: 2 targets: - type: list diff --git a/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndPointToStartOfSecondCarWhaleAndStartOfJustWhaleTakeWhale.yml b/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndPointToStartOfSecondCarWhaleAndStartOfJustWhaleTakeWhale.yml index bc88db91a8..7715c019c4 100644 --- a/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndPointToStartOfSecondCarWhaleAndStartOfJustWhaleTakeWhale.yml +++ b/src/test/suite/fixtures/recorded/hatTokenMap/bringPointAndPointToStartOfSecondCarWhaleAndStartOfJustWhaleTakeWhale.yml @@ -1,8 +1,8 @@ languageId: plaintext command: spokenForm: >- - bring point and point to start of second car whale and start of just whale take - whale + bring point and point to start of second car whale and start of just whale + take whale version: 2 targets: - type: list diff --git a/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis.yml b/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis.yml index f0cb2a87b6..fbeb169c80 100644 --- a/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis.yml +++ b/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis.yml @@ -17,12 +17,12 @@ finalState: documentContents: |- try { const foo = "hello"; - } catch () { + } catch (err) { } selections: - - anchor: {line: 2, character: 9} - active: {line: 2, character: 9} + - anchor: {line: 3, character: 4} + active: {line: 3, character: 4} thatMark: - anchor: {line: 0, character: 0} active: {line: 4, character: 1} diff --git a/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis2.yml b/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis2.yml index b277447c0a..fbbb292760 100644 --- a/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis2.yml +++ b/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis2.yml @@ -26,20 +26,20 @@ finalState: if (true) { const foo = "hello"; } - } catch () { + } catch (err) { } try { const bar = "hello"; - } catch () { + } catch (err) { } selections: - - anchor: {line: 10, character: 9} - active: {line: 10, character: 9} - - anchor: {line: 4, character: 9} - active: {line: 4, character: 9} + - anchor: {line: 11, character: 4} + active: {line: 11, character: 4} + - anchor: {line: 5, character: 4} + active: {line: 5, character: 4} thatMark: - anchor: {line: 8, character: 0} active: {line: 12, character: 1} diff --git a/src/typings/Types.ts b/src/typings/Types.ts index 00cc00bcef..b0446fd02b 100644 --- a/src/typings/Types.ts +++ b/src/typings/Types.ts @@ -24,7 +24,16 @@ export interface Token extends FullRangeInfo { } export interface ProcessedTargetsContext { - finalStages: ModifierStage[]; + /** + * Modifier stages contributed by the action that should run before the final + * positional stage, if there is one + */ + actionPrePositionStages: ModifierStage[]; + /** + * Modifier stages contributed by the action that should run at the end of the + * modifier pipeline + */ + actionFinalStages: ModifierStage[]; currentSelections: SelectionWithEditor[]; currentEditor: vscode.TextEditor | undefined; hatTokenMap: ReadOnlyHatMap; @@ -184,3 +193,9 @@ export interface Edit { export interface EditWithRangeUpdater extends Edit { updateRange: (range: vscode.Range) => vscode.Range; } + +export type TextFormatterName = + | "camelCase" + | "pascalCase" + | "snakeCase" + | "upperSnakeCase"; diff --git a/src/typings/snippet.ts b/src/typings/snippet.ts index 940a2f9391..d71cf94696 100644 --- a/src/typings/snippet.ts +++ b/src/typings/snippet.ts @@ -1,19 +1,38 @@ -import { ScopeType, SimpleScopeTypeType } from "./targetDescriptor.types"; +import { SimpleScopeTypeType } from "./targetDescriptor.types"; +import { TextFormatterName } from "./Types"; export interface SnippetScope { + /** + * VSCode language ids where this snippet definition should be active + */ langIds?: string[]; - scopeType?: ScopeType; + + /** + * Cursorless scopes in which this snippet is active. Allows, for example, to + * have different snippets to define a function if you're in a class or at + * global scope. + */ + scopeTypes?: SimpleScopeTypeType[]; } export type SnippetBody = string[]; export interface SnippetDefinition { + /** + * Inline snippet text using VSCode snippet syntax; entries joined by newline. + * Named variables of the form `$foo` can be used as placeholders + */ body: SnippetBody; /** * Scopes where this snippet is active */ scope?: SnippetScope; + + /** + * Scope-specific overrides for the variables defined in the snippet + */ + variables?: Record; } export interface SnippetVariable { @@ -27,6 +46,11 @@ export interface SnippetVariable { * Description of the snippet variable */ description?: string; + + /** + * Format text inserted into this variable using the given formatter + */ + formatter?: TextFormatterName; } export interface Snippet { @@ -44,6 +68,13 @@ export interface Snippet { * Description of the snippet */ description?: string; + + /** + * Try to expand target to this scope type when inserting this snippet + * before/after a target without scope type specified. If multiple scope types + * are specified try them each in order until one of them matches. + */ + insertionScopeTypes?: SimpleScopeTypeType[]; } export type SnippetMap = Record; diff --git a/src/typings/targetDescriptor.types.ts b/src/typings/targetDescriptor.types.ts index a780cf852e..5590fc3750 100644 --- a/src/typings/targetDescriptor.types.ts +++ b/src/typings/targetDescriptor.types.ts @@ -192,6 +192,32 @@ export interface HeadTailModifier { modifiers?: Modifier[]; } +/** + * Runs {@link modifier} if the target is weak. + */ +export interface ModifyIfWeakModifier { + type: "modifyIfWeak"; + + /** + * The modifier to apply if the target is weak + */ + 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. + */ +export interface CascadingModifier { + type: "cascading"; + + /** + * The modifiers to try in turn + */ + modifiers: Modifier[]; +} + export type Modifier = | PositionModifier | InteriorOnlyModifier @@ -202,7 +228,9 @@ export type Modifier = | HeadTailModifier | LeadingModifier | TrailingModifier - | RawSelectionModifier; + | RawSelectionModifier + | ModifyIfWeakModifier + | CascadingModifier; export interface PartialRangeTargetDescriptor { type: "range"; @@ -239,6 +267,13 @@ export interface PrimitiveTargetDescriptor * character of the name. */ modifiers: Modifier[]; + + /** + * We separate the positional modifier from the other modifiers because it + * behaves differently and and makes the target behave like a destination for + * example for bring. This change is the first step toward #803 + */ + positionModifier?: PositionModifier; } export interface RangeTargetDescriptor { diff --git a/src/util/snippet.ts b/src/util/snippet.ts new file mode 100644 index 0000000000..2301f3e8fe --- /dev/null +++ b/src/util/snippet.ts @@ -0,0 +1,131 @@ +import { SnippetDefinition } from "../typings/snippet"; +import { Target } from "../typings/target.types"; +import { + Placeholder, + Text, + TextmateSnippet, + Variable, +} from "../vendor/snippet/snippetParser"; +import { KnownSnippetVariableNames } from "../vendor/snippet/snippetVariables"; + +/** + * Replaces the snippet variable with name `placeholderName` with + * TM_SELECTED_TEXT + * + * Also replaces any unknown variables with placeholders. We do this so it's + * easier to leave one of the placeholders blank. We may make it so that you can + * disable this with a setting in the future + * @param parsedSnippet The parsed textmate snippet to operate on + * @param placeholderName The variable name to replace with TM_SELECTED_TEXT + * @param substitutions A map from variable names to text values that will be + * substituted and the given variable will no longer be a placeholder in the + * final snippet + */ +export function transformSnippetVariables( + parsedSnippet: TextmateSnippet, + placeholderName?: string | null, + substitutions?: Record +): void { + let nextPlaceholderIndex = getMaxPlaceholderIndex(parsedSnippet) + 1; + const placeholderIndexMap: Record = {}; + + parsedSnippet.walk((candidate) => { + if (candidate instanceof Variable) { + if (candidate.name === placeholderName) { + candidate.name = "TM_SELECTED_TEXT"; + } else if ( + substitutions != null && + substitutions.hasOwnProperty(candidate.name) + ) { + candidate.parent.replace(candidate, [ + new Text(substitutions[candidate.name]), + ]); + } else if (!KnownSnippetVariableNames[candidate.name]) { + let placeholderIndex: number; + if (candidate.name in placeholderIndexMap) { + placeholderIndex = placeholderIndexMap[candidate.name]; + } else { + placeholderIndex = nextPlaceholderIndex++; + placeholderIndexMap[candidate.name] = placeholderIndex; + } + const placeholder = new Placeholder(placeholderIndex); + candidate.children.forEach((child) => placeholder.appendChild(child)); + candidate.parent.replace(candidate, [placeholder]); + } + } + return true; + }); +} + +/** + * Returns the highest placeholder index in the given snippet + * @param parsedSnippet The parsed textmate snippet + * @returns The highest placeholder index in the given snippet + */ +function getMaxPlaceholderIndex(parsedSnippet: TextmateSnippet): number { + var placeholderIndex = 0; + parsedSnippet.walk((candidate) => { + if (candidate instanceof Placeholder) { + placeholderIndex = Math.max(placeholderIndex, candidate.index); + } + return true; + }); + return placeholderIndex; +} + +/** + * Based on the context determined by {@link targets} (eg the file's language + * id and containing scope), finds the first snippet definition that matches the + * given context. Throws an error if different snippet definitions match for + * different targets or if matching snippet definition could not be found + * @param targets The target that defines the context to use for finding the + * right snippet definition + * @param definitions The list of snippet definitions to search + * @returns The snippet definition that matches the given context + */ +export function findMatchingSnippetDefinitionStrict( + targets: Target[], + definitions: SnippetDefinition[] +): SnippetDefinition { + const definitionIndices = targets.map((target) => + findMatchingSnippetDefinitionForSingleTarget(target, definitions) + ); + + const definitionIndex = definitionIndices[0]; + + if (!definitionIndices.every((index) => index === definitionIndex)) { + throw new Error("Multiple snippet definitions match the given context"); + } + + if (definitionIndex === -1) { + throw new Error("Couldn't find matching snippet definition"); + } + + return definitions[definitionIndex]; +} + +function findMatchingSnippetDefinitionForSingleTarget( + target: Target, + definitions: SnippetDefinition[] +): number { + const languageId = target.editor.document.languageId; + + return definitions.findIndex(({ scope }) => { + if (scope == null) { + return true; + } + + const { langIds, scopeTypes } = scope; + + if (langIds != null && !langIds.includes(languageId)) { + return false; + } + + if (scopeTypes != null) { + // TODO: Implement this; see #802 + throw new Error("Scope types not yet implemented"); + } + + return true; + }); +}