diff --git a/cursorless-snippets/functionDeclaration.cursorless-snippets b/cursorless-snippets/functionDeclaration.cursorless-snippets new file mode 100644 index 00000000000..e559224344f --- /dev/null +++ b/cursorless-snippets/functionDeclaration.cursorless-snippets @@ -0,0 +1,49 @@ +{ + "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" + } + }, + "insertionScopeType": "statement" + } +} \ No newline at end of file diff --git a/cursorless-snippets/ifElseStatement.cursorless-snippets b/cursorless-snippets/ifElseStatement.cursorless-snippets index fa05102ce14..8f61aa6ca64 100644 --- a/cursorless-snippets/ifElseStatement.cursorless-snippets +++ b/cursorless-snippets/ifElseStatement.cursorless-snippets @@ -44,6 +44,7 @@ "alternative": { "wrapperScopeType": "statement" } - } + }, + "insertionScopeType": "statement" } -} +} \ No newline at end of file diff --git a/cursorless-snippets/ifStatement.cursorless-snippets b/cursorless-snippets/ifStatement.cursorless-snippets index 3eb229ea3ed..6e7a36a9031 100644 --- a/cursorless-snippets/ifStatement.cursorless-snippets +++ b/cursorless-snippets/ifStatement.cursorless-snippets @@ -37,6 +37,7 @@ "consequence": { "wrapperScopeType": "statement" } - } + }, + "insertionScopeType": "statement" } -} +} \ No newline at end of file diff --git a/cursorless-snippets/tryCatchStatement.cursorless-snippets b/cursorless-snippets/tryCatchStatement.cursorless-snippets index 4c0155f0a51..2c8bbac1abd 100644 --- a/cursorless-snippets/tryCatchStatement.cursorless-snippets +++ b/cursorless-snippets/tryCatchStatement.cursorless-snippets @@ -44,6 +44,7 @@ "exceptBody": { "wrapperScopeType": "statement" } - } + }, + "insertionScopeType": "statement" } -} +} \ No newline at end of file diff --git a/schemas/cursorless-snippets.json b/schemas/cursorless-snippets.json index 0a1e6488136..b70b87d4e78 100644 --- a/schemas/cursorless-snippets.json +++ b/schemas/cursorless-snippets.json @@ -9,59 +9,22 @@ "type": "array", "descriptions": "List of possible definitions for this snippet", "items": { - "type": "object", - "properties": { - "scope": { - "type": "object", - "description": "Scopes where this snippet is active", - "properties": { - "langIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "scopeType": { - "$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." - } - } - }, - "body": { - "type": "array", - "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" - } - }, - "required": [ - "body" - ] + "$ref": "#/$defs/snippetDefinition" } }, "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": { "type": "string", "description": "Description of the snippet" + }, + "insertionScopeType": { + "$ref": "#/$defs/scopeType", + "description": "Default to this scope type when inserting this snippet before/after a target without scope type specified" } - } + }, + "additionalProperties": false }, "$defs": { "scopeType": { @@ -92,6 +55,69 @@ "xmlEndTag", "xmlStartTag" ] + }, + "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", + "enum": [ + "camelCase", + "pascalCase", + "snakeCase" + ], + "description": "Format text inserted into this variable using the given formatter" + } + }, + "additionalProperties": false + } + }, + "snippetDefinition": { + "type": "object", + "properties": { + "scope": { + "type": "object", + "description": "Scopes where this snippet is active", + "properties": { + "langIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "scopeType": { + "$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." + } + }, + "additionalProperties": false + }, + "body": { + "type": "array", + "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" + }, + "variables": { + "$ref": "#/$defs/variables" + } + }, + "additionalProperties": false, + "required": [ + "body" + ] } } } \ No newline at end of file diff --git a/src/actions/InsertSnippet.ts b/src/actions/InsertSnippet.ts new file mode 100644 index 00000000000..0b4e1308f11 --- /dev/null +++ b/src/actions/InsertSnippet.ts @@ -0,0 +1,159 @@ +import { commands, DecorationRangeBehavior } from "vscode"; +import { + Action, + ActionPreferences, + ActionReturnValue, + Graph, + TypedSelection, +} from "../typings/Types"; +import displayPendingEditDecorations from "../util/editDisplayUtils"; +import { ensureSingleEditor } from "../util/targetUtils"; +import { SnippetParser } from "../vendor/snippet/snippetParser"; +import { + findMatchingSnippetDefinition, + transformSnippetVariables, +} from "../util/snippet"; +import textFormatters from "../core/textFormatters"; +import { SnippetDefinition, Snippet } from "../typings/snippet"; +import { + callFunctionAndUpdateSelectionInfos, + callFunctionAndUpdateSelections, + getSelectionInfo, +} from "../core/updateSelections/updateSelections"; + +export default class InsertSnippet implements Action { + private snippetParser = new SnippetParser(); + + getTargetPreferences(snippetName: string): ActionPreferences[] { + const snippet = this.graph.snippets.getSnippet(snippetName); + + if (snippet == null) { + throw new Error(`Couldn't find snippet ${snippetName}`); + } + + const defaultScopeType = snippet.insertionScopeType; + + return [ + { + insideOutsideType: "outside", + modifier: + defaultScopeType == null + ? undefined + : { + type: "containingScope", + scopeType: defaultScopeType, + includeSiblings: false, + }, + }, + ]; + } + + constructor(private graph: Graph) { + this.run = this.run.bind(this); + } + + async run( + [targets]: [TypedSelection[]], + snippetName: string, + substitutions: Record + ): Promise { + const snippet = this.graph.snippets.getSnippet(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], + snippet.definitions + ); + + if (definition == null) { + throw new Error("Couldn't find matching snippet definition"); + } + + 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 displayPendingEditDecorations( + targets, + this.graph.editStyles.pendingModification0 + ); + + const targetSelections = targets.map( + (target) => target.selection.selection + ); + + // TODO: Fix "insert before" once we have the new update selections code + // TODO: Remove undo stop once we have the new update selections code + await this.graph.actions.setSelection.run([targets]); + + // NB: We do this to auto insert the delimiter if necessary + await this.graph.actions.replace.run([targets], [""]); + + const targetSelectionInfos = targetSelections.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, + })), + }; + } +} +function formatSubstitutions( + snippet: Snippet, + definition: SnippetDefinition, + substitutions: Record +) { + return Object.fromEntries( + Object.entries(substitutions).map(([variableName, value]) => { + 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 953ba4cd092..d6e95dbc4fd 100644 --- a/src/actions/Replace.ts +++ b/src/actions/Replace.ts @@ -13,7 +13,7 @@ import { performEditsAndUpdateSelections } from "../core/updateSelections/update type RangeGenerator = { start: number }; -export default class implements Action { +export default class Replace implements Action { getTargetPreferences: () => ActionPreferences[] = () => [ { insideOutsideType: null }, ]; diff --git a/src/actions/WrapWithSnippet.ts b/src/actions/WrapWithSnippet.ts index 0fc160c4c63..cd2761ad281 100644 --- a/src/actions/WrapWithSnippet.ts +++ b/src/actions/WrapWithSnippet.ts @@ -1,6 +1,4 @@ import { commands } from "vscode"; -import { callFunctionAndUpdateSelections } from "../core/updateSelections/updateSelections"; -import { SnippetDefinition } from "../typings/snippet"; import { Action, ActionPreferences, @@ -10,13 +8,13 @@ import { } from "../typings/Types"; import displayPendingEditDecorations from "../util/editDisplayUtils"; import { ensureSingleEditor } from "../util/targetUtils"; +import { callFunctionAndUpdateSelections } from "../core/updateSelections/updateSelections"; +import { SnippetParser } from "../vendor/snippet/snippetParser"; import { - Placeholder, - SnippetParser, - TextmateSnippet, - Variable, -} from "../vendor/snippet/snippetParser"; -import { KnownSnippetVariableNames } from "../vendor/snippet/snippetVariables"; + parseSnippetLocation, + findMatchingSnippetDefinition, + transformSnippetVariables, +} from "../util/snippet"; export default class WrapWithSnippet implements Action { private snippetParser = new SnippetParser(); @@ -115,77 +113,3 @@ 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) { - throw new Error("Snippet location missing '.'"); - } - return [snippetName, placeholderName]; -} - -function findMatchingSnippetDefinition( - typedSelection: TypedSelection, - definitions: SnippetDefinition[] -) { - const languageId = typedSelection.selection.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/index.ts b/src/actions/index.ts index 07659408ec5..6e0e91b2698 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -28,6 +28,7 @@ import SetBreakpoint from "./SetBreakpoint"; import { Sort, Reverse } from "./Sort"; import Call from "./Call"; import WrapWithSnippet from "./WrapWithSnippet"; +import InsertSnippet from "./InsertSnippet"; class Actions implements ActionRecord { constructor(private graph: Graph) {} @@ -48,6 +49,7 @@ class Actions implements ActionRecord { insertEmptyLineAfter = new InsertEmptyLineBelow(this.graph); insertEmptyLineBefore = new InsertEmptyLineAbove(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/core/Snippets.ts b/src/core/Snippets.ts index 12457bcad7f..65f4aacc6b5 100644 --- a/src/core/Snippets.ts +++ b/src/core/Snippets.ts @@ -1,11 +1,12 @@ 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 { walkFiles } from "../testUtil/walkAsync"; import { Snippet, SnippetMap } from "../typings/snippet"; import { Graph } from "../typings/Types"; import { mergeStrict } from "../util/object"; +import { CURSORLESS_SNIPPETS_SUFFIX } from "./constants"; const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000; @@ -34,6 +35,8 @@ export class Snippets { */ private maxSnippetMtimeMs: number = -1; + private shownErrorMessageForDir: string | null | undefined = null; + constructor(private graph: Graph) { this.updateUserSnippetsPath(); @@ -63,7 +66,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 +84,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 (process.env.CURSORLESS_TEST != null) { + 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 +114,25 @@ 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.shownErrorMessageForDir !== this.userSnippetsDir) { + window.showErrorMessage( + `Error with cursorless snippets dir "${this.userSnippetsDir}": ${ + (err as Error).message + }` + ); + } + + this.shownErrorMessageForDir = this.userSnippetsDir; + return; + } + + this.shownErrorMessageForDir = null; const maxSnippetMtime = max( @@ -117,9 +149,24 @@ 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) { + return {}; + } + + return JSON.parse(content); + } catch (err) { + window.showErrorMessage( + `Error with cursorless snippets file "${path}": ${ + (err as Error).message + }` + ); + return {}; + } + }) )) ); @@ -185,3 +232,9 @@ export class Snippets { return this.mergedSnippets[snippetName]; } } + +async function getSnippetPaths(snippetsDir: string) { + return (await walkFiles(snippetsDir)).filter((path) => + path.endsWith(CURSORLESS_SNIPPETS_SUFFIX) + ); +} diff --git a/src/core/constants.ts b/src/core/constants.ts index 9dbd446e496..8b0e3910fc0 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -1,3 +1,5 @@ +export const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; + export const SUBWORD_MATCHER = /[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+/g; export const DECORATION_DEBOUNCE_DELAY = 175; diff --git a/src/core/textFormatters.ts b/src/core/textFormatters.ts new file mode 100644 index 00000000000..0bfab8c6766 --- /dev/null +++ b/src/core/textFormatters.ts @@ -0,0 +1,28 @@ +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("_"); + }, + + 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/test/suite/fixtures/cursorless-snippets/spaghetti.cursorless-snippets b/src/test/suite/fixtures/cursorless-snippets/spaghetti.cursorless-snippets new file mode 100644 index 00000000000..50167cbfd45 --- /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" + } +} \ No newline at end of file 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 00000000000..982083b360c --- /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", + "}" + ] + } + ] + } +} \ No newline at end of file diff --git a/src/test/suite/fixtures/recorded/actions/snipFunk.yml b/src/test/suite/fixtures/recorded/actions/snipFunk.yml new file mode 100644 index 00000000000..e8d39da25d4 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snipFunk.yml @@ -0,0 +1,30 @@ +spokenForm: snip funk +languageId: typescript +command: + actionName: insertSnippet + partialTargets: + - type: primitive + mark: {type: cursor} + selectionType: token + position: contents + modifier: {type: identity} + insideOutsideType: inside + extraArgs: [functionDeclaration] +marks: {} +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/snipFunkAfterThis.yml b/src/test/suite/fixtures/recorded/actions/snipFunkAfterThis.yml new file mode 100644 index 00000000000..399613244ae --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snipFunkAfterThis.yml @@ -0,0 +1,27 @@ +spokenForm: snip funk after this +languageId: typescript +command: + actionName: insertSnippet + partialTargets: + - type: primitive + position: after + mark: {type: cursor} + extraArgs: [functionDeclaration] +marks: {} +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: 0, character: 28} + active: {line: 0, character: 28} + thatMark: + - anchor: {line: 0, character: 18} + active: {line: 0, character: 18} +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/snipFunkHelloWorld.yml b/src/test/suite/fixtures/recorded/actions/snipFunkHelloWorld.yml new file mode 100644 index 00000000000..13355813570 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snipFunkHelloWorld.yml @@ -0,0 +1,32 @@ +spokenForm: snip funk hello world +languageId: typescript +command: + actionName: insertSnippet + partialTargets: + - type: primitive + mark: {type: cursor} + selectionType: token + position: contents + modifier: {type: identity} + insideOutsideType: inside + extraArgs: + - functionDeclaration + - {name: hello world} +marks: {} +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/snipFunkHelloWorld2.yml b/src/test/suite/fixtures/recorded/actions/snipFunkHelloWorld2.yml new file mode 100644 index 00000000000..9acb36d2126 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/snipFunkHelloWorld2.yml @@ -0,0 +1,31 @@ +spokenForm: snip funk hello world +languageId: python +command: + actionName: insertSnippet + partialTargets: + - type: primitive + mark: {type: cursor} + selectionType: token + position: contents + modifier: {type: identity} + insideOutsideType: inside + extraArgs: + - functionDeclaration + - {name: hello world} +marks: {} +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/languages/typescript/tryCatchWrapThis.yml b/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis.yml index 2a06a44a396..f6daef04e2b 100644 --- a/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis.yml +++ b/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis.yml @@ -16,12 +16,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 837fd8170cf..8e0d612595a 100644 --- a/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis2.yml +++ b/src/test/suite/fixtures/recorded/languages/typescript/tryCatchWrapThis2.yml @@ -25,20 +25,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/test/suite/fixtures/recorded/snippets/snipSpaghetti.yml b/src/test/suite/fixtures/recorded/snippets/snipSpaghetti.yml new file mode 100644 index 00000000000..233e3f8d9ef --- /dev/null +++ b/src/test/suite/fixtures/recorded/snippets/snipSpaghetti.yml @@ -0,0 +1,27 @@ +spokenForm: snip spaghetti +languageId: plaintext +command: + actionName: insertSnippet + partialTargets: + - type: primitive + mark: {type: cursor} + selectionType: token + position: contents + modifier: {type: identity} + insideOutsideType: inside + 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/snippets/snipSpaghettiGraceHopper.yml b/src/test/suite/fixtures/recorded/snippets/snipSpaghettiGraceHopper.yml new file mode 100644 index 00000000000..2b8114e26eb --- /dev/null +++ b/src/test/suite/fixtures/recorded/snippets/snipSpaghettiGraceHopper.yml @@ -0,0 +1,29 @@ +spokenForm: snip spaghetti grace hopper +languageId: plaintext +command: + actionName: insertSnippet + partialTargets: + - type: primitive + mark: {type: cursor} + selectionType: token + position: contents + modifier: {type: identity} + insideOutsideType: inside + 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/snippets/spaghettiWrapPastGust.yml b/src/test/suite/fixtures/recorded/snippets/spaghettiWrapPastGust.yml new file mode 100644 index 00000000000..99932535a9e --- /dev/null +++ b/src/test/suite/fixtures/recorded/snippets/spaghettiWrapPastGust.yml @@ -0,0 +1,31 @@ +spokenForm: spaghetti wrap past gust +languageId: plaintext +command: + actionName: wrapWithSnippet + partialTargets: + - 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/typings/Types.ts b/src/typings/Types.ts index fa6cf760b86..8d00eafdbf8 100644 --- a/src/typings/Types.ts +++ b/src/typings/Types.ts @@ -311,6 +311,7 @@ export type ActionType = | "insertEmptyLineAfter" | "insertEmptyLineBefore" | "insertEmptyLinesAround" + | "insertSnippet" | "moveToTarget" | "outdentLine" | "pasteFromClipboard" @@ -422,3 +423,5 @@ export interface Edit { */ isReplace?: boolean; } + +export type TextFormatterName = "camelCase" | "pascalCase" | "snakeCase"; diff --git a/src/typings/snippet.ts b/src/typings/snippet.ts index 4eb7ef3c082..5213c348dab 100644 --- a/src/typings/snippet.ts +++ b/src/typings/snippet.ts @@ -1,4 +1,4 @@ -import { ScopeType } from "./Types"; +import { ScopeType, TextFormatterName } from "./Types"; export interface SnippetScope { langIds?: string[]; @@ -14,6 +14,11 @@ export interface SnippetDefinition { * Scopes where this snippet is active */ scope?: SnippetScope; + + /** + * Scope-specific overrides for the variable + */ + variables?: Record; } export interface SnippetVariable { @@ -27,6 +32,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 +54,12 @@ export interface Snippet { * Description of the snippet */ description?: string; + + /** + * Default to this scope type when inserting this snippet before/after a + * target without scope type specified + */ + insertionScopeType?: ScopeType; } export type SnippetMap = Record; diff --git a/src/util/snippet.ts b/src/util/snippet.ts new file mode 100644 index 00000000000..0ddedd1f2a4 --- /dev/null +++ b/src/util/snippet.ts @@ -0,0 +1,93 @@ +import { SnippetDefinition } from "../typings/snippet"; +import { TypedSelection } from "../typings/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 + */ +export function transformSnippetVariables( + parsedSnippet: TextmateSnippet, + placeholderName?: string | null, + substitutions?: Record +) { + var placeholderIndex = getMaxPlaceholderIndex(parsedSnippet) + 1; + + 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]) { + const placeholder = new Placeholder(placeholderIndex++); + candidate.children.forEach((child) => placeholder.appendChild(child)); + candidate.parent.replace(candidate, [placeholder]); + } + } + return true; + }); +} + +export function getMaxPlaceholderIndex(parsedSnippet: TextmateSnippet) { + var placeholderIndex = 0; + parsedSnippet.walk((candidate) => { + if (candidate instanceof Placeholder) { + placeholderIndex = Math.max(placeholderIndex, candidate.index); + } + return true; + }); + return placeholderIndex; +} + +export function parseSnippetLocation( + snippetLocation: string +): [string, string] { + const [snippetName, placeholderName] = snippetLocation.split("."); + if (snippetName == null || placeholderName == null) { + throw new Error("Snippet location missing '.'"); + } + return [snippetName, placeholderName]; +} + +export function findMatchingSnippetDefinition( + typedSelection: TypedSelection, + definitions: SnippetDefinition[] +) { + const languageId = typedSelection.selection.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; + }); +}