From 2c19566d4897b44c4d7eeed640c1db454152b810 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Thu, 30 Jan 2025 18:10:07 +0100 Subject: [PATCH] Migrate snippets follow up (#2789) 1. Skip snippets with fields we cannot migrate instead of just discarding those fields 2. Show a untitled markdown document with the migration results 3. Use spoken forms/phrases from Talon ## Checklist - [/] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [/] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [/] I have not broken the cheatsheet --------- Co-authored-by: Phil Cohen --- .../src/actions/generate_snippet.py | 11 +- .../cursorless-vscode/src/migrateSnippets.ts | 154 ++++++++++++++++-- .../cursorless-vscode/src/registerCommands.ts | 2 +- 3 files changed, 147 insertions(+), 20 deletions(-) diff --git a/cursorless-talon/src/actions/generate_snippet.py b/cursorless-talon/src/actions/generate_snippet.py index 4f85521e71..54835bd66f 100644 --- a/cursorless-talon/src/actions/generate_snippet.py +++ b/cursorless-talon/src/actions/generate_snippet.py @@ -1,7 +1,7 @@ import glob from pathlib import Path -from talon import Context, Module, actions, settings +from talon import Context, Module, actions, registry, settings from ..targets.target_types import CursorlessExplicitTarget @@ -20,6 +20,15 @@ def private_cursorless_migrate_snippets(): actions.user.private_cursorless_run_rpc_command_no_wait( "cursorless.migrateSnippets", str(get_directory_path()), + { + "insertion": registry.lists[ + "user.cursorless_insertion_snippet_no_phrase" + ][-1], + "insertionWithPhrase": registry.lists[ + "user.cursorless_insertion_snippet_single_phrase" + ][-1], + "wrapper": registry.lists["user.cursorless_wrapper_snippet"][-1], + }, ) def private_cursorless_generate_snippet_action(target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues] diff --git a/packages/cursorless-vscode/src/migrateSnippets.ts b/packages/cursorless-vscode/src/migrateSnippets.ts index f85b3bf7f6..429b43c4f7 100644 --- a/packages/cursorless-vscode/src/migrateSnippets.ts +++ b/packages/cursorless-vscode/src/migrateSnippets.ts @@ -15,71 +15,130 @@ import { type VscodeSnippets, } from "./VscodeSnippets"; +interface Result { + migrated: Record; + migratedPartially: Record; + skipped: string[]; +} + +interface SpokenForms { + insertion: Record; + insertionWithPhrase: Record; + wrapper: Record; +} + export async function migrateSnippets( snippets: VscodeSnippets, targetDirectory: string, + spokenForms: SpokenForms, ) { - const userSnippetsDir = snippets.getUserDirectoryStrict(); - const files = await snippets.getSnippetPaths(userSnippetsDir); + const sourceDirectory = snippets.getUserDirectoryStrict(); + const files = await snippets.getSnippetPaths(sourceDirectory); + + const spokenFormsInverted: SpokenForms = { + insertion: swapKeyValue(spokenForms.insertion), + insertionWithPhrase: swapKeyValue( + spokenForms.insertionWithPhrase, + (name) => name.split(".")[0], + ), + wrapper: swapKeyValue(spokenForms.wrapper), + }; + + const result: Result = { + migrated: {}, + migratedPartially: {}, + skipped: [], + }; for (const file of files) { - await migrateFile(targetDirectory, file); + await migrateFile(result, spokenFormsInverted, targetDirectory, file); } - await vscode.window.showInformationMessage( - `${files.length} snippet files migrated successfully!`, - ); + await openResultDocument(result, sourceDirectory, targetDirectory); } -async function migrateFile(targetDirectory: string, filePath: string) { +async function migrateFile( + result: Result, + spokenForms: SpokenForms, + targetDirectory: string, + filePath: string, +) { const fileName = path.basename(filePath, CURSORLESS_SNIPPETS_SUFFIX); const snippetFile = await readLegacyFile(filePath); const communitySnippetFile: SnippetFile = { snippets: [] }; + let hasSkippedSnippet = false; for (const snippetName in snippetFile) { const snippet = snippetFile[snippetName]; + const phrase = + spokenForms.insertion[snippetName] ?? + spokenForms.insertionWithPhrase[snippetName]; communitySnippetFile.header = { name: snippetName, description: snippet.description, - variables: parseVariables(snippet.variables), + phrases: phrase ? [phrase] : undefined, + variables: parseVariables(spokenForms, snippetName, snippet.variables), insertionScopes: snippet.insertionScopeTypes, }; for (const def of snippet.definitions) { + if ( + def.scope?.scopeTypes?.length || + def.scope?.excludeDescendantScopeTypes?.length + ) { + hasSkippedSnippet = true; + continue; + } communitySnippetFile.snippets.push({ body: def.body.map((line) => line.replaceAll("\t", " ")), languages: def.scope?.langIds, - variables: parseVariables(def.variables), + variables: parseVariables(spokenForms, snippetName, def.variables), // SKIP: def.scope?.scopeTypes // SKIP: def.scope?.excludeDescendantScopeTypes }); } } + if (communitySnippetFile.snippets.length === 0) { + result.skipped.push(fileName); + return; + } + + let destinationName: string; + try { - const destinationPath = path.join(targetDirectory, `${fileName}.snippet`); - await writeCommunityFile(communitySnippetFile, destinationPath); + destinationName = `${fileName}.snippet`; + const destinationPath = path.join(targetDirectory, destinationName); + await writeCommunityFile(communitySnippetFile, destinationPath, "wx"); } catch (error: any) { if (error.code === "EEXIST") { - const destinationPath = path.join( - targetDirectory, - `${fileName}_CONFLICT.snippet`, - ); - await writeCommunityFile(communitySnippetFile, destinationPath); + destinationName = `${fileName}_CONFLICT.snippet`; + const destinationPath = path.join(targetDirectory, destinationName); + await writeCommunityFile(communitySnippetFile, destinationPath, "w"); } else { throw error; } } + + if (hasSkippedSnippet) { + result.migratedPartially[fileName] = destinationName; + } else { + result.migrated[fileName] = destinationName; + } } function parseVariables( + spokenForms: SpokenForms, + snippetName: string, variables?: Record, ): SnippetVariable[] { return Object.entries(variables ?? {}).map( ([name, variable]): SnippetVariable => { + const phrase = spokenForms.wrapper[`${snippetName}.${name}`]; return { name, + wrapperPhrases: phrase ? [phrase] : undefined, wrapperScope: variable.wrapperScopeType, insertionFormatters: variable.formatter ? [variable.formatter] @@ -90,6 +149,52 @@ function parseVariables( ); } +async function openResultDocument( + result: Result, + sourceDirectory: string, + targetDirectory: string, +) { + const migratedKeys = Object.keys(result.migrated).sort(); + const migratedPartiallyKeys = Object.keys(result.migratedPartially).sort(); + const skipMessage = + "(Snippets containing `scopeTypes` and/or `excludeDescendantScopeTypes` attributes are not supported by community snippets.)"; + + const content: string[] = [ + `# Snippets migrated from Cursorless`, + "", + `From: ${sourceDirectory}`, + `To: ${targetDirectory}`, + "", + `## Migrated ${migratedKeys.length} snippet files:`, + ...migratedKeys.map((key) => `- ${key} -> ${result.migrated[key]}`), + "", + ]; + + if (migratedPartiallyKeys.length > 0) { + content.push( + `## Migrated ${migratedPartiallyKeys.length} snippet files partially:`, + ...migratedPartiallyKeys.map( + (key) => `- ${key} -> ${result.migratedPartially[key]}`, + ), + skipMessage, + ); + } + + if (result.skipped.length > 0) { + content.push( + `## Skipped ${result.skipped.length} snippet files:`, + ...result.skipped.map((key) => `- ${key}`), + skipMessage, + ); + } + + const textDocument = await vscode.workspace.openTextDocument({ + content: content.join("\n"), + language: "markdown", + }); + await vscode.window.showTextDocument(textDocument); +} + async function readLegacyFile(filePath: string): Promise { const content = await fs.readFile(filePath, "utf8"); if (content.length === 0) { @@ -98,12 +203,25 @@ async function readLegacyFile(filePath: string): Promise { return JSON.parse(content); } -async function writeCommunityFile(snippetFile: SnippetFile, filePath: string) { +async function writeCommunityFile( + snippetFile: SnippetFile, + filePath: string, + flags: string, +) { const snippetText = serializeSnippetFile(snippetFile); - const file = await fs.open(filePath, "wx"); + const file = await fs.open(filePath, flags); try { await file.write(snippetText); } finally { await file.close(); } } + +function swapKeyValue( + obj: Record, + map?: (value: string) => string, +): Record { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [map?.(value) ?? value, key]), + ); +} diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts index 21faece1a9..4675bf9d4f 100644 --- a/packages/cursorless-vscode/src/registerCommands.ts +++ b/packages/cursorless-vscode/src/registerCommands.ts @@ -89,7 +89,7 @@ export function registerCommands( ["cursorless.showDocumentation"]: showDocumentation, ["cursorless.showInstallationDependencies"]: installationDependencies.show, - ["cursorless.migrateSnippets"]: (dir) => migrateSnippets(snippets, dir), + ["cursorless.migrateSnippets"]: migrateSnippets.bind(null, snippets), ["cursorless.private.logQuickActions"]: logQuickActions,