diff --git a/cursorless-talon/src/actions/generate_snippet.py b/cursorless-talon/src/actions/generate_snippet.py index ef5452d2ea..4f85521e71 100644 --- a/cursorless-talon/src/actions/generate_snippet.py +++ b/cursorless-talon/src/actions/generate_snippet.py @@ -15,6 +15,13 @@ @mod.action_class class Actions: + def private_cursorless_migrate_snippets(): + """Migrate snippets from Cursorless to community format""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.migrateSnippets", + str(get_directory_path()), + ) + def private_cursorless_generate_snippet_action(target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues] """Generate a snippet from the given target""" actions.user.private_cursorless_command_no_wait( diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon index 11c2fa2f9a..96e34b03b1 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -47,3 +47,6 @@ tutorial resume: user.private_cursorless_tutorial_resume() tutorial (list | close): user.private_cursorless_tutorial_list() tutorial : user.private_cursorless_tutorial_start_by_number(number_small) + +{user.cursorless_homophone} migrate snippets: + user.private_cursorless_migrate_snippets() diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index a06d70a48a..c9292d4cc3 100644 --- a/packages/common/src/cursorlessCommandIds.ts +++ b/packages/common/src/cursorlessCommandIds.ts @@ -38,6 +38,7 @@ export const cursorlessCommandIds = [ "cursorless.keyboard.targeted.targetHat", "cursorless.keyboard.targeted.targetScope", "cursorless.keyboard.targeted.targetSelection", + "cursorless.migrateSnippets", "cursorless.pauseRecording", "cursorless.recomputeDecorationStyles", "cursorless.recordTestCase", @@ -164,4 +165,7 @@ export const cursorlessCommandDescriptions: Record< ["cursorless.keyboard.redoTarget"]: new HiddenCommand( "Redo keyboard targeting changes", ), + ["cursorless.migrateSnippets"]: new HiddenCommand( + "Migrate snippets from the old Cursorless format to the new community format", + ), }; diff --git a/packages/cursorless-engine/package.json b/packages/cursorless-engine/package.json index fc6cf065a8..503fdb5d8c 100644 --- a/packages/cursorless-engine/package.json +++ b/packages/cursorless-engine/package.json @@ -30,7 +30,7 @@ "lodash-es": "^4.17.21", "moo": "0.5.2", "nearley": "2.20.1", - "talon-snippets": "1.1.0", + "talon-snippets": "1.3.0", "uuid": "^10.0.0", "zod": "3.23.8" }, diff --git a/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetCommunity.ts b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetCommunity.ts index 8f77d8a4d5..898c15db7d 100644 --- a/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetCommunity.ts +++ b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetCommunity.ts @@ -7,10 +7,11 @@ import { type TextEditor, } from "@cursorless/common"; import { - getHeaderSnippet, parseSnippetFile, serializeSnippetFile, - type SnippetDocument, + type Snippet, + type SnippetFile, + type SnippetHeader, type SnippetVariable, } from "talon-snippets"; import type { Snippets } from "../../core/Snippets"; @@ -129,36 +130,35 @@ export default class GenerateSnippetCommunity { const snippetLines = constructSnippetBody(snippetBodyText, linePrefix); let editableEditor: EditableTextEditor; - let snippetDocuments: SnippetDocument[]; + let snippetFile: SnippetFile = { snippets: [] }; if (ide().runMode === "test") { // If we're testing, we just overwrite the current document editableEditor = ide().getEditableTextEditor(editor); - snippetDocuments = []; } else { // Otherwise, we create and open a new document for the snippet editableEditor = ide().getEditableTextEditor( await this.snippets.openNewSnippetFile(snippetName, directory), ); - snippetDocuments = parseSnippetFile(editableEditor.document.getText()); + snippetFile = parseSnippetFile(editableEditor.document.getText()); } await editableEditor.setSelections([ editableEditor.document.range.toSelection(false), ]); - const headerSnippet = getHeaderSnippet(snippetDocuments); - /** The next placeholder index to use for the meta snippet */ let currentPlaceholderIndex = 1; + const { header } = snippetFile; + const phrases = - headerSnippet?.phrases != null + snippetFile.header?.phrases != null ? undefined : [`${PLACEHOLDER}${currentPlaceholderIndex++}`]; const createVariable = (variable: Variable): SnippetVariable => { - const hasPhrase = headerSnippet?.variables?.some( + const hasPhrase = header?.variables?.some( (v) => v.name === variable.name && v.wrapperPhrases != null, ); return { @@ -169,22 +169,22 @@ export default class GenerateSnippetCommunity { }; }; - const snippet: SnippetDocument = { - name: headerSnippet?.name === snippetName ? undefined : snippetName, + const snippet: Snippet = { + name: header?.name === snippetName ? undefined : snippetName, phrases, - languages: getSnippetLanguages(editor, headerSnippet), + languages: getSnippetLanguages(editor, header), body: snippetLines, variables: variables.map(createVariable), }; - snippetDocuments.push(snippet); + snippetFile.snippets.push(snippet); /** * This is the text of the meta-snippet in Textmate format that we will * insert into the new document where the user will fill out their snippet * definition */ - const metaSnippetText = serializeSnippetFile(snippetDocuments) + const metaSnippetText = serializeSnippetFile(snippetFile) // Escape dollar signs in the snippet text so that they don't get used as // placeholders in the meta snippet .replace(/\$/g, "\\$") @@ -205,7 +205,7 @@ export default class GenerateSnippetCommunity { function getSnippetLanguages( editor: TextEditor, - header: SnippetDocument | undefined, + header: SnippetHeader | undefined, ): string[] | undefined { if (header?.languages?.includes(editor.document.languageId)) { return undefined; diff --git a/packages/cursorless-neovim/src/registerCommands.ts b/packages/cursorless-neovim/src/registerCommands.ts index c926191eff..048955b38b 100644 --- a/packages/cursorless-neovim/src/registerCommands.ts +++ b/packages/cursorless-neovim/src/registerCommands.ts @@ -88,6 +88,7 @@ export async function registerCommands( ["cursorless.showQuickPick"]: dummyCommandHandler, ["cursorless.showDocumentation"]: dummyCommandHandler, ["cursorless.showInstallationDependencies"]: dummyCommandHandler, + ["cursorless.migrateSnippets"]: dummyCommandHandler, ["cursorless.private.logQuickActions"]: dummyCommandHandler, // Hats diff --git a/packages/cursorless-org-docs/src/docs/user/experimental/snippets.md b/packages/cursorless-org-docs/src/docs/user/experimental/snippets.md index 0748ea0535..850cd68927 100644 --- a/packages/cursorless-org-docs/src/docs/user/experimental/snippets.md +++ b/packages/cursorless-org-docs/src/docs/user/experimental/snippets.md @@ -19,6 +19,10 @@ Note that this line will also disable any Cursorless snippets defined in your Cu Cursorless has its own experimental snippet engine that allows you to both insert snippets and wrap targets with snippets. Cursorless ships with a few built-in snippets, but users can also use their own snippets. +## Migrate Cursorless snippet to community + +Say `"Cursorless migrate snippets"` to convert your existing experimental Cursorless snippet JSON files (which are now deprecated) to the new community snippet format. + ## Using snippets ### Wrapping a target with snippets diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 83a2b1cc4d..b9f70fda81 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -238,6 +238,11 @@ "command": "cursorless.keyboard.redoTarget", "title": "Cursorless: Redo keyboard targeting changes", "enablement": "false" + }, + { + "command": "cursorless.migrateSnippets", + "title": "Cursorless: Migrate snippets from the old Cursorless format to the new community format", + "enablement": "false" } ], "colors": [ @@ -1284,6 +1289,7 @@ "lodash-es": "^4.17.21", "nearley": "2.20.1", "semver": "^7.6.3", + "talon-snippets": "1.3.0", "tinycolor2": "1.6.0", "trie-search": "2.0.0", "uuid": "^10.0.0", diff --git a/packages/cursorless-vscode/src/VscodeSnippets.ts b/packages/cursorless-vscode/src/VscodeSnippets.ts index 0a24f446ad..4514809b3b 100644 --- a/packages/cursorless-vscode/src/VscodeSnippets.ts +++ b/packages/cursorless-vscode/src/VscodeSnippets.ts @@ -6,7 +6,7 @@ import { max } from "lodash-es"; import { open, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; +export const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets"; const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000; interface DirectoryErrorMessage { @@ -77,7 +77,7 @@ export class VscodeSnippets implements Snippets { async init() { const extensionPath = this.ide.assetsRoot; const snippetsDir = join(extensionPath, "cursorless-snippets"); - const snippetFiles = await getSnippetPaths(snippetsDir); + const snippetFiles = await this.getSnippetPaths(snippetsDir); this.coreSnippets = mergeStrict( ...(await Promise.all( snippetFiles.map(async (path) => @@ -115,7 +115,7 @@ export class VscodeSnippets implements Snippets { let snippetFiles: string[]; try { snippetFiles = this.userSnippetsDir - ? await getSnippetPaths(this.userSnippetsDir) + ? await this.getSnippetPaths(this.userSnippetsDir) : []; } catch (err) { if (this.directoryErrorMessage?.directory !== this.userSnippetsDir) { @@ -244,24 +244,31 @@ export class VscodeSnippets implements Snippets { return join(directory, `${snippetName}.snippet`); } - const userSnippetsDir = this.ide.configuration.getOwnConfiguration( - "experimental.snippetsDir", + return join( + this.getUserDirectoryStrict(), + `${snippetName}.cursorless-snippets`, ); - - if (!userSnippetsDir) { - throw new Error("User snippets dir not configured."); - } - - return join(userSnippetsDir, `${snippetName}.cursorless-snippets`); })(); await touch(path); return this.ide.openTextDocument(path); } -} -function getSnippetPaths(snippetsDir: string) { - return walkFiles(snippetsDir, CURSORLESS_SNIPPETS_SUFFIX); + getUserDirectoryStrict() { + const userSnippetsDir = this.ide.configuration.getOwnConfiguration( + "experimental.snippetsDir", + ); + + if (!userSnippetsDir) { + throw new Error("User snippets dir not configured."); + } + + return userSnippetsDir; + } + + getSnippetPaths(snippetsDir: string) { + return walkFiles(snippetsDir, CURSORLESS_SNIPPETS_SUFFIX); + } } async function touch(path: string) { diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 70555d0f47..a3995a33ce 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -194,6 +194,7 @@ export async function activate( vscodeTutorial, installationDependencies, storedTargets, + snippets, ); void new ReleaseNotes(vscodeApi, context, normalizedIde.messages).maybeShow(); diff --git a/packages/cursorless-vscode/src/migrateSnippets.ts b/packages/cursorless-vscode/src/migrateSnippets.ts new file mode 100644 index 0000000000..f85b3bf7f6 --- /dev/null +++ b/packages/cursorless-vscode/src/migrateSnippets.ts @@ -0,0 +1,109 @@ +import type { + SnippetMap, + SnippetVariable as SnippetVariableLegacy, +} from "@cursorless/common"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { + serializeSnippetFile, + type SnippetFile, + type SnippetVariable, +} from "talon-snippets"; +import * as vscode from "vscode"; +import { + CURSORLESS_SNIPPETS_SUFFIX, + type VscodeSnippets, +} from "./VscodeSnippets"; + +export async function migrateSnippets( + snippets: VscodeSnippets, + targetDirectory: string, +) { + const userSnippetsDir = snippets.getUserDirectoryStrict(); + const files = await snippets.getSnippetPaths(userSnippetsDir); + + for (const file of files) { + await migrateFile(targetDirectory, file); + } + + await vscode.window.showInformationMessage( + `${files.length} snippet files migrated successfully!`, + ); +} + +async function migrateFile(targetDirectory: string, filePath: string) { + const fileName = path.basename(filePath, CURSORLESS_SNIPPETS_SUFFIX); + const snippetFile = await readLegacyFile(filePath); + const communitySnippetFile: SnippetFile = { snippets: [] }; + + for (const snippetName in snippetFile) { + const snippet = snippetFile[snippetName]; + + communitySnippetFile.header = { + name: snippetName, + description: snippet.description, + variables: parseVariables(snippet.variables), + insertionScopes: snippet.insertionScopeTypes, + }; + + for (const def of snippet.definitions) { + communitySnippetFile.snippets.push({ + body: def.body.map((line) => line.replaceAll("\t", " ")), + languages: def.scope?.langIds, + variables: parseVariables(def.variables), + // SKIP: def.scope?.scopeTypes + // SKIP: def.scope?.excludeDescendantScopeTypes + }); + } + } + + try { + const destinationPath = path.join(targetDirectory, `${fileName}.snippet`); + await writeCommunityFile(communitySnippetFile, destinationPath); + } catch (error: any) { + if (error.code === "EEXIST") { + const destinationPath = path.join( + targetDirectory, + `${fileName}_CONFLICT.snippet`, + ); + await writeCommunityFile(communitySnippetFile, destinationPath); + } else { + throw error; + } + } +} + +function parseVariables( + variables?: Record, +): SnippetVariable[] { + return Object.entries(variables ?? {}).map( + ([name, variable]): SnippetVariable => { + return { + name, + wrapperScope: variable.wrapperScopeType, + insertionFormatters: variable.formatter + ? [variable.formatter] + : undefined, + // SKIP: variable.description + }; + }, + ); +} + +async function readLegacyFile(filePath: string): Promise { + const content = await fs.readFile(filePath, "utf8"); + if (content.length === 0) { + return {}; + } + return JSON.parse(content); +} + +async function writeCommunityFile(snippetFile: SnippetFile, filePath: string) { + const snippetText = serializeSnippetFile(snippetFile); + const file = await fs.open(filePath, "wx"); + try { + await file.write(snippetText); + } finally { + await file.close(); + } +} diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts index 478a59782f..21faece1a9 100644 --- a/packages/cursorless-vscode/src/registerCommands.ts +++ b/packages/cursorless-vscode/src/registerCommands.ts @@ -19,12 +19,14 @@ import type { import * as vscode from "vscode"; import type { InstallationDependencies } from "./InstallationDependencies"; import type { ScopeVisualizer } from "./ScopeVisualizerCommandApi"; +import type { VscodeSnippets } from "./VscodeSnippets"; import type { VscodeTutorial } from "./VscodeTutorial"; import { showDocumentation, showQuickPick } from "./commands"; import type { VscodeIDE } from "./ide/vscode/VscodeIDE"; import type { VscodeHats } from "./ide/vscode/hats/VscodeHats"; import type { KeyboardCommands } from "./keyboard/KeyboardCommands"; import { logQuickActions } from "./logQuickActions"; +import { migrateSnippets } from "./migrateSnippets"; export function registerCommands( extensionContext: vscode.ExtensionContext, @@ -39,6 +41,7 @@ export function registerCommands( tutorial: VscodeTutorial, installationDependencies: InstallationDependencies, storedTargets: StoredTargetMap, + snippets: VscodeSnippets, ): void { const runCommandWrapper = async (run: () => Promise) => { try { @@ -86,6 +89,8 @@ export function registerCommands( ["cursorless.showDocumentation"]: showDocumentation, ["cursorless.showInstallationDependencies"]: installationDependencies.show, + ["cursorless.migrateSnippets"]: (dir) => migrateSnippets(snippets, dir), + ["cursorless.private.logQuickActions"]: logQuickActions, // Hats diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f879ab8f41..169d21af0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,8 +285,8 @@ importers: specifier: 2.20.1 version: 2.20.1(patch_hash=mg2fc7wgvzub3myuz6m74hllma) talon-snippets: - specifier: 1.1.0 - version: 1.1.0 + specifier: 1.3.0 + version: 1.3.0 uuid: specifier: ^10.0.0 version: 10.0.0 @@ -643,6 +643,9 @@ importers: semver: specifier: ^7.6.3 version: 7.6.3 + talon-snippets: + specifier: 1.3.0 + version: 1.3.0 tinycolor2: specifier: 1.6.0 version: 1.6.0 @@ -9245,8 +9248,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - talon-snippets@1.1.0: - resolution: {integrity: sha512-NOkb/8KOlezJXP2TVzYF4AJBdrew1c1636EqEUxEyese8Qpb1yQyRkZtY16YzAoTpwcEg4KYxX6vl8SaRlHyDA==} + talon-snippets@1.3.0: + resolution: {integrity: sha512-iFc1ePBQyaqZ73TL0lVgY+G8/DBfFTSiBRVdT2wT1CdPDips6usxSkBmXKGTDgHYJKstQx/NpXhIc0vXiAL4Kw==} tapable@1.1.3: resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==} @@ -20849,7 +20852,7 @@ snapshots: transitivePeerDependencies: - ts-node - talon-snippets@1.1.0: {} + talon-snippets@1.3.0: {} tapable@1.1.3: {}