diff --git a/cursorless-snippets/functionDeclaration.cursorless-snippets b/cursorless-snippets/functionDeclaration.cursorless-snippets index c57443bd35..a87d9a93b6 100644 --- a/cursorless-snippets/functionDeclaration.cursorless-snippets +++ b/cursorless-snippets/functionDeclaration.cursorless-snippets @@ -21,6 +21,32 @@ } } }, + { + "scope": { + "langIds": [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact" + ], + "scopeTypes": [ + "class" + ], + "excludeDescendantScopeTypes": [ + "namedFunction" + ] + }, + "body": [ + "$name($parameterList) {", + "\t$body", + "}" + ], + "variables": { + "name": { + "formatter": "camelCase" + } + } + }, { "scope": { "langIds": [ @@ -36,6 +62,28 @@ "formatter": "snakeCase" } } + }, + { + "scope": { + "langIds": [ + "python" + ], + "scopeTypes": [ + "class" + ], + "excludeDescendantScopeTypes": [ + "namedFunction" + ] + }, + "body": [ + "def $name(self${parameterList:, }):", + "\t$body" + ], + "variables": { + "name": { + "formatter": "snakeCase" + } + } } ], "description": "Function declaration", diff --git a/packages/common/src/types/snippet.types.ts b/packages/common/src/types/snippet.types.ts index f25b28e402..9145a888db 100644 --- a/packages/common/src/types/snippet.types.ts +++ b/packages/common/src/types/snippet.types.ts @@ -13,6 +13,14 @@ export interface SnippetScope { * global scope. */ scopeTypes?: SimpleScopeTypeType[]; + + /** + * Exclude regions of {@link scopeTypes} that are descendants of these scope + * types. For example, if you have a snippet that should be active in a class + * but not in a function within the class, you can specify + * `scopeTypes: ["class"], excludeDescendantScopeTypes: ["namedFunction"]`. + */ + excludeDescendantScopeTypes?: SimpleScopeTypeType[]; } export type SnippetBody = string[]; diff --git a/packages/cursorless-engine/src/actions/InsertSnippet.ts b/packages/cursorless-engine/src/actions/InsertSnippet.ts index cb186043d0..948a0a8506 100644 --- a/packages/cursorless-engine/src/actions/InsertSnippet.ts +++ b/packages/cursorless-engine/src/actions/InsertSnippet.ts @@ -23,6 +23,7 @@ import { Target } from "../typings/target.types"; import { ensureSingleEditor } from "../util/targetUtils"; import { Actions } from "./Actions"; import { Action, ActionReturnValue } from "./actions.types"; +import { UntypedTarget } from "../processTargets/targets"; interface NamedSnippetArg { type: "named"; @@ -94,6 +95,7 @@ export default class InsertSnippet implements Action { const snippet = this.snippets.getSnippetStrict(name); const definition = findMatchingSnippetDefinitionStrict( + this.modifierStageFactory, targets, snippet.definitions, ); @@ -124,9 +126,28 @@ export default class InsertSnippet implements Action { ): Promise { const editor = ide().getEditableTextEditor(ensureSingleEditor(targets)); + await this.actions.editNew.run([targets]); + + const targetSelectionInfos = editor.selections.map((selection) => + getSelectionInfo( + editor.document, + selection, + RangeExpansionBehavior.openOpen, + ), + ); const { body, formatSubstitutions } = this.getSnippetInfo( snippetDescription, - targets, + // Use new selection locations instead of original targets because + // that's where we'll be doing the snippet insertion + editor.selections.map( + (selection) => + new UntypedTarget({ + editor, + contentRange: selection, + isReversed: false, + hasExplicitRange: true, + }), + ), ); const parsedSnippet = this.snippetParser.parse(body); @@ -139,16 +160,6 @@ export default class InsertSnippet implements Action { const snippetString = parsedSnippet.toTextmateString(); - await this.actions.editNew.run([targets]); - - const targetSelectionInfos = editor.selections.map((selection) => - getSelectionInfo( - editor.document, - selection, - RangeExpansionBehavior.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( diff --git a/packages/cursorless-engine/src/actions/WrapWithSnippet.ts b/packages/cursorless-engine/src/actions/WrapWithSnippet.ts index 7b0201288a..2bec5d6af2 100644 --- a/packages/cursorless-engine/src/actions/WrapWithSnippet.ts +++ b/packages/cursorless-engine/src/actions/WrapWithSnippet.ts @@ -86,6 +86,7 @@ export default class WrapWithSnippet implements Action { const snippet = this.snippets.getSnippetStrict(name); const definition = findMatchingSnippetDefinitionStrict( + this.modifierStageFactory, targets, snippet.definitions, ); diff --git a/packages/cursorless-engine/src/core/Snippets.ts b/packages/cursorless-engine/src/core/Snippets.ts index 2907721202..d93e61e83f 100644 --- a/packages/cursorless-engine/src/core/Snippets.ts +++ b/packages/cursorless-engine/src/core/Snippets.ts @@ -1,9 +1,10 @@ import { showError, Snippet, SnippetMap, walkFiles } from "@cursorless/common"; import { readFile, stat } from "fs/promises"; -import { cloneDeep, max, merge } from "lodash"; +import { max } from "lodash"; import { join } from "path"; import { ide } from "../singletons/ide.singleton"; import { mergeStrict } from "../util/object"; +import { mergeSnippets } from "./mergeSnippets"; const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000; @@ -21,7 +22,7 @@ interface DirectoryErrorMessage { export class Snippets { private coreSnippets!: SnippetMap; private thirdPartySnippets: Record = {}; - private userSnippets!: SnippetMap; + private userSnippets!: SnippetMap[]; private mergedSnippets!: SnippetMap; @@ -133,7 +134,7 @@ export class Snippets { }; } - this.userSnippets = {}; + this.userSnippets = []; this.mergeSnippets(); return; @@ -154,34 +155,32 @@ export class Snippets { this.maxSnippetMtimeMs = maxSnippetMtime; - this.userSnippets = mergeStrict( - ...(await Promise.all( - 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) { - showError( - ide().messages, - "snippetsFileError", - `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 + this.userSnippets = await Promise.all( + 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) { + showError( + ide().messages, + "snippetsFileError", + `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 {}; + } + }), ); this.mergeSnippets(); @@ -206,34 +205,11 @@ export class Snippets { * party > core. */ private mergeSnippets() { - this.mergedSnippets = {}; - - // We make a list of all entries from all sources, in order of increasing - // precedence: user > third party > core. - const entries = [ - ...Object.entries(cloneDeep(this.coreSnippets)), - ...Object.values(this.thirdPartySnippets).flatMap((snippets) => - Object.entries(cloneDeep(snippets)), - ), - ...Object.entries(cloneDeep(this.userSnippets)), - ]; - - entries.forEach(([key, value]) => { - if (Object.prototype.hasOwnProperty.call(this.mergedSnippets, key)) { - const { definitions, ...rest } = value; - const mergedSnippet = this.mergedSnippets[key]; - - // NB: We make sure that the new definitions appear before the previous - // ones so that they take precedence - mergedSnippet.definitions = definitions.concat( - ...mergedSnippet.definitions, - ); - - merge(mergedSnippet, rest); - } else { - this.mergedSnippets[key] = value; - } - }); + this.mergedSnippets = mergeSnippets( + this.coreSnippets, + this.thirdPartySnippets, + this.userSnippets, + ); } /** diff --git a/packages/cursorless-engine/src/core/compareSnippetDefinitions.ts b/packages/cursorless-engine/src/core/compareSnippetDefinitions.ts new file mode 100644 index 0000000000..794282db2f --- /dev/null +++ b/packages/cursorless-engine/src/core/compareSnippetDefinitions.ts @@ -0,0 +1,95 @@ +import { + SimpleScopeTypeType, + SnippetDefinition, + SnippetScope, +} from "@cursorless/common"; +import { SnippetOrigin } from "./mergeSnippets"; + +/** + * Compares two snippet definitions by how specific their scope, breaking + * ties by origin. + * @param a One of the snippet definitions to compare + * @param b The other snippet definition to compare + * @returns A negative number if a should come before b, a positive number if b + */ +export function compareSnippetDefinitions( + a: SnippetDefinitionWithOrigin, + b: SnippetDefinitionWithOrigin, +): number { + const scopeComparision = compareSnippetScopes( + a.definition.scope, + b.definition.scope, + ); + + // Prefer the more specific snippet definitino, no matter the origin + if (scopeComparision !== 0) { + return scopeComparision; + } + + // If the scopes are the same, prefer the snippet from the higher priority + // origin + return a.origin - b.origin; +} + +function compareSnippetScopes( + a: SnippetScope | undefined, + b: SnippetScope | undefined, +): number { + if (a == null && b == null) { + return 0; + } + + // Prefer the snippet that has a scope at all + if (a == null) { + return -1; + } + + if (b == null) { + return 1; + } + + // Prefer the snippet that is language-specific, regardless of scope type + if (a.langIds == null && b.langIds != null) { + return -1; + } + + if (b.langIds == null && a.langIds != null) { + return 1; + } + + // If both snippets are language-specific, prefer the snippet that specifies + // scope types. Note that this holds even if one snippet specifies more + // languages than the other. The motivating use case is if you have a snippet + // for functions in js and ts, and a snippet for methods in js and ts. If you + // override the function snippet for ts, you still want the method snippet to + // be used for ts methods. + const scopeTypesComparision = compareScopeTypes(a.scopeTypes, b.scopeTypes); + + if (scopeTypesComparision !== 0) { + return scopeTypesComparision; + } + + // If snippets both have scope types or both don't have scope types, prefer + // the snippet that specifies fewer languages + return a.langIds == null ? 0 : b.langIds!.length - a.langIds.length; +} + +function compareScopeTypes( + a: SimpleScopeTypeType[] | undefined, + b: SimpleScopeTypeType[] | undefined, +): number { + if (a == null && b != null) { + return -1; + } + + if (b == null && a != null) { + return 1; + } + + return 0; +} + +interface SnippetDefinitionWithOrigin { + origin: SnippetOrigin; + definition: SnippetDefinition; +} diff --git a/packages/cursorless-engine/src/core/mergeSnippets.test.ts b/packages/cursorless-engine/src/core/mergeSnippets.test.ts new file mode 100644 index 0000000000..bd4e4579c7 --- /dev/null +++ b/packages/cursorless-engine/src/core/mergeSnippets.test.ts @@ -0,0 +1,348 @@ +import { SnippetMap } from "@cursorless/common"; +import { mergeSnippets } from "./mergeSnippets"; +import assert = require("assert"); + +interface TestCase { + name: string; + coreSnippets?: SnippetMap; + thirdPartySnippets?: Record; + userSnippets?: SnippetMap[]; + expected: SnippetMap; +} + +const testCases: TestCase[] = [ + { + name: "should handle simple case", + coreSnippets: { + aaa: { + definitions: [ + { + body: ["aaa"], + }, + ], + }, + }, + thirdPartySnippets: { + someThirdParty: { + bbb: { + definitions: [ + { + body: ["bbb"], + }, + ], + }, + }, + }, + userSnippets: [ + { + ccc: { + definitions: [ + { + body: ["ccc"], + }, + ], + }, + }, + ], + expected: { + aaa: { + definitions: [ + { + body: ["aaa"], + }, + ], + }, + bbb: { + definitions: [ + { + body: ["bbb"], + }, + ], + }, + ccc: { + definitions: [ + { + body: ["ccc"], + }, + ], + }, + }, + }, + + { + name: "should prefer user snippets", + coreSnippets: { + aaa: { + definitions: [ + { + body: ["core aaa"], + }, + ], + description: "core snippet", + }, + }, + thirdPartySnippets: { + someThirdParty: { + aaa: { + definitions: [ + { + body: ["someThirdParty aaa"], + }, + ], + description: "someThirdParty snippet", + }, + }, + }, + userSnippets: [ + { + aaa: { + definitions: [ + { + body: ["user aaa"], + }, + ], + description: "user snippet", + }, + }, + ], + expected: { + aaa: { + definitions: [ + { + body: ["user aaa"], + }, + { + body: ["someThirdParty aaa"], + }, + { + body: ["core aaa"], + }, + ], + description: "user snippet", + }, + }, + }, + + { + name: "should prefer user snippets when scopes are the same", + coreSnippets: { + aaa: { + definitions: [ + { + body: ["core aaa"], + scope: { + langIds: ["typescript"], + scopeTypes: ["anonymousFunction"], + }, + }, + ], + description: "core snippet", + }, + }, + thirdPartySnippets: { + someThirdParty: { + aaa: { + definitions: [ + { + body: ["someThirdParty aaa"], + scope: { + langIds: ["typescript"], + scopeTypes: ["anonymousFunction"], + }, + }, + ], + description: "someThirdParty snippet", + }, + }, + }, + userSnippets: [ + { + aaa: { + definitions: [ + { + body: ["user aaa"], + scope: { + langIds: ["typescript"], + scopeTypes: ["anonymousFunction"], + }, + }, + ], + description: "user snippet", + }, + }, + ], + expected: { + aaa: { + definitions: [ + { + body: ["user aaa"], + scope: { + langIds: ["typescript"], + scopeTypes: ["anonymousFunction"], + }, + }, + { + body: ["someThirdParty aaa"], + scope: { + langIds: ["typescript"], + scopeTypes: ["anonymousFunction"], + }, + }, + { + body: ["core aaa"], + scope: { + langIds: ["typescript"], + scopeTypes: ["anonymousFunction"], + }, + }, + ], + description: "user snippet", + }, + }, + }, + + { + name: "should prefer more specific snippets, even if they are from a lower priority origin", + coreSnippets: { + aaa: { + definitions: [ + { + body: ["core aaa"], + scope: { + langIds: ["typescript"], + }, + }, + ], + description: "core snippet", + }, + }, + userSnippets: [ + { + aaa: { + definitions: [ + { + body: ["user aaa"], + }, + ], + description: "user snippet", + }, + }, + ], + expected: { + aaa: { + definitions: [ + { + body: ["core aaa"], + scope: { + langIds: ["typescript"], + }, + }, + { + body: ["user aaa"], + }, + ], + description: "user snippet", + }, + }, + }, + + { + name: "should prefer snippets based on specificity", + coreSnippets: { + aaa: { + definitions: [ + { + body: [""], + }, + { + body: [""], + scope: { + langIds: ["typescript"], + }, + }, + { + body: [""], + scope: { + langIds: ["typescript", "javascript"], + }, + }, + { + body: [""], + scope: { + scopeTypes: ["anonymousFunction"], + }, + }, + { + body: [""], + scope: { + langIds: ["typescript"], + scopeTypes: ["anonymousFunction"], + }, + }, + { + body: [""], + scope: { + langIds: ["typescript", "javascript"], + scopeTypes: ["anonymousFunction"], + }, + }, + ], + }, + }, + expected: { + aaa: { + definitions: [ + { + body: [""], + scope: { + langIds: ["typescript"], + scopeTypes: ["anonymousFunction"], + }, + }, + { + body: [""], + scope: { + langIds: ["typescript", "javascript"], + scopeTypes: ["anonymousFunction"], + }, + }, + { + body: [""], + scope: { + langIds: ["typescript"], + }, + }, + { + body: [""], + scope: { + langIds: ["typescript", "javascript"], + }, + }, + { + body: [""], + scope: { + scopeTypes: ["anonymousFunction"], + }, + }, + { + body: [""], + }, + ], + }, + }, + }, +]; + +suite("mergeSnippets", function () { + for (const testCase of testCases) { + test(testCase.name, function () { + const actual = mergeSnippets( + testCase.coreSnippets ?? {}, + testCase.thirdPartySnippets ?? {}, + testCase.userSnippets ?? [], + ); + + assert.deepStrictEqual(actual, testCase.expected); + }); + } +}); diff --git a/packages/cursorless-engine/src/core/mergeSnippets.ts b/packages/cursorless-engine/src/core/mergeSnippets.ts new file mode 100644 index 0000000000..333a022d0a --- /dev/null +++ b/packages/cursorless-engine/src/core/mergeSnippets.ts @@ -0,0 +1,81 @@ +import { Snippet, SnippetMap } from "@cursorless/common"; +import { cloneDeep, groupBy, mapValues, merge } from "lodash"; +import { compareSnippetDefinitions } from "./compareSnippetDefinitions"; + +export function mergeSnippets( + coreSnippets: SnippetMap, + thirdPartySnippets: Record, + userSnippets: SnippetMap[], +): SnippetMap { + const mergedSnippets: SnippetMap = {}; + + // We make a merged map where we map every key to an array of all snippets + // with that key, whether they are core, third-party, or user snippets. + const mergedMap = mapValues( + groupBy( + [ + ...prepareSnippetsFromOrigin(SnippetOrigin.core, coreSnippets), + ...prepareSnippetsFromOrigin( + SnippetOrigin.thirdParty, + ...Object.values(thirdPartySnippets), + ), + ...prepareSnippetsFromOrigin(SnippetOrigin.user, ...userSnippets), + ], + ([key]) => key, + ), + (entries) => entries.map(([, value]) => value), + ); + + Object.entries(mergedMap).forEach(([key, snippets]) => { + const mergedSnippet: Snippet = merge( + {}, + // We sort the snippets by origin as (core, third-party, user) so that + // when we merge them, the user snippets will override the third-party + // snippets, which will override the core snippets. + ...snippets + .sort((a, b) => a.origin - b.origin) + .map(({ snippet }) => snippet), + ); + + // We sort the definitions by decreasing precedence, so that earlier + // definitions will be chosen before later definitions when we're choosing a + // definition for a given target context. + mergedSnippet.definitions = snippets + .flatMap(({ origin, snippet }) => + snippet.definitions.map((definition) => ({ origin, definition })), + ) + .sort((a, b) => -compareSnippetDefinitions(a, b)) + .map(({ definition }) => definition); + + mergedSnippets[key] = mergedSnippet; + }); + + return mergedSnippets; +} + +/** + * Prepares the given snippet maps for merging by adding the given origin to + * each snippet. + * @param origin The origin of the snippets + * @param snippetMaps The snippet maps from the given origin + * @returns An array of entries of the form [key, {origin, snippet}] + */ +function prepareSnippetsFromOrigin( + origin: SnippetOrigin, + ...snippetMaps: SnippetMap[] +) { + return snippetMaps + .map((snippetMap) => + mapValues(cloneDeep(snippetMap), (snippet) => ({ + origin, + snippet, + })), + ) + .flatMap((snippetMap) => Object.entries(snippetMap)); +} + +export enum SnippetOrigin { + core = 0, + thirdParty = 1, + user = 2, +} diff --git a/packages/cursorless-engine/src/snippets/snippet.ts b/packages/cursorless-engine/src/snippets/snippet.ts index 5ce167ce4c..9f2480b264 100644 --- a/packages/cursorless-engine/src/snippets/snippet.ts +++ b/packages/cursorless-engine/src/snippets/snippet.ts @@ -1,4 +1,4 @@ -import { SnippetDefinition } from "@cursorless/common"; +import { SimpleScopeTypeType, SnippetDefinition } from "@cursorless/common"; import { Target } from "../typings/target.types"; import { Placeholder, @@ -7,6 +7,7 @@ import { Variable, } from "./vendor/vscodeSnippet/snippetParser"; import { KnownSnippetVariableNames } from "./vendor/vscodeSnippet/snippetVariables"; +import { ModifierStageFactory } from "../processTargets/ModifierStageFactory"; /** * Replaces the snippet variable with name `placeholderName` with @@ -84,11 +85,16 @@ function getMaxPlaceholderIndex(parsedSnippet: TextmateSnippet): number { * @returns The snippet definition that matches the given context */ export function findMatchingSnippetDefinitionStrict( + modifierStageFactory: ModifierStageFactory, targets: Target[], definitions: SnippetDefinition[], ): SnippetDefinition { const definitionIndices = targets.map((target) => - findMatchingSnippetDefinitionForSingleTarget(target, definitions), + findMatchingSnippetDefinitionForSingleTarget( + modifierStageFactory, + target, + definitions, + ), ); const definitionIndex = definitionIndices[0]; @@ -104,26 +110,85 @@ export function findMatchingSnippetDefinitionStrict( return definitions[definitionIndex]; } +/** + * Based on the context determined by {@link target} (eg the file's language id + * and containing scope), finds the best snippet definition that matches the + * given context. Returns -1 if no matching snippet definition could be found. + * + * We assume that the definitions are sorted in precedence order, so we just + * return the first match we find. + * + * @param modifierStageFactory For creating containing scope modifiers + * @param target The target to find a matching snippet definition for + * @param definitions The list of snippet definitions to search + * @returns The index of the best snippet definition that matches the given + * target, or -1 if no matching snippet definition could be found + */ function findMatchingSnippetDefinitionForSingleTarget( + modifierStageFactory: ModifierStageFactory, target: Target, definitions: SnippetDefinition[], ): number { const languageId = target.editor.document.languageId; + // We want to find the first definition that matches the given context. + // Note that we just use the first match we find because the definitions are + // guaranteed to come sorted in precedence order. return definitions.findIndex(({ scope }) => { if (scope == null) { return true; } - const { langIds, scopeTypes } = scope; + const { langIds, scopeTypes, excludeDescendantScopeTypes } = 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"); + const allScopeTypes = scopeTypes.concat( + excludeDescendantScopeTypes ?? [], + ); + let matchingTarget: Target | undefined = undefined; + let matchingScopeType: SimpleScopeTypeType | undefined = undefined; + for (const scopeTypeType of allScopeTypes) { + try { + let containingTarget = modifierStageFactory + .create({ + type: "containingScope", + scopeType: { type: scopeTypeType }, + }) + .run(target)[0]; + + if (target.contentRange.isRangeEqual(containingTarget.contentRange)) { + // Skip this scope if the target is exactly the same as the + // containing scope, otherwise wrapping won't work, because we're + // really outside the containing scope when we're wrapping + containingTarget = modifierStageFactory + .create({ + type: "containingScope", + scopeType: { type: scopeTypeType }, + ancestorIndex: 1, + }) + .run(target)[0]; + } + + if ( + matchingTarget == null || + matchingTarget.contentRange.contains(containingTarget.contentRange) + ) { + matchingTarget = containingTarget; + matchingScopeType = scopeTypeType; + } + } catch (e) { + continue; + } + } + + return ( + matchingTarget != null && + !(excludeDescendantScopeTypes ?? []).includes(matchingScopeType!) + ); } return true; diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/funkWrapClass.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/funkWrapClass.yml new file mode 100644 index 0000000000..1ae85aa448 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/funkWrapClass.yml @@ -0,0 +1,39 @@ +languageId: python +command: + version: 5 + spokenForm: funk wrap class + action: + name: wrapWithSnippet + args: + - {type: named, name: functionDeclaration, variableName: body} + targets: + - type: primitive + modifiers: + - type: containingScope + scopeType: {type: class} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa: + def bbb(): + pass + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + marks: {} +finalState: + documentContents: |- + def (): + class Aaa: + def bbb(): + pass + selections: + - anchor: {line: 0, character: 4} + active: {line: 0, character: 4} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 3, character: 16} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk3.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk3.yml new file mode 100644 index 0000000000..74d6479925 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk3.yml @@ -0,0 +1,37 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + marks: {} +finalState: + documentContents: |- + class Aaa { + () { + + } + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 4} + end: {line: 3, character: 5} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk4.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk4.yml new file mode 100644 index 0000000000..c22cc9ba8c --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk4.yml @@ -0,0 +1,41 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + bbb() { + + } + } + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + marks: {} +finalState: + documentContents: |- + class Aaa { + bbb() { + function () { + + } + } + } + selections: + - anchor: {line: 2, character: 17} + active: {line: 2, character: 17} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 8} + end: {line: 4, character: 9} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk5.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk5.yml new file mode 100644 index 0000000000..2e94070aa0 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk5.yml @@ -0,0 +1,41 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + function aaa() { + class Bbb { + + } + } + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + marks: {} +finalState: + documentContents: |- + function aaa() { + class Bbb { + () { + + } + } + } + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 8} + end: {line: 4, character: 9} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk6.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk6.yml new file mode 100644 index 0000000000..1070e623d9 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunk6.yml @@ -0,0 +1,45 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + bbb() { + class Bbb { + + } + } + } + selections: + - anchor: {line: 3, character: 12} + active: {line: 3, character: 12} + marks: {} +finalState: + documentContents: |- + class Aaa { + bbb() { + class Bbb { + () { + + } + } + } + } + selections: + - anchor: {line: 3, character: 12} + active: {line: 3, character: 12} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 3, character: 12} + end: {line: 5, character: 13} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkAfterClass.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkAfterClass.yml new file mode 100644 index 0000000000..c3a3eced21 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkAfterClass.yml @@ -0,0 +1,43 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk after class + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - type: primitive + modifiers: + - {type: position, position: after} + - type: containingScope + scopeType: {type: class} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + marks: {} +finalState: + documentContents: |- + class Aaa { + + } + + function () { + + } + selections: + - anchor: {line: 4, character: 9} + active: {line: 4, character: 9} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 4, character: 0} + end: {line: 6, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis5.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis5.yml new file mode 100644 index 0000000000..63d310fe37 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkAfterThis5.yml @@ -0,0 +1,46 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk after this + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - type: primitive + mark: {type: cursor} + modifiers: + - {type: position, position: after} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + bbb() { + + } + } + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + marks: {} +finalState: + documentContents: |- + class Aaa { + bbb() { + + } + + () { + + } + } + selections: + - anchor: {line: 5, character: 4} + active: {line: 5, character: 4} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 5, character: 4} + end: {line: 7, character: 5} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkBeforeClass.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkBeforeClass.yml new file mode 100644 index 0000000000..790879dfac --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkBeforeClass.yml @@ -0,0 +1,43 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk before class + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - type: primitive + modifiers: + - {type: position, position: before} + - type: containingScope + scopeType: {type: class} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + marks: {} +finalState: + documentContents: |- + function () { + + } + + class Aaa { + + } + selections: + - anchor: {line: 0, character: 9} + active: {line: 0, character: 9} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 2, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis4.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis4.yml new file mode 100644 index 0000000000..7b43c0ec42 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkBeforeThis4.yml @@ -0,0 +1,46 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk before this + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - type: primitive + mark: {type: cursor} + modifiers: + - {type: position, position: before} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + bbb() { + + } + } + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + marks: {} +finalState: + documentContents: |- + class Aaa { + () { + + } + + bbb() { + + } + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 4} + end: {line: 3, character: 5} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkCelloWorld.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkCelloWorld.yml new file mode 100644 index 0000000000..33d7249590 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkCelloWorld.yml @@ -0,0 +1,39 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk cello world + action: + name: insertSnippet + args: + - type: named + name: functionDeclaration + substitutions: {name: cello world} + targets: + - {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + marks: {} +finalState: + documentContents: |- + class Aaa { + celloWorld() { + + } + } + selections: + - anchor: {line: 1, character: 15} + active: {line: 1, character: 15} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 4} + end: {line: 3, character: 5} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkToClass.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkToClass.yml new file mode 100644 index 0000000000..2484515c8f --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkToClass.yml @@ -0,0 +1,38 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk to class + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - type: primitive + modifiers: + - type: containingScope + scopeType: {type: class} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + marks: {} +finalState: + documentContents: |- + function () { + + } + selections: + - anchor: {line: 0, character: 9} + active: {line: 0, character: 9} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 2, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkToThis.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkToThis.yml new file mode 100644 index 0000000000..2aa46eb21a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/actions/snippets/snipFunkToThis.yml @@ -0,0 +1,40 @@ +languageId: typescript +command: + version: 5 + spokenForm: snip funk to this + action: + name: insertSnippet + args: + - {type: named, name: functionDeclaration} + targets: + - type: primitive + mark: {type: cursor} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + class Aaa { + bbb() { + + } + } + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + marks: {} +finalState: + documentContents: |- + class Aaa { + () { + + } + } + selections: + - anchor: {line: 1, character: 4} + active: {line: 1, character: 4} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 4} + end: {line: 3, character: 5} + isReversed: false + hasExplicitRange: true diff --git a/schemas/cursorless-snippets.json b/schemas/cursorless-snippets.json index 7b5aa08af4..e067736612 100644 --- a/schemas/cursorless-snippets.json +++ b/schemas/cursorless-snippets.json @@ -28,6 +28,13 @@ "$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." + }, + "excludeDescendantScopeTypes": { + "type": "array", + "items": { + "$ref": "#/$defs/scopeType" + }, + "description": "Exclude regions of scopeTypes that are descendants of these scope types. For example, if you have a snippet that should be active in a class but not in a function within the class, you can specify `scopeTypes: [\"class\"], excludeDescendantScopeTypes: [\"namedFunction\"]`" } }, "additionalProperties": false