Skip to content

Commit 2c19566

Browse files
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 <[email protected]>
1 parent c30ea2c commit 2c19566

File tree

3 files changed

+147
-20
lines changed

3 files changed

+147
-20
lines changed

cursorless-talon/src/actions/generate_snippet.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import glob
22
from pathlib import Path
33

4-
from talon import Context, Module, actions, settings
4+
from talon import Context, Module, actions, registry, settings
55

66
from ..targets.target_types import CursorlessExplicitTarget
77

@@ -20,6 +20,15 @@ def private_cursorless_migrate_snippets():
2020
actions.user.private_cursorless_run_rpc_command_no_wait(
2121
"cursorless.migrateSnippets",
2222
str(get_directory_path()),
23+
{
24+
"insertion": registry.lists[
25+
"user.cursorless_insertion_snippet_no_phrase"
26+
][-1],
27+
"insertionWithPhrase": registry.lists[
28+
"user.cursorless_insertion_snippet_single_phrase"
29+
][-1],
30+
"wrapper": registry.lists["user.cursorless_wrapper_snippet"][-1],
31+
},
2332
)
2433

2534
def private_cursorless_generate_snippet_action(target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues]

packages/cursorless-vscode/src/migrateSnippets.ts

Lines changed: 136 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,71 +15,130 @@ import {
1515
type VscodeSnippets,
1616
} from "./VscodeSnippets";
1717

18+
interface Result {
19+
migrated: Record<string, string>;
20+
migratedPartially: Record<string, string>;
21+
skipped: string[];
22+
}
23+
24+
interface SpokenForms {
25+
insertion: Record<string, string>;
26+
insertionWithPhrase: Record<string, string>;
27+
wrapper: Record<string, string>;
28+
}
29+
1830
export async function migrateSnippets(
1931
snippets: VscodeSnippets,
2032
targetDirectory: string,
33+
spokenForms: SpokenForms,
2134
) {
22-
const userSnippetsDir = snippets.getUserDirectoryStrict();
23-
const files = await snippets.getSnippetPaths(userSnippetsDir);
35+
const sourceDirectory = snippets.getUserDirectoryStrict();
36+
const files = await snippets.getSnippetPaths(sourceDirectory);
37+
38+
const spokenFormsInverted: SpokenForms = {
39+
insertion: swapKeyValue(spokenForms.insertion),
40+
insertionWithPhrase: swapKeyValue(
41+
spokenForms.insertionWithPhrase,
42+
(name) => name.split(".")[0],
43+
),
44+
wrapper: swapKeyValue(spokenForms.wrapper),
45+
};
46+
47+
const result: Result = {
48+
migrated: {},
49+
migratedPartially: {},
50+
skipped: [],
51+
};
2452

2553
for (const file of files) {
26-
await migrateFile(targetDirectory, file);
54+
await migrateFile(result, spokenFormsInverted, targetDirectory, file);
2755
}
2856

29-
await vscode.window.showInformationMessage(
30-
`${files.length} snippet files migrated successfully!`,
31-
);
57+
await openResultDocument(result, sourceDirectory, targetDirectory);
3258
}
3359

