Skip to content

Commit

Permalink
Migrate snippets follow up (#2789)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
AndreasArvidsson and phillco authored Jan 30, 2025
1 parent c30ea2c commit 2c19566
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 20 deletions.
11 changes: 10 additions & 1 deletion cursorless-talon/src/actions/generate_snippet.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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]
Expand Down
154 changes: 136 additions & 18 deletions packages/cursorless-vscode/src/migrateSnippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,71 +15,130 @@ import {
type VscodeSnippets,
} from "./VscodeSnippets";

interface Result {
migrated: Record<string, string>;
migratedPartially: Record<string, string>;
skipped: string[];
}

interface SpokenForms {
insertion: Record<string, string>;
insertionWithPhrase: Record<string, string>;
wrapper: Record<string, string>;
}

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<string, SnippetVariableLegacy>,
): 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]
Expand All @@ -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<SnippetMap> {
const content = await fs.readFile(filePath, "utf8");
if (content.length === 0) {
Expand All @@ -98,12 +203,25 @@ async function readLegacyFile(filePath: string): Promise<SnippetMap> {
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<string, string>,
map?: (value: string) => string,
): Record<string, string> {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [map?.(value) ?? value, key]),
);
}
2 changes: 1 addition & 1 deletion packages/cursorless-vscode/src/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down

0 comments on commit 2c19566

Please sign in to comment.