From 3a9d9847e500e8531723f0a11a9821b34181a1c5 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Thu, 6 Feb 2025 20:03:35 +0100 Subject: [PATCH 1/6] Working on restoring legacy snippets --- .../src/actions/InsertSnippet.ts | 20 ++ .../snippetsLegacy/InsertSnippetLegacy.ts | 201 +++++++++++++++++ .../snippetsLegacy/WrapWithSnippetLegacy.ts | 127 +++++++++++ .../src/actions/snippetsLegacy/snippet.ts | 207 +++++++++++++++++ .../actions/snippetsLegacy/textFormatters.ts | 35 +++ .../cursorless-engine/src/core/Snippets.ts | 11 +- .../disabledComponents/DisabledSnippets.ts | 6 +- .../cursorless-vscode/src/VscodeSnippets.ts | 213 +++++++++++++++++- .../compareSnippetDefinitions.ts | 95 ++++++++ .../src/snippetLegacy/mergeSnippets.ts | 81 +++++++ 10 files changed, 987 insertions(+), 9 deletions(-) create mode 100644 packages/cursorless-engine/src/actions/snippetsLegacy/InsertSnippetLegacy.ts create mode 100644 packages/cursorless-engine/src/actions/snippetsLegacy/WrapWithSnippetLegacy.ts create mode 100644 packages/cursorless-engine/src/actions/snippetsLegacy/snippet.ts create mode 100644 packages/cursorless-engine/src/actions/snippetsLegacy/textFormatters.ts create mode 100644 packages/cursorless-vscode/src/snippetLegacy/compareSnippetDefinitions.ts create mode 100644 packages/cursorless-vscode/src/snippetLegacy/mergeSnippets.ts diff --git a/packages/cursorless-engine/src/actions/InsertSnippet.ts b/packages/cursorless-engine/src/actions/InsertSnippet.ts index 126dabe45a..c862203c97 100644 --- a/packages/cursorless-engine/src/actions/InsertSnippet.ts +++ b/packages/cursorless-engine/src/actions/InsertSnippet.ts @@ -30,6 +30,16 @@ export default class InsertSnippet { destinations: Destination[], snippetDescription: InsertSnippetArg, ) { + if (snippetDescription.type === "named") { + const action = new InsertSnippet( + this.rangeUpdater, + this.snippets, + this.actions, + this.modifierStageFactory, + ); + return action.getFinalStages(destinations, snippetDescription); + } + const editor = ensureSingleEditor(destinations); const snippet = getPreferredSnippet( snippetDescription, @@ -53,6 +63,16 @@ export default class InsertSnippet { destinations: Destination[], snippetDescription: InsertSnippetArg, ): Promise { + if (snippetDescription.type === "named") { + const action = new InsertSnippet( + this.rangeUpdater, + this.snippets, + this.actions, + this.modifierStageFactory, + ); + return action.run(destinations, snippetDescription); + } + const editor = ide().getEditableTextEditor( ensureSingleEditor(destinations), ); diff --git a/packages/cursorless-engine/src/actions/snippetsLegacy/InsertSnippetLegacy.ts b/packages/cursorless-engine/src/actions/snippetsLegacy/InsertSnippetLegacy.ts new file mode 100644 index 0000000000..1e437371aa --- /dev/null +++ b/packages/cursorless-engine/src/actions/snippetsLegacy/InsertSnippetLegacy.ts @@ -0,0 +1,201 @@ +import type { + InsertSnippetArg, + ScopeType, + Snippet, + SnippetDefinition, +} from "@cursorless/common"; +import { RangeExpansionBehavior } from "@cursorless/common"; +import type { Snippets } from "../../core/Snippets"; +import type { RangeUpdater } from "../../core/updateSelections/RangeUpdater"; +import { performEditsAndUpdateSelections } from "../../core/updateSelections/updateSelections"; +import type { ModifierStageFactory } from "../../processTargets/ModifierStageFactory"; +import { ModifyIfUntypedExplicitStage } from "../../processTargets/modifiers/ConditionalModifierStages"; +import { UntypedTarget } from "../../processTargets/targets"; +import { ide } from "../../singletons/ide.singleton"; +import { SnippetParser } from "../../snippets/vendor/vscodeSnippet/snippetParser"; +import type { Destination, Target } from "../../typings/target.types"; +import { ensureSingleEditor } from "../../util/targetUtils"; +import type { Actions } from "../Actions"; +import type { ActionReturnValue } from "../actions.types"; +import { + findMatchingSnippetDefinitionStrict, + transformSnippetVariables, +} from "../snippetsLegacy/snippet"; + +export default class InsertSnippetLegacy { + private snippetParser = new SnippetParser(); + + constructor( + private rangeUpdater: RangeUpdater, + private snippets: Snippets, + private actions: Actions, + private modifierStageFactory: ModifierStageFactory, + ) { + this.run = this.run.bind(this); + } + + getFinalStages(snippetDescription: InsertSnippetArg) { + const defaultScopeTypes = this.getScopeTypes(snippetDescription); + + return defaultScopeTypes.length === 0 + ? [] + : [ + new ModifyIfUntypedExplicitStage(this.modifierStageFactory, { + type: "cascading", + modifiers: defaultScopeTypes.map((scopeType) => ({ + type: "containingScope", + scopeType, + })), + }), + ]; + } + + private getScopeTypes(snippetDescription: InsertSnippetArg): ScopeType[] { + if (snippetDescription.type === "named") { + const { name } = snippetDescription; + + const snippet = this.snippets.getSnippetStrict(name); + + const scopeTypeTypes = snippet.insertionScopeTypes; + return scopeTypeTypes == null + ? [] + : scopeTypeTypes.map((scopeTypeType) => ({ + type: scopeTypeType, + })); + } else { + return snippetDescription.scopeTypes ?? []; + } + } + + private getSnippetInfo( + snippetDescription: InsertSnippetArg, + targets: Target[], + ) { + if (snippetDescription.type === "named") { + const { name } = snippetDescription; + + const snippet = this.snippets.getSnippetStrict(name); + + const definition = findMatchingSnippetDefinitionStrict( + this.modifierStageFactory, + targets, + snippet.definitions, + ); + + return { + body: definition.body.join("\n"), + + formatSubstitutions(substitutions: Record | undefined) { + return substitutions == null + ? undefined + : formatSubstitutions(snippet, definition, substitutions); + }, + }; + } else { + return { + body: snippetDescription.body, + + formatSubstitutions(substitutions: Record | undefined) { + return substitutions; + }, + }; + } + } + + async run( + destinations: Destination[], + snippetDescription: InsertSnippetArg, + ): Promise { + const editor = ide().getEditableTextEditor( + ensureSingleEditor(destinations), + ); + + await this.actions.editNew.run(destinations); + + const { body, formatSubstitutions } = this.getSnippetInfo( + snippetDescription, + // 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); + + transformSnippetVariables( + parsedSnippet, + null, + formatSubstitutions(snippetDescription.substitutions), + ); + + const snippetString = parsedSnippet.toTextmateString(); + + const { editorSelections: updatedThatSelections } = + await performEditsAndUpdateSelections({ + rangeUpdater: this.rangeUpdater, + editor, + callback: () => editor.insertSnippet(snippetString), + preserveCursorSelections: true, + selections: { + editorSelections: { + selections: editor.selections, + behavior: RangeExpansionBehavior.openOpen, + }, + }, + }); + + return { + thatSelections: updatedThatSelections.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/packages/cursorless-engine/src/actions/snippetsLegacy/WrapWithSnippetLegacy.ts b/packages/cursorless-engine/src/actions/snippetsLegacy/WrapWithSnippetLegacy.ts new file mode 100644 index 0000000000..3a1ceffef7 --- /dev/null +++ b/packages/cursorless-engine/src/actions/snippetsLegacy/WrapWithSnippetLegacy.ts @@ -0,0 +1,127 @@ +import type { ScopeType, WrapWithSnippetArg } from "@cursorless/common"; +import { FlashStyle } from "@cursorless/common"; +import type { Snippets } from "../../core/Snippets"; +import type { RangeUpdater } from "../../core/updateSelections/RangeUpdater"; +import { performEditsAndUpdateSelections } from "../../core/updateSelections/updateSelections"; +import type { ModifierStageFactory } from "../../processTargets/ModifierStageFactory"; +import { ModifyIfUntypedStage } from "../../processTargets/modifiers/ConditionalModifierStages"; +import { ide } from "../../singletons/ide.singleton"; +import { SnippetParser } from "../../snippets/vendor/vscodeSnippet/snippetParser"; +import type { Target } from "../../typings/target.types"; +import { ensureSingleEditor, flashTargets } from "../../util/targetUtils"; +import type { ActionReturnValue } from "../actions.types"; +import { + findMatchingSnippetDefinitionStrict, + transformSnippetVariables, +} from "../snippetsLegacy/snippet"; + +export default class WrapWithSnippetLegacy { + private snippetParser = new SnippetParser(); + + constructor( + private rangeUpdater: RangeUpdater, + private snippets: Snippets, + private modifierStageFactory: ModifierStageFactory, + ) { + this.run = this.run.bind(this); + } + + getFinalStages(snippet: WrapWithSnippetArg) { + const defaultScopeType = this.getScopeType(snippet); + + if (defaultScopeType == null) { + return []; + } + + return [ + new ModifyIfUntypedStage(this.modifierStageFactory, { + type: "modifyIfUntyped", + modifier: { + type: "containingScope", + scopeType: defaultScopeType, + }, + }), + ]; + } + + private getScopeType( + snippetDescription: WrapWithSnippetArg, + ): ScopeType | undefined { + if (snippetDescription.type === "named") { + const { name, variableName } = snippetDescription; + + const snippet = this.snippets.getSnippetStrict(name); + + const variables = snippet.variables ?? {}; + const scopeTypeType = variables[variableName]?.wrapperScopeType; + return scopeTypeType == null + ? undefined + : { + type: scopeTypeType, + }; + } else { + return snippetDescription.scopeType; + } + } + + private getBody( + snippetDescription: WrapWithSnippetArg, + targets: Target[], + ): string { + if (snippetDescription.type === "named") { + const { name } = snippetDescription; + + const snippet = this.snippets.getSnippetStrict(name); + + const definition = findMatchingSnippetDefinitionStrict( + this.modifierStageFactory, + targets, + snippet.definitions, + ); + + return definition.body.join("\n"); + } else { + return snippetDescription.body; + } + } + + async run( + targets: Target[], + snippetDescription: WrapWithSnippetArg, + ): Promise { + const editor = ide().getEditableTextEditor(ensureSingleEditor(targets)); + + const body = this.getBody(snippetDescription, targets); + + const parsedSnippet = this.snippetParser.parse(body); + + transformSnippetVariables(parsedSnippet, snippetDescription.variableName); + + const snippetString = parsedSnippet.toTextmateString(); + + await flashTargets(ide(), targets, FlashStyle.pendingModification0); + + const targetSelections = targets.map((target) => target.contentSelection); + + const callback = () => + editor.insertSnippet(snippetString, targetSelections); + + const { targetSelections: updatedTargetSelections } = + await performEditsAndUpdateSelections({ + rangeUpdater: this.rangeUpdater, + editor, + callback, + preserveCursorSelections: true, + selections: { + targetSelections, + }, + }); + + return { + thatSelections: updatedTargetSelections.map((selection) => ({ + editor, + selection, + })), + }; + } +} diff --git a/packages/cursorless-engine/src/actions/snippetsLegacy/snippet.ts b/packages/cursorless-engine/src/actions/snippetsLegacy/snippet.ts new file mode 100644 index 0000000000..78a98d8f36 --- /dev/null +++ b/packages/cursorless-engine/src/actions/snippetsLegacy/snippet.ts @@ -0,0 +1,207 @@ +import type { + SimpleScopeTypeType, + SnippetDefinition, +} from "@cursorless/common"; +import { + Placeholder, + Text, + Variable, + type TextmateSnippet, +} from "../../snippets/vendor/vscodeSnippet/snippetParser"; +import { KnownSnippetVariableNames } from "../../snippets/vendor/vscodeSnippet/snippetVariables"; +import type { ModifierStageFactory } from "../../processTargets/ModifierStageFactory"; +import type { Target } from "../../typings/target.types"; + +/** + * 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 && + Object.prototype.hasOwnProperty.call(substitutions, 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]); + } + } else if (candidate instanceof Placeholder) { + if (candidate.index.toString() === placeholderName) { + candidate.parent.replace(candidate, [new Variable("TM_SELECTED_TEXT")]); + } + } + 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 { + let 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( + modifierStageFactory: ModifierStageFactory, + targets: Target[], + definitions: SnippetDefinition[], +): SnippetDefinition { + const definitionIndices = targets.map((target) => + findMatchingSnippetDefinitionForSingleTarget( + modifierStageFactory, + 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]; +} + +/** + * 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, excludeDescendantScopeTypes } = scope; + + if (langIds != null && !langIds.includes(languageId)) { + return false; + } + + if (scopeTypes != null) { + 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; + } + } + + if (matchingScopeType == null) { + return false; + } + + return ( + matchingTarget != null && + !(excludeDescendantScopeTypes ?? []).includes(matchingScopeType) + ); + } + + return true; + }); +} diff --git a/packages/cursorless-engine/src/actions/snippetsLegacy/textFormatters.ts b/packages/cursorless-engine/src/actions/snippetsLegacy/textFormatters.ts new file mode 100644 index 0000000000..d5e6dc9581 --- /dev/null +++ b/packages/cursorless-engine/src/actions/snippetsLegacy/textFormatters.ts @@ -0,0 +1,35 @@ +type TextFormatter = (tokens: string[]) => string; + +export 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 type TextFormatterName = + | "camelCase" + | "pascalCase" + | "snakeCase" + | "upperSnakeCase"; diff --git a/packages/cursorless-engine/src/core/Snippets.ts b/packages/cursorless-engine/src/core/Snippets.ts index b6f2311d57..dd09026c44 100644 --- a/packages/cursorless-engine/src/core/Snippets.ts +++ b/packages/cursorless-engine/src/core/Snippets.ts @@ -1,4 +1,4 @@ -import type { TextEditor } from "@cursorless/common"; +import type { Snippet, TextEditor } from "@cursorless/common"; /** * Handles all cursorless snippets, including core, third-party and @@ -6,6 +6,15 @@ import type { TextEditor } from "@cursorless/common"; * name. */ export interface Snippets { + /** + * Looks in merged collection of snippets for a snippet with key + * `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 + */ + getSnippetStrict(snippetName: string): Snippet; + /** * Opens a new snippet file * @param snippetName The name of the snippet diff --git a/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts b/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts index cf40f8f75f..b145222719 100644 --- a/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts +++ b/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts @@ -1,7 +1,11 @@ -import type { TextEditor } from "@cursorless/common"; +import type { Snippet, TextEditor } from "@cursorless/common"; import type { Snippets } from "../core/Snippets"; export class DisabledSnippets implements Snippets { + getSnippetStrict(_snippetName: string): Snippet { + throw new Error("Snippets are not implemented."); + } + openNewSnippetFile( _snippetName: string, _directory: string, diff --git a/packages/cursorless-vscode/src/VscodeSnippets.ts b/packages/cursorless-vscode/src/VscodeSnippets.ts index 7ea83f8c93..d03e3cc4d3 100644 --- a/packages/cursorless-vscode/src/VscodeSnippets.ts +++ b/packages/cursorless-vscode/src/VscodeSnippets.ts @@ -1,12 +1,19 @@ -import type { TextEditor } from "@cursorless/common"; -import { type IDE } from "@cursorless/common"; +import type { Snippet, SnippetMap, TextEditor } from "@cursorless/common"; +import { mergeStrict, showError, type IDE } from "@cursorless/common"; import { type Snippets } from "@cursorless/cursorless-engine"; import { walkFiles } from "@cursorless/node-common"; -import { open } from "node:fs/promises"; +import { max } from "lodash-es"; +import { open, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; +import { mergeSnippets } from "./snippetLegacy/mergeSnippets"; -// DEPRECATED @ 2025-02-01 -export const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; +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 @@ -14,22 +21,210 @@ export const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; * name. */ export class VscodeSnippets implements Snippets { + private coreSnippets!: SnippetMap; + private userSnippets!: SnippetMap[]; + + private mergedSnippets!: SnippetMap; + private userSnippetsDir?: string; + /** + * The maximum modification time of any snippet in user snippets dir. + * + * This variable will be set to -1 if no user snippets have yet been read or + * if the user snippets path has changed. + * + * This variable will be set to 0 if the user has no snippets dir configured and + * we've already set userSnippets to {}. + */ + 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 ide: IDE) { this.updateUserSnippetsPath(); + this.updateUserSnippetsPath(); + this.ide.disposeOnExit( this.ide.configuration.onDidChangeConfiguration(() => this.updateUserSnippetsPath(), ), ); + + this.updateUserSnippets = this.updateUserSnippets.bind(this); + + const timer = setInterval( + this.updateUserSnippets, + SNIPPET_DIR_REFRESH_INTERVAL_MS, + ); + + this.ide.disposeOnExit( + this.ide.configuration.onDidChangeConfiguration(() => { + if (this.updateUserSnippetsPath()) { + void this.updateUserSnippets(); + } + }), + { + dispose() { + clearInterval(timer); + }, + }, + ); + } + + async init() { + const extensionPath = this.ide.assetsRoot; + const snippetsDir = join(extensionPath, "cursorless-snippets"); + const snippetFiles = await getSnippetPaths(snippetsDir); + this.coreSnippets = mergeStrict( + ...(await Promise.all( + snippetFiles.map(async (path) => + JSON.parse(await readFile(path, "utf8")), + ), + )), + ); + await this.updateUserSnippets(); } - private updateUserSnippetsPath() { - this.userSnippetsDir = this.ide.configuration.getOwnConfiguration( + /** + * Updates the userSnippetsDir field if it has change, returning a boolean + * indicating whether there was an update. If there was an update, resets the + * maxSnippetMtime to -1 to ensure snippet update. + * @returns Boolean indicating whether path has changed + */ + private updateUserSnippetsPath(): boolean { + const newUserSnippetsDir = this.ide.configuration.getOwnConfiguration( "experimental.snippetsDir", ); + + if (newUserSnippetsDir === this.userSnippetsDir) { + return false; + } + + // Reset mtime to -1 so that next time we'll update the snippets + this.maxSnippetMtimeMs = -1; + + this.userSnippetsDir = newUserSnippetsDir; + + return true; + } + + async updateUserSnippets() { + 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}`; + + void showError(this.ide.messages, "snippetsDirError", errorMessage); + + this.directoryErrorMessage = { + directory: this.userSnippetsDir!, + errorMessage, + }; + } + + this.userSnippets = []; + this.mergeSnippets(); + + return; + } + + this.directoryErrorMessage = null; + + const maxSnippetMtime = + max( + (await Promise.all(snippetFiles.map((file) => stat(file)))).map( + (stat) => stat.mtimeMs, + ), + ) ?? 0; + + if (maxSnippetMtime <= this.maxSnippetMtimeMs) { + return; + } + + this.maxSnippetMtimeMs = maxSnippetMtime; + + 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) { + void showError( + this.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(); + } + + /** + * Merge core, third-party, and user snippets, with precedence user > third + * party > core. + */ + private mergeSnippets() { + this.mergedSnippets = mergeSnippets( + this.coreSnippets, + {}, + this.userSnippets, + ); + } + + /** + * Looks in merged collection of snippets for a snippet with key + * `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 + */ + 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 openNewSnippetFile( @@ -58,6 +253,10 @@ export class VscodeSnippets implements Snippets { } } +function getSnippetPaths(snippetsDir: string) { + return walkFiles(snippetsDir, CURSORLESS_SNIPPETS_SUFFIX); +} + async function touch(path: string) { const file = await open(path, "w"); await file.close(); diff --git a/packages/cursorless-vscode/src/snippetLegacy/compareSnippetDefinitions.ts b/packages/cursorless-vscode/src/snippetLegacy/compareSnippetDefinitions.ts new file mode 100644 index 0000000000..b10f5e8c56 --- /dev/null +++ b/packages/cursorless-vscode/src/snippetLegacy/compareSnippetDefinitions.ts @@ -0,0 +1,95 @@ +import type { + SimpleScopeTypeType, + SnippetDefinition, + SnippetScope, +} from "@cursorless/common"; +import type { 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 definition, 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-vscode/src/snippetLegacy/mergeSnippets.ts b/packages/cursorless-vscode/src/snippetLegacy/mergeSnippets.ts new file mode 100644 index 0000000000..e00bdb088e --- /dev/null +++ b/packages/cursorless-vscode/src/snippetLegacy/mergeSnippets.ts @@ -0,0 +1,81 @@ +import type { Snippet, SnippetMap } from "@cursorless/common"; +import { cloneDeep, groupBy, mapValues, merge } from "lodash-es"; +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, +} From ff87a405f80310daa22f6a4aca1ab0560dcea869 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Fri, 7 Feb 2025 05:11:49 +0100 Subject: [PATCH 2/6] Added tests --- .../snippets/snipFunkBeforeClassLegacy.yml | 45 +++++++++++ .../recorded/actions/snippets/tryWrapThis.yml | 7 ++ .../actions/snippets/tryWrapThisLegacy.yml | 35 ++++++++ .../src/types/command/ActionDescriptor.ts | 4 +- .../src/actions/InsertSnippet.ts | 30 +++---- .../src/actions/WrapWithSnippet.ts | 24 +++++- .../snippetsLegacy/InsertSnippetLegacy.ts | 79 ++++++++----------- .../snippetsLegacy/WrapWithSnippetLegacy.ts | 56 ++++++------- .../cursorless-vscode/src/VscodeSnippets.ts | 3 +- packages/cursorless-vscode/src/extension.ts | 3 + 10 files changed, 190 insertions(+), 96 deletions(-) create mode 100644 data/fixtures/recorded/actions/snippets/snipFunkBeforeClassLegacy.yml create mode 100644 data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml diff --git a/data/fixtures/recorded/actions/snippets/snipFunkBeforeClassLegacy.yml b/data/fixtures/recorded/actions/snippets/snipFunkBeforeClassLegacy.yml new file mode 100644 index 0000000000..35cb6755e3 --- /dev/null +++ b/data/fixtures/recorded/actions/snippets/snipFunkBeforeClassLegacy.yml @@ -0,0 +1,45 @@ +languageId: typescript +command: + version: 6 + spokenForm: snippet funk before class + action: + name: insertSnippet + snippetDescription: {type: named, name: functionDeclaration} + destination: + type: primitive + insertionMode: before + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: class} + usePrePhraseSnapshot: true +spokenFormError: named insertion snippet +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/data/fixtures/recorded/actions/snippets/tryWrapThis.yml b/data/fixtures/recorded/actions/snippets/tryWrapThis.yml index 26b2ec3145..150a2ca682 100644 --- a/data/fixtures/recorded/actions/snippets/tryWrapThis.yml +++ b/data/fixtures/recorded/actions/snippets/tryWrapThis.yml @@ -30,3 +30,10 @@ finalState: selections: - anchor: {line: 3, character: 4} active: {line: 3, character: 4} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 4, character: 1} + isReversed: false + hasExplicitRange: true \ No newline at end of file diff --git a/data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml b/data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml new file mode 100644 index 0000000000..ba7e154cdd --- /dev/null +++ b/data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml @@ -0,0 +1,35 @@ +languageId: typescript +command: + version: 6 + spokenForm: try wrap this + action: + name: wrapWithSnippet + snippetDescription: {type: named, name: tryCatchStatement, variableName: body} + target: + type: primitive + mark: {type: cursor} + usePrePhraseSnapshot: true +spokenFormError: named wrap with snippet +initialState: + documentContents: const foo = "bar"; + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: |- + try { + const foo = "bar"; + } catch (err) { + + } + selections: + - anchor: {line: 3, character: 4} + active: {line: 3, character: 4} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 4, character: 1} + isReversed: false + hasExplicitRange: true \ No newline at end of file diff --git a/packages/common/src/types/command/ActionDescriptor.ts b/packages/common/src/types/command/ActionDescriptor.ts index 4bbad94d29..549e7ea341 100644 --- a/packages/common/src/types/command/ActionDescriptor.ts +++ b/packages/common/src/types/command/ActionDescriptor.ts @@ -145,7 +145,7 @@ export interface GenerateSnippetActionDescriptor { target: PartialTargetDescriptor; } -interface NamedInsertSnippetArg { +export interface NamedInsertSnippetArg { type: "named"; name: string; substitutions?: Record; @@ -176,7 +176,7 @@ export interface InsertSnippetActionDescriptor { destination: DestinationDescriptor; } -interface NamedWrapWithSnippetArg { +export interface NamedWrapWithSnippetArg { type: "named"; name: string; variableName: string; diff --git a/packages/cursorless-engine/src/actions/InsertSnippet.ts b/packages/cursorless-engine/src/actions/InsertSnippet.ts index c862203c97..688be156d9 100644 --- a/packages/cursorless-engine/src/actions/InsertSnippet.ts +++ b/packages/cursorless-engine/src/actions/InsertSnippet.ts @@ -5,6 +5,7 @@ import { getPreferredSnippet } from "../core/getPreferredSnippet"; import type { RangeUpdater } from "../core/updateSelections/RangeUpdater"; import { performEditsAndUpdateSelections } from "../core/updateSelections/updateSelections"; import type { ModifierStageFactory } from "../processTargets/ModifierStageFactory"; +import type { ModifierStage } from "../processTargets/PipelineStages.types"; import { ModifyIfUntypedExplicitStage } from "../processTargets/modifiers/ConditionalModifierStages"; import { ide } from "../singletons/ide.singleton"; import { transformSnippetVariables } from "../snippets/transformSnippetVariables"; @@ -13,6 +14,7 @@ import type { Destination } from "../typings/target.types"; import { ensureSingleEditor } from "../util/targetUtils"; import type { Actions } from "./Actions"; import type { ActionReturnValue } from "./actions.types"; +import InsertSnippetLegacy from "./snippetsLegacy/InsertSnippetLegacy"; export default class InsertSnippet { private snippetParser = new SnippetParser(); @@ -29,15 +31,9 @@ export default class InsertSnippet { getFinalStages( destinations: Destination[], snippetDescription: InsertSnippetArg, - ) { + ): ModifierStage[] { if (snippetDescription.type === "named") { - const action = new InsertSnippet( - this.rangeUpdater, - this.snippets, - this.actions, - this.modifierStageFactory, - ); - return action.getFinalStages(destinations, snippetDescription); + return this.legacy().getFinalStages(snippetDescription); } const editor = ensureSingleEditor(destinations); @@ -64,13 +60,7 @@ export default class InsertSnippet { snippetDescription: InsertSnippetArg, ): Promise { if (snippetDescription.type === "named") { - const action = new InsertSnippet( - this.rangeUpdater, - this.snippets, - this.actions, - this.modifierStageFactory, - ); - return action.run(destinations, snippetDescription); + return this.legacy().run(destinations, snippetDescription); } const editor = ide().getEditableTextEditor( @@ -113,4 +103,14 @@ export default class InsertSnippet { })), }; } + + // DEPRECATED @ 2025-02-07 + private legacy() { + return new InsertSnippetLegacy( + this.rangeUpdater, + this.snippets, + this.actions, + this.modifierStageFactory, + ); + } } diff --git a/packages/cursorless-engine/src/actions/WrapWithSnippet.ts b/packages/cursorless-engine/src/actions/WrapWithSnippet.ts index 4610d69701..fbbc2f69ad 100644 --- a/packages/cursorless-engine/src/actions/WrapWithSnippet.ts +++ b/packages/cursorless-engine/src/actions/WrapWithSnippet.ts @@ -5,6 +5,7 @@ import { getPreferredSnippet } from "../core/getPreferredSnippet"; import type { RangeUpdater } from "../core/updateSelections/RangeUpdater"; import { performEditsAndUpdateSelections } from "../core/updateSelections/updateSelections"; import type { ModifierStageFactory } from "../processTargets/ModifierStageFactory"; +import type { ModifierStage } from "../processTargets/PipelineStages.types"; import { ModifyIfUntypedStage } from "../processTargets/modifiers/ConditionalModifierStages"; import { ide } from "../singletons/ide.singleton"; import { transformSnippetVariables } from "../snippets/transformSnippetVariables"; @@ -12,6 +13,7 @@ import { SnippetParser } from "../snippets/vendor/vscodeSnippet/snippetParser"; import type { Target } from "../typings/target.types"; import { ensureSingleEditor, flashTargets } from "../util/targetUtils"; import type { ActionReturnValue } from "./actions.types"; +import WrapWithSnippetLegacy from "./snippetsLegacy/WrapWithSnippetLegacy"; export default class WrapWithSnippet { private snippetParser = new SnippetParser(); @@ -24,7 +26,14 @@ export default class WrapWithSnippet { this.run = this.run.bind(this); } - getFinalStages(targets: Target[], snippetDescription: WrapWithSnippetArg) { + getFinalStages( + targets: Target[], + snippetDescription: WrapWithSnippetArg, + ): ModifierStage[] { + if (snippetDescription.type === "named") { + return this.legacy().getFinalStages(snippetDescription); + } + const editor = ensureSingleEditor(targets); const snippet = getPreferredSnippet( snippetDescription, @@ -50,6 +59,10 @@ export default class WrapWithSnippet { targets: Target[], snippetDescription: WrapWithSnippetArg, ): Promise { + if (snippetDescription.type === "named") { + return this.legacy().run(targets, snippetDescription); + } + const editor = ide().getEditableTextEditor(ensureSingleEditor(targets)); const snippet = getPreferredSnippet( snippetDescription, @@ -84,4 +97,13 @@ export default class WrapWithSnippet { })), }; } + + // DEPRECATED @ 2025-02-07 + private legacy() { + return new WrapWithSnippetLegacy( + this.rangeUpdater, + this.snippets, + this.modifierStageFactory, + ); + } } diff --git a/packages/cursorless-engine/src/actions/snippetsLegacy/InsertSnippetLegacy.ts b/packages/cursorless-engine/src/actions/snippetsLegacy/InsertSnippetLegacy.ts index 1e437371aa..e65b0e34e2 100644 --- a/packages/cursorless-engine/src/actions/snippetsLegacy/InsertSnippetLegacy.ts +++ b/packages/cursorless-engine/src/actions/snippetsLegacy/InsertSnippetLegacy.ts @@ -1,5 +1,5 @@ import type { - InsertSnippetArg, + NamedInsertSnippetArg, ScopeType, Snippet, SnippetDefinition, @@ -21,6 +21,7 @@ import { findMatchingSnippetDefinitionStrict, transformSnippetVariables, } from "../snippetsLegacy/snippet"; +import { textFormatters, type TextFormatterName } from "./textFormatters"; export default class InsertSnippetLegacy { private snippetParser = new SnippetParser(); @@ -34,7 +35,7 @@ export default class InsertSnippetLegacy { this.run = this.run.bind(this); } - getFinalStages(snippetDescription: InsertSnippetArg) { + getFinalStages(snippetDescription: NamedInsertSnippetArg) { const defaultScopeTypes = this.getScopeTypes(snippetDescription); return defaultScopeTypes.length === 0 @@ -50,61 +51,49 @@ export default class InsertSnippetLegacy { ]; } - private getScopeTypes(snippetDescription: InsertSnippetArg): ScopeType[] { - if (snippetDescription.type === "named") { - const { name } = snippetDescription; - - const snippet = this.snippets.getSnippetStrict(name); - - const scopeTypeTypes = snippet.insertionScopeTypes; - return scopeTypeTypes == null - ? [] - : scopeTypeTypes.map((scopeTypeType) => ({ - type: scopeTypeType, - })); - } else { - return snippetDescription.scopeTypes ?? []; - } + private getScopeTypes( + snippetDescription: NamedInsertSnippetArg, + ): ScopeType[] { + const { name } = snippetDescription; + + const snippet = this.snippets.getSnippetStrict(name); + + const scopeTypeTypes = snippet.insertionScopeTypes; + return scopeTypeTypes == null + ? [] + : scopeTypeTypes.map((scopeTypeType) => ({ + type: scopeTypeType, + })); } private getSnippetInfo( - snippetDescription: InsertSnippetArg, + snippetDescription: NamedInsertSnippetArg, targets: Target[], ) { - if (snippetDescription.type === "named") { - const { name } = snippetDescription; + const { name } = snippetDescription; - const snippet = this.snippets.getSnippetStrict(name); + const snippet = this.snippets.getSnippetStrict(name); - const definition = findMatchingSnippetDefinitionStrict( - this.modifierStageFactory, - targets, - snippet.definitions, - ); - - return { - body: definition.body.join("\n"), + const definition = findMatchingSnippetDefinitionStrict( + this.modifierStageFactory, + targets, + snippet.definitions, + ); - formatSubstitutions(substitutions: Record | undefined) { - return substitutions == null - ? undefined - : formatSubstitutions(snippet, definition, substitutions); - }, - }; - } else { - return { - body: snippetDescription.body, + return { + body: definition.body.join("\n"), - formatSubstitutions(substitutions: Record | undefined) { - return substitutions; - }, - }; - } + formatSubstitutions(substitutions: Record | undefined) { + return substitutions == null + ? undefined + : formatSubstitutions(snippet, definition, substitutions); + }, + }; } async run( destinations: Destination[], - snippetDescription: InsertSnippetArg, + snippetDescription: NamedInsertSnippetArg, ): Promise { const editor = ide().getEditableTextEditor( ensureSingleEditor(destinations), @@ -187,7 +176,7 @@ function formatSubstitutions( return [variableName, value]; } - const formatter = textFormatters[formatterName]; + const formatter = textFormatters[formatterName as TextFormatterName]; if (formatter == null) { throw new Error( diff --git a/packages/cursorless-engine/src/actions/snippetsLegacy/WrapWithSnippetLegacy.ts b/packages/cursorless-engine/src/actions/snippetsLegacy/WrapWithSnippetLegacy.ts index 3a1ceffef7..5e9c4d1dce 100644 --- a/packages/cursorless-engine/src/actions/snippetsLegacy/WrapWithSnippetLegacy.ts +++ b/packages/cursorless-engine/src/actions/snippetsLegacy/WrapWithSnippetLegacy.ts @@ -1,4 +1,4 @@ -import type { ScopeType, WrapWithSnippetArg } from "@cursorless/common"; +import type { NamedWrapWithSnippetArg, ScopeType } from "@cursorless/common"; import { FlashStyle } from "@cursorless/common"; import type { Snippets } from "../../core/Snippets"; import type { RangeUpdater } from "../../core/updateSelections/RangeUpdater"; @@ -26,7 +26,7 @@ export default class WrapWithSnippetLegacy { this.run = this.run.bind(this); } - getFinalStages(snippet: WrapWithSnippetArg) { + getFinalStages(snippet: NamedWrapWithSnippetArg) { const defaultScopeType = this.getScopeType(snippet); if (defaultScopeType == null) { @@ -45,49 +45,41 @@ export default class WrapWithSnippetLegacy { } private getScopeType( - snippetDescription: WrapWithSnippetArg, + snippetDescription: NamedWrapWithSnippetArg, ): ScopeType | undefined { - if (snippetDescription.type === "named") { - const { name, variableName } = snippetDescription; - - const snippet = this.snippets.getSnippetStrict(name); - - const variables = snippet.variables ?? {}; - const scopeTypeType = variables[variableName]?.wrapperScopeType; - return scopeTypeType == null - ? undefined - : { - type: scopeTypeType, - }; - } else { - return snippetDescription.scopeType; - } + const { name, variableName } = snippetDescription; + + const snippet = this.snippets.getSnippetStrict(name); + + const variables = snippet.variables ?? {}; + const scopeTypeType = variables[variableName]?.wrapperScopeType; + return scopeTypeType == null + ? undefined + : { + type: scopeTypeType, + }; } private getBody( - snippetDescription: WrapWithSnippetArg, + snippetDescription: NamedWrapWithSnippetArg, targets: Target[], ): string { - if (snippetDescription.type === "named") { - const { name } = snippetDescription; + const { name } = snippetDescription; - const snippet = this.snippets.getSnippetStrict(name); + const snippet = this.snippets.getSnippetStrict(name); - const definition = findMatchingSnippetDefinitionStrict( - this.modifierStageFactory, - targets, - snippet.definitions, - ); + const definition = findMatchingSnippetDefinitionStrict( + this.modifierStageFactory, + targets, + snippet.definitions, + ); - return definition.body.join("\n"); - } else { - return snippetDescription.body; - } + return definition.body.join("\n"); } async run( targets: Target[], - snippetDescription: WrapWithSnippetArg, + snippetDescription: NamedWrapWithSnippetArg, ): Promise { const editor = ide().getEditableTextEditor(ensureSingleEditor(targets)); diff --git a/packages/cursorless-vscode/src/VscodeSnippets.ts b/packages/cursorless-vscode/src/VscodeSnippets.ts index d03e3cc4d3..ea06aacf80 100644 --- a/packages/cursorless-vscode/src/VscodeSnippets.ts +++ b/packages/cursorless-vscode/src/VscodeSnippets.ts @@ -7,7 +7,8 @@ import { open, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { mergeSnippets } from "./snippetLegacy/mergeSnippets"; -const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; +// DEPRECATED @ 2025-02-07 +export const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000; interface DirectoryErrorMessage { diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index d6ff7f83cd..3873024ac0 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -97,7 +97,10 @@ export async function activate( const treeSitter = createTreeSitter(parseTreeApi); const talonSpokenForms = new FileSystemTalonSpokenForms(fileSystem); + // NOTE: do not await on snippet loading and hats initialization because we don't want to + // block extension activation const snippets = new VscodeSnippets(normalizedIde); + void snippets.init(); const treeSitterQueryProvider = new FileSystemRawTreeSitterQueryProvider( normalizedIde, From c1c9f4274f2054a60b7f5c461e72a3363eda6aa4 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Fri, 7 Feb 2025 05:13:35 +0100 Subject: [PATCH 3/6] Re named folder --- .../compareSnippetDefinitions.ts | 0 .../src/{snippetLegacy => snippetsLegacy}/mergeSnippets.ts | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/cursorless-vscode/src/{snippetLegacy => snippetsLegacy}/compareSnippetDefinitions.ts (100%) rename packages/cursorless-vscode/src/{snippetLegacy => snippetsLegacy}/mergeSnippets.ts (100%) diff --git a/packages/cursorless-vscode/src/snippetLegacy/compareSnippetDefinitions.ts b/packages/cursorless-vscode/src/snippetsLegacy/compareSnippetDefinitions.ts similarity index 100% rename from packages/cursorless-vscode/src/snippetLegacy/compareSnippetDefinitions.ts rename to packages/cursorless-vscode/src/snippetsLegacy/compareSnippetDefinitions.ts diff --git a/packages/cursorless-vscode/src/snippetLegacy/mergeSnippets.ts b/packages/cursorless-vscode/src/snippetsLegacy/mergeSnippets.ts similarity index 100% rename from packages/cursorless-vscode/src/snippetLegacy/mergeSnippets.ts rename to packages/cursorless-vscode/src/snippetsLegacy/mergeSnippets.ts From 4825b01de401f949c0c5818b6cfa7c6387a22f0d Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Fri, 7 Feb 2025 05:15:16 +0100 Subject: [PATCH 4/6] Update comment --- packages/cursorless-engine/src/actions/InsertSnippet.ts | 2 +- packages/cursorless-engine/src/actions/WrapWithSnippet.ts | 2 +- packages/cursorless-vscode/src/VscodeSnippets.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cursorless-engine/src/actions/InsertSnippet.ts b/packages/cursorless-engine/src/actions/InsertSnippet.ts index 688be156d9..87ed8dea6e 100644 --- a/packages/cursorless-engine/src/actions/InsertSnippet.ts +++ b/packages/cursorless-engine/src/actions/InsertSnippet.ts @@ -104,7 +104,7 @@ export default class InsertSnippet { }; } - // DEPRECATED @ 2025-02-07 + // DEPRECATED @ 2025-02-01 private legacy() { return new InsertSnippetLegacy( this.rangeUpdater, diff --git a/packages/cursorless-engine/src/actions/WrapWithSnippet.ts b/packages/cursorless-engine/src/actions/WrapWithSnippet.ts index fbbc2f69ad..26178e3353 100644 --- a/packages/cursorless-engine/src/actions/WrapWithSnippet.ts +++ b/packages/cursorless-engine/src/actions/WrapWithSnippet.ts @@ -98,7 +98,7 @@ export default class WrapWithSnippet { }; } - // DEPRECATED @ 2025-02-07 + // DEPRECATED @ 2025-02-01 private legacy() { return new WrapWithSnippetLegacy( this.rangeUpdater, diff --git a/packages/cursorless-vscode/src/VscodeSnippets.ts b/packages/cursorless-vscode/src/VscodeSnippets.ts index ea06aacf80..722a8099eb 100644 --- a/packages/cursorless-vscode/src/VscodeSnippets.ts +++ b/packages/cursorless-vscode/src/VscodeSnippets.ts @@ -5,9 +5,9 @@ import { walkFiles } from "@cursorless/node-common"; import { max } from "lodash-es"; import { open, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { mergeSnippets } from "./snippetLegacy/mergeSnippets"; +import { mergeSnippets } from "./snippetsLegacy/mergeSnippets"; -// DEPRECATED @ 2025-02-07 +// DEPRECATED @ 2025-02-01 export const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000; From c3b106315721eb6f682bb6491245a9488b9a25d6 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Fri, 7 Feb 2025 05:18:40 +0100 Subject: [PATCH 5/6] Rename --- .../cursorless-engine/src/actions/snippetsLegacy/snippet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cursorless-engine/src/actions/snippetsLegacy/snippet.ts b/packages/cursorless-engine/src/actions/snippetsLegacy/snippet.ts index 78a98d8f36..8cffb3ac06 100644 --- a/packages/cursorless-engine/src/actions/snippetsLegacy/snippet.ts +++ b/packages/cursorless-engine/src/actions/snippetsLegacy/snippet.ts @@ -187,7 +187,7 @@ function findMatchingSnippetDefinitionForSingleTarget( matchingTarget = containingTarget; matchingScopeType = scopeTypeType; } - } catch (e) { + } catch (_e) { continue; } } From 4c43a9de5ad73e97ca80303e37350189aa77477f Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Fri, 7 Feb 2025 05:20:09 +0100 Subject: [PATCH 6/6] New line --- data/fixtures/recorded/actions/snippets/tryWrapThis.yml | 2 +- data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/fixtures/recorded/actions/snippets/tryWrapThis.yml b/data/fixtures/recorded/actions/snippets/tryWrapThis.yml index 150a2ca682..01a54a4c38 100644 --- a/data/fixtures/recorded/actions/snippets/tryWrapThis.yml +++ b/data/fixtures/recorded/actions/snippets/tryWrapThis.yml @@ -36,4 +36,4 @@ finalState: start: {line: 0, character: 0} end: {line: 4, character: 1} isReversed: false - hasExplicitRange: true \ No newline at end of file + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml b/data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml index ba7e154cdd..3b3212b41d 100644 --- a/data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml +++ b/data/fixtures/recorded/actions/snippets/tryWrapThisLegacy.yml @@ -32,4 +32,4 @@ finalState: start: {line: 0, character: 0} end: {line: 4, character: 1} isReversed: false - hasExplicitRange: true \ No newline at end of file + hasExplicitRange: true