34-
async function migrateFile(targetDirectory: string, filePath: string) {
60+
async function migrateFile(
61+
result: Result,
62+
spokenForms: SpokenForms,
63+
targetDirectory: string,
64+
filePath: string,
65+
) {
3566
const fileName = path.basename(filePath, CURSORLESS_SNIPPETS_SUFFIX);
3667
const snippetFile = await readLegacyFile(filePath);
3768
const communitySnippetFile: SnippetFile = { snippets: [] };
69+
let hasSkippedSnippet = false;
3870

3971
for (const snippetName in snippetFile) {
4072
const snippet = snippetFile[snippetName];
73+
const phrase =
74+
spokenForms.insertion[snippetName] ??
75+
spokenForms.insertionWithPhrase[snippetName];
4176

4277
communitySnippetFile.header = {
4378
name: snippetName,
4479
description: snippet.description,
45-
variables: parseVariables(snippet.variables),
80+
phrases: phrase ? [phrase] : undefined,
81+
variables: parseVariables(spokenForms, snippetName, snippet.variables),
4682
insertionScopes: snippet.insertionScopeTypes,
4783
};
4884

4985
for (const def of snippet.definitions) {
86+
if (
87+
def.scope?.scopeTypes?.length ||
88+
def.scope?.excludeDescendantScopeTypes?.length
89+
) {
90+
hasSkippedSnippet = true;
91+
continue;
92+
}
5093
communitySnippetFile.snippets.push({
5194
body: def.body.map((line) => line.replaceAll("\t", " ")),
5295
languages: def.scope?.langIds,
53-
variables: parseVariables(def.variables),
96+
variables: parseVariables(spokenForms, snippetName, def.variables),
5497
// SKIP: def.scope?.scopeTypes
5598
// SKIP: def.scope?.excludeDescendantScopeTypes
5699
});
57100
}
58101
}
59102

103+
if (communitySnippetFile.snippets.length === 0) {
104+
result.skipped.push(fileName);
105+
return;
106+
}
107+
108+
let destinationName: string;
109+
60110
try {
61-
const destinationPath = path.join(targetDirectory, `${fileName}.snippet`);
62-
await writeCommunityFile(communitySnippetFile, destinationPath);
111+
destinationName = `${fileName}.snippet`;
112+
const destinationPath = path.join(targetDirectory, destinationName);
113+
await writeCommunityFile(communitySnippetFile, destinationPath, "wx");
63114
} catch (error: any) {
64115
if (error.code === "EEXIST") {
65-
const destinationPath = path.join(
66-
targetDirectory,
67-
`${fileName}_CONFLICT.snippet`,
68-
);
69-
await writeCommunityFile(communitySnippetFile, destinationPath);
116+
destinationName = `${fileName}_CONFLICT.snippet`;
117+
const destinationPath = path.join(targetDirectory, destinationName);
118+
await writeCommunityFile(communitySnippetFile, destinationPath, "w");
70119
} else {
71120
throw error;
72121
}
73122
}
123+
124+
if (hasSkippedSnippet) {
125+
result.migratedPartially[fileName] = destinationName;
126+
} else {
127+
result.migrated[fileName] = destinationName;
128+
}
74129
}
75130

76131
function parseVariables(
132+
spokenForms: SpokenForms,
133+
snippetName: string,
77134
variables?: Record<string, SnippetVariableLegacy>,
78135
): SnippetVariable[] {
79136
return Object.entries(variables ?? {}).map(
80137
([name, variable]): SnippetVariable => {
138+
const phrase = spokenForms.wrapper[`${snippetName}.${name}`];
81139
return {
82140
name,
141+
wrapperPhrases: phrase ? [phrase] : undefined,
83142
wrapperScope: variable.wrapperScopeType,
84143
insertionFormatters: variable.formatter
85144
? [variable.formatter]
@@ -90,6 +149,52 @@ function parseVariables(
90149
);
91150
}
92151

152+
async function openResultDocument(
153+
result: Result,
154+
sourceDirectory: string,
155+
targetDirectory: string,
156+
) {
157+
const migratedKeys = Object.keys(result.migrated).sort();
158+
const migratedPartiallyKeys = Object.keys(result.migratedPartially).sort();
159+
const skipMessage =
160+
"(Snippets containing `scopeTypes` and/or `excludeDescendantScopeTypes` attributes are not supported by community snippets.)";
161+
162+
const content: string[] = [
163+
`# Snippets migrated from Cursorless`,
164+
"",
165+
`From: ${sourceDirectory}`,
166+
`To: ${targetDirectory}`,
167+
"",
168+
`## Migrated ${migratedKeys.length} snippet files:`,
169+
...migratedKeys.map((key) => `- ${key} -> ${result.migrated[key]}`),
170+
"",
171+
];
172+
173+
if (migratedPartiallyKeys.length > 0) {
174+
content.push(
175+
`## Migrated ${migratedPartiallyKeys.length} snippet files partially:`,
176+
...migratedPartiallyKeys.map(
177+
(key) => `- ${key} -> ${result.migratedPartially[key]}`,
178+
),
179+
skipMessage,
180+
);
181+
}
182+
183+
if (result.skipped.length > 0) {
184+
content.push(
185+
`## Skipped ${result.skipped.length} snippet files:`,
186+
...result.skipped.map((key) => `- ${key}`),
187+
skipMessage,
188+
);
189+
}
190+
191+
const textDocument = await vscode.workspace.openTextDocument({
192+
content: content.join("\n"),
193+
language: "markdown",
194+
});
195+
await vscode.window.showTextDocument(textDocument);
196+
}
197+
93198
async function readLegacyFile(filePath: string): Promise<SnippetMap> {
94199
const content = await fs.readFile(filePath, "utf8");
95200
if (content.length === 0) {
@@ -98,12 +203,25 @@ async function readLegacyFile(filePath: string): Promise<SnippetMap> {
98203
return JSON.parse(content);
99204
}
100205

101-
async function writeCommunityFile(snippetFile: SnippetFile, filePath: string) {
206+
async function writeCommunityFile(
207+
snippetFile: SnippetFile,
208+
filePath: string,
209+
flags: string,
210+
) {
102211
const snippetText = serializeSnippetFile(snippetFile);
103-
const file = await fs.open(filePath, "wx");
212+
const file = await fs.open(filePath, flags);
104213
try {
105214
await file.write(snippetText);
106215
} finally {
107216
await file.close();
108217
}
109218
}
219+
220+
function swapKeyValue(
221+
obj: Record<string, string>,
222+
map?: (value: string) => string,
223+
): Record<string, string> {
224+
return Object.fromEntries(
225+
Object.entries(obj).map(([key, value]) => [map?.(value) ?? value, key]),
226+
);
227+
}

packages/cursorless-vscode/src/registerCommands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export function registerCommands(
8989
["cursorless.showDocumentation"]: showDocumentation,
9090
["cursorless.showInstallationDependencies"]: installationDependencies.show,
9191

92-
["cursorless.migrateSnippets"]: (dir) => migrateSnippets(snippets, dir),
92+
["cursorless.migrateSnippets"]: migrateSnippets.bind(null, snippets),
9393

9494
["cursorless.private.logQuickActions"]: logQuickActions,
9595

0 commit comments

Comments
 (0)