Skip to content

Commit c690d93

Browse files
AndreasArvidssonpre-commit-ci-lite[bot]phillco
authored
Added voice command to migrate Cursorless snippet to community format (#2747)
2 fields in the Cursorless snippets are not available in the community format 1. scopeTypes. To my knowledge the only use case is omitting the `function` keyword when in a javascript class 2. excludeDescendantScopeTypes: Used in conjunction with `scopeTypes` to include the function keyword when in a function inside a class. scopeTypes and excludeDescendantScopeTypes I'm not to sure we want to add to community. First of all they have a single use case and needing two separate fields just to describe this behavior fields a bit verbose. As well community doesn't have support for sending multiple snippets. I'm planning this as a follow up. Reference https://github.com/cursorless-dev/cursorless/blob/57acd6c0fcc32e5df122ce09bb26bde5f09495ca/packages/common/src/types/snippet.types.ts#L4-L24 Fixes #2355 ## Checklist - [ ] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [x] 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: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Phil Cohen <phillip@phillip.io>
1 parent fede4a2 commit c690d93

File tree

13 files changed

+185
-35
lines changed

13 files changed

+185
-35
lines changed

cursorless-talon/src/actions/generate_snippet.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515

1616
@mod.action_class
1717
class Actions:
18+
def private_cursorless_migrate_snippets():
19+
"""Migrate snippets from Cursorless to community format"""
20+
actions.user.private_cursorless_run_rpc_command_no_wait(
21+
"cursorless.migrateSnippets",
22+
str(get_directory_path()),
23+
)
24+
1825
def private_cursorless_generate_snippet_action(target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues]
1926
"""Generate a snippet from the given target"""
2027
actions.user.private_cursorless_command_no_wait(

cursorless-talon/src/cursorless.talon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,6 @@ tutorial resume: user.private_cursorless_tutorial_resume()
4747
tutorial (list | close): user.private_cursorless_tutorial_list()
4848
tutorial <number_small>:
4949
user.private_cursorless_tutorial_start_by_number(number_small)
50+
51+
{user.cursorless_homophone} migrate snippets:
52+
user.private_cursorless_migrate_snippets()

packages/common/src/cursorlessCommandIds.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const cursorlessCommandIds = [
3838
"cursorless.keyboard.targeted.targetHat",
3939
"cursorless.keyboard.targeted.targetScope",
4040
"cursorless.keyboard.targeted.targetSelection",
41+
"cursorless.migrateSnippets",
4142
"cursorless.pauseRecording",
4243
"cursorless.recomputeDecorationStyles",
4344
"cursorless.recordTestCase",
@@ -164,4 +165,7 @@ export const cursorlessCommandDescriptions: Record<
164165
["cursorless.keyboard.redoTarget"]: new HiddenCommand(
165166
"Redo keyboard targeting changes",
166167
),
168+
["cursorless.migrateSnippets"]: new HiddenCommand(
169+
"Migrate snippets from the old Cursorless format to the new community format",
170+
),
167171
};

packages/cursorless-engine/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"lodash-es": "^4.17.21",
3131
"moo": "0.5.2",
3232
"nearley": "2.20.1",
33-
"talon-snippets": "1.1.0",
33+
"talon-snippets": "1.3.0",
3434
"uuid": "^10.0.0",
3535
"zod": "3.23.8"
3636
},

packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetCommunity.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import {
77
type TextEditor,
88
} from "@cursorless/common";
99
import {
10-
getHeaderSnippet,
1110
parseSnippetFile,
1211
serializeSnippetFile,
13-
type SnippetDocument,
12+
type Snippet,
13+
type SnippetFile,
14+
type SnippetHeader,
1415
type SnippetVariable,
1516
} from "talon-snippets";
1617
import type { Snippets } from "../../core/Snippets";
@@ -129,36 +130,35 @@ export default class GenerateSnippetCommunity {
129130
const snippetLines = constructSnippetBody(snippetBodyText, linePrefix);
130131

131132
let editableEditor: EditableTextEditor;
132-
let snippetDocuments: SnippetDocument[];
133+
let snippetFile: SnippetFile = { snippets: [] };
133134

134135
if (ide().runMode === "test") {
135136
// If we're testing, we just overwrite the current document
136137
editableEditor = ide().getEditableTextEditor(editor);
137-
snippetDocuments = [];
138138
} else {
139139
// Otherwise, we create and open a new document for the snippet
140140
editableEditor = ide().getEditableTextEditor(
141141
await this.snippets.openNewSnippetFile(snippetName, directory),
142142
);
143-
snippetDocuments = parseSnippetFile(editableEditor.document.getText());
143+
snippetFile = parseSnippetFile(editableEditor.document.getText());
144144
}
145145

146146
await editableEditor.setSelections([
147147
editableEditor.document.range.toSelection(false),
148148
]);
149149

150-
const headerSnippet = getHeaderSnippet(snippetDocuments);
151-
152150
/** The next placeholder index to use for the meta snippet */
153151
let currentPlaceholderIndex = 1;
154152

153+
const { header } = snippetFile;
154+
155155
const phrases =
156-
headerSnippet?.phrases != null
156+
snippetFile.header?.phrases != null
157157
? undefined
158158
: [`${PLACEHOLDER}${currentPlaceholderIndex++}`];
159159

160160
const createVariable = (variable: Variable): SnippetVariable => {
161-
const hasPhrase = headerSnippet?.variables?.some(
161+
const hasPhrase = header?.variables?.some(
162162
(v) => v.name === variable.name && v.wrapperPhrases != null,
163163
);
164164
return {
@@ -169,22 +169,22 @@ export default class GenerateSnippetCommunity {
169169
};
170170
};
171171

172-
const snippet: SnippetDocument = {
173-
name: headerSnippet?.name === snippetName ? undefined : snippetName,
172+
const snippet: Snippet = {
173+
name: header?.name === snippetName ? undefined : snippetName,
174174
phrases,
175-
languages: getSnippetLanguages(editor, headerSnippet),
175+
languages: getSnippetLanguages(editor, header),
176176
body: snippetLines,
177177
variables: variables.map(createVariable),
178178
};
179179

180-
snippetDocuments.push(snippet);
180+
snippetFile.snippets.push(snippet);
181181

182182
/**
183183
* This is the text of the meta-snippet in Textmate format that we will
184184
* insert into the new document where the user will fill out their snippet
185185
* definition
186186
*/
187-
const metaSnippetText = serializeSnippetFile(snippetDocuments)
187+
const metaSnippetText = serializeSnippetFile(snippetFile)
188188
// Escape dollar signs in the snippet text so that they don't get used as
189189
// placeholders in the meta snippet
190190
.replace(/\$/g, "\\$")
@@ -205,7 +205,7 @@ export default class GenerateSnippetCommunity {
205205

206206
function getSnippetLanguages(
207207
editor: TextEditor,
208-
header: SnippetDocument | undefined,
208+
header: SnippetHeader | undefined,
209209
): string[] | undefined {
210210
if (header?.languages?.includes(editor.document.languageId)) {
211211
return undefined;

packages/cursorless-neovim/src/registerCommands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export async function registerCommands(
8888
["cursorless.showQuickPick"]: dummyCommandHandler,
8989
["cursorless.showDocumentation"]: dummyCommandHandler,
9090
["cursorless.showInstallationDependencies"]: dummyCommandHandler,
91+
["cursorless.migrateSnippets"]: dummyCommandHandler,
9192
["cursorless.private.logQuickActions"]: dummyCommandHandler,
9293

9394
// Hats

packages/cursorless-org-docs/src/docs/user/experimental/snippets.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ Note that this line will also disable any Cursorless snippets defined in your Cu
1919

2020
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.
2121

22+
## Migrate Cursorless snippet to community
23+
24+
Say `"Cursorless migrate snippets"` to convert your existing experimental Cursorless snippet JSON files (which are now deprecated) to the new community snippet format.
25+
2226
## Using snippets
2327

2428
### Wrapping a target with snippets

packages/cursorless-vscode/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,11 @@
238238
"command": "cursorless.keyboard.redoTarget",
239239
"title": "Cursorless: Redo keyboard targeting changes",
240240
"enablement": "false"
241+
},
242+
{
243+
"command": "cursorless.migrateSnippets",
244+
"title": "Cursorless: Migrate snippets from the old Cursorless format to the new community format",
245+
"enablement": "false"
241246
}
242247
],
243248
"colors": [
@@ -1284,6 +1289,7 @@
12841289
"lodash-es": "^4.17.21",
12851290
"nearley": "2.20.1",
12861291
"semver": "^7.6.3",
1292+
"talon-snippets": "1.3.0",
12871293
"tinycolor2": "1.6.0",
12881294
"trie-search": "2.0.0",
12891295
"uuid": "^10.0.0",

packages/cursorless-vscode/src/VscodeSnippets.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { max } from "lodash-es";
66
import { open, readFile, stat } from "node:fs/promises";
77
import { join } from "node:path";
88

9-
const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets";
9+
export const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets";
1010
const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000;
1111

1212
interface DirectoryErrorMessage {
@@ -77,7 +77,7 @@ export class VscodeSnippets implements Snippets {
7777
async init() {
7878
const extensionPath = this.ide.assetsRoot;
7979
const snippetsDir = join(extensionPath, "cursorless-snippets");
80-
const snippetFiles = await getSnippetPaths(snippetsDir);
80+
const snippetFiles = await this.getSnippetPaths(snippetsDir);
8181
this.coreSnippets = mergeStrict(
8282
...(await Promise.all(
8383
snippetFiles.map(async (path) =>
@@ -115,7 +115,7 @@ export class VscodeSnippets implements Snippets {
115115
let snippetFiles: string[];
116116
try {
117117
snippetFiles = this.userSnippetsDir
118-
? await getSnippetPaths(this.userSnippetsDir)
118+
? await this.getSnippetPaths(this.userSnippetsDir)
119119
: [];
120120
} catch (err) {
121121
if (this.directoryErrorMessage?.directory !== this.userSnippetsDir) {
@@ -244,24 +244,31 @@ export class VscodeSnippets implements Snippets {
244244
return join(directory, `${snippetName}.snippet`);
245245
}
246246

247-
const userSnippetsDir = this.ide.configuration.getOwnConfiguration(
248-
"experimental.snippetsDir",
247+
return join(
248+
this.getUserDirectoryStrict(),
249+
`${snippetName}.cursorless-snippets`,
249250
);
250-
251-
if (!userSnippetsDir) {
252-
throw new Error("User snippets dir not configured.");
253-
}
254-
255-
return join(userSnippetsDir, `${snippetName}.cursorless-snippets`);
256251
})();
257252

258253
await touch(path);
259254
return this.ide.openTextDocument(path);
260255
}
261-
}
262256

263-
function getSnippetPaths(snippetsDir: string) {
264-
return walkFiles(snippetsDir, CURSORLESS_SNIPPETS_SUFFIX);
257+
getUserDirectoryStrict() {
258+
const userSnippetsDir = this.ide.configuration.getOwnConfiguration(
259+
"experimental.snippetsDir",
260+
);
261+
262+
if (!userSnippetsDir) {
263+
throw new Error("User snippets dir not configured.");
264+
}
265+
266+
return userSnippetsDir;
267+
}
268+
269+
getSnippetPaths(snippetsDir: string) {
270+
return walkFiles(snippetsDir, CURSORLESS_SNIPPETS_SUFFIX);
271+
}
265272
}
266273

267274
async function touch(path: string) {

packages/cursorless-vscode/src/extension.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ export async function activate(
194194
vscodeTutorial,
195195
installationDependencies,
196196
storedTargets,
197+
snippets,
197198
);
198199

199200
void new ReleaseNotes(vscodeApi, context, normalizedIde.messages).maybeShow();
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type {
2+
SnippetMap,
3+
SnippetVariable as SnippetVariableLegacy,
4+
} from "@cursorless/common";
5+
import * as fs from "node:fs/promises";
6+
import * as path from "node:path";
7+
import {
8+
serializeSnippetFile,
9+
type SnippetFile,
10+
type SnippetVariable,
11+
} from "talon-snippets";
12+
import * as vscode from "vscode";
13+
import {
14+
CURSORLESS_SNIPPETS_SUFFIX,
15+
type VscodeSnippets,
16+
} from "./VscodeSnippets";
17+
18+
export async function migrateSnippets(
19+
snippets: VscodeSnippets,
20+
targetDirectory: string,
21+
) {
22+
const userSnippetsDir = snippets.getUserDirectoryStrict();
23+
const files = await snippets.getSnippetPaths(userSnippetsDir);
24+
25+
for (const file of files) {
26+
await migrateFile(targetDirectory, file);
27+
}
28+
29+
await vscode.window.showInformationMessage(
30+
`${files.length} snippet files migrated successfully!`,
31+
);
32+
}
33+
34+
async function migrateFile(targetDirectory: string, filePath: string) {
35+
const fileName = path.basename(filePath, CURSORLESS_SNIPPETS_SUFFIX);
36+
const snippetFile = await readLegacyFile(filePath);
37+
const communitySnippetFile: SnippetFile = { snippets: [] };
38+
39+
for (const snippetName in snippetFile) {
40+
const snippet = snippetFile[snippetName];
41+
42+
communitySnippetFile.header = {
43+
name: snippetName,
44+
description: snippet.description,
45+
variables: parseVariables(snippet.variables),
46+
insertionScopes: snippet.insertionScopeTypes,
47+
};
48+
49+
for (const def of snippet.definitions) {
50+
communitySnippetFile.snippets.push({
51+
body: def.body.map((line) => line.replaceAll("\t", " ")),
52+
languages: def.scope?.langIds,
53+
variables: parseVariables(def.variables),
54+
// SKIP: def.scope?.scopeTypes
55+
// SKIP: def.scope?.excludeDescendantScopeTypes
56+
});
57+
}
58+
}
59+
60+
try {
61+
const destinationPath = path.join(targetDirectory, `${fileName}.snippet`);
62+
await writeCommunityFile(communitySnippetFile, destinationPath);
63+
} catch (error: any) {
64+
if (error.code === "EEXIST") {
65+
const destinationPath = path.join(
66+
targetDirectory,
67+
`${fileName}_CONFLICT.snippet`,
68+
);
69+
await writeCommunityFile(communitySnippetFile, destinationPath);
70+
} else {
71+
throw error;
72+
}
73+
}
74+
}
75+
76+
function parseVariables(
77+
variables?: Record<string, SnippetVariableLegacy>,
78+
): SnippetVariable[] {
79+
return Object.entries(variables ?? {}).map(
80+
([name, variable]): SnippetVariable => {
81+
return {
82+
name,
83+
wrapperScope: variable.wrapperScopeType,
84+
insertionFormatters: variable.formatter
85+
? [variable.formatter]
86+
: undefined,
87+
// SKIP: variable.description
88+
};
89+
},
90+
);
91+
}
92+
93+
async function readLegacyFile(filePath: string): Promise<SnippetMap> {
94+
const content = await fs.readFile(filePath, "utf8");
95+
if (content.length === 0) {
96+
return {};
97+
}
98+
return JSON.parse(content);
99+
}
100+
101+
async function writeCommunityFile(snippetFile: SnippetFile, filePath: string) {
102+
const snippetText = serializeSnippetFile(snippetFile);
103+
const file = await fs.open(filePath, "wx");
104+
try {
105+
await file.write(snippetText);
106+
} finally {
107+
await file.close();
108+
}
109+
}

packages/cursorless-vscode/src/registerCommands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ import type {
1919
import * as vscode from "vscode";
2020
import type { InstallationDependencies } from "./InstallationDependencies";
2121
import type { ScopeVisualizer } from "./ScopeVisualizerCommandApi";
22+
import type { VscodeSnippets } from "./VscodeSnippets";
2223
import type { VscodeTutorial } from "./VscodeTutorial";
2324
import { showDocumentation, showQuickPick } from "./commands";
2425
import type { VscodeIDE } from "./ide/vscode/VscodeIDE";
2526
import type { VscodeHats } from "./ide/vscode/hats/VscodeHats";
2627
import type { KeyboardCommands } from "./keyboard/KeyboardCommands";
2728
import { logQuickActions } from "./logQuickActions";
29+
import { migrateSnippets } from "./migrateSnippets";
2830

2931
export function registerCommands(
3032
extensionContext: vscode.ExtensionContext,
@@ -39,6 +41,7 @@ export function registerCommands(
3941
tutorial: VscodeTutorial,
4042
installationDependencies: InstallationDependencies,
4143
storedTargets: StoredTargetMap,
44+
snippets: VscodeSnippets,
4245
): void {
4346
const runCommandWrapper = async (run: () => Promise<unknown>) => {
4447
try {
@@ -86,6 +89,8 @@ export function registerCommands(
8689
["cursorless.showDocumentation"]: showDocumentation,
8790
["cursorless.showInstallationDependencies"]: installationDependencies.show,
8891

92+
["cursorless.migrateSnippets"]: (dir) => migrateSnippets(snippets, dir),
93+
8994
["cursorless.private.logQuickActions"]: logQuickActions,
9095

9196
// Hats

0 commit comments

Comments
 (0)