Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added voice command to migrate Cursorless snippet to community format #2747

Merged
merged 35 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3754e88
Added the ability to generate community snippets
AndreasArvidsson Jan 21, 2025
325d9a6
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Jan 21, 2025
6d48577
Update comment
AndreasArvidsson Jan 21, 2025
2362952
neovim skip generate snippet test
AndreasArvidsson Jan 21, 2025
e858d69
Started working on migrating snippets
AndreasArvidsson Jan 22, 2025
92b187e
Added dependency
AndreasArvidsson Jan 22, 2025
d92e383
Ordered imports
AndreasArvidsson Jan 22, 2025
5dcc518
Merge branch 'generateSnippets2' into migrateSnippets
AndreasArvidsson Jan 22, 2025
bfabb54
Save files
AndreasArvidsson Jan 22, 2025
2ee5a90
Added notification
AndreasArvidsson Jan 22, 2025
7646f6e
Use path return
AndreasArvidsson Jan 23, 2025
b585a44
Update packages/cursorless-engine/src/actions/GenerateSnippet/Generat…
phillco Jan 23, 2025
2ac4d44
Reflow comment
AndreasArvidsson Jan 23, 2025
d51cea3
Update name to directory
AndreasArvidsson Jan 23, 2025
216309d
we name
AndreasArvidsson Jan 23, 2025
67f6133
Up dit
AndreasArvidsson Jan 23, 2025
c980df5
fix
AndreasArvidsson Jan 23, 2025
a813db4
Merge branch 'generateSnippets2' into migrateSnippets
AndreasArvidsson Jan 23, 2025
cd073e0
Merge branch 'main' into migrateSnippets
AndreasArvidsson Jan 23, 2025
4e185e2
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Jan 23, 2025
821038f
neovim dummy command
AndreasArvidsson Jan 23, 2025
820e3e4
Merge branch 'migrateSnippets' of github.com:cursorless-dev/cursorles…
AndreasArvidsson Jan 23, 2025
456c3e8
fix meta
AndreasArvidsson Jan 23, 2025
efeec1b
Update docs
AndreasArvidsson Jan 23, 2025
3f4be9c
Removed todos
AndreasArvidsson Jan 25, 2025
9515360
New version of snippet lib
AndreasArvidsson Jan 26, 2025
5321c28
New snippet lib
AndreasArvidsson Jan 26, 2025
40bc300
Use constant
AndreasArvidsson Jan 28, 2025
1a85c84
Refactor
AndreasArvidsson Jan 28, 2025
555028c
Update packages/cursorless-vscode/src/migrateSnippets.ts
phillco Jan 28, 2025
3995ca4
Update packages/cursorless-org-docs/src/docs/user/experimental/snippe…
phillco Jan 28, 2025
45aa9dc
indent
AndreasArvidsson Jan 28, 2025
c6ef011
Merge branch 'migrateSnippets' of github.com:cursorless-dev/cursorles…
AndreasArvidsson Jan 28, 2025
050cbc2
Merge branch 'main' into migrateSnippets
AndreasArvidsson Jan 28, 2025
c45a55b
Update packages/cursorless-vscode/src/migrateSnippets.ts
phillco Jan 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cursorless-talon/src/actions/generate_snippet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions cursorless-talon/src/cursorless.talon
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ tutorial resume: user.private_cursorless_tutorial_resume()
tutorial (list | close): user.private_cursorless_tutorial_list()
tutorial <number_small>:
user.private_cursorless_tutorial_start_by_number(number_small)

{user.cursorless_homophone} migrate snippets:
user.private_cursorless_migrate_snippets()
4 changes: 4 additions & 0 deletions packages/common/src/cursorlessCommandIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
),
};
2 changes: 1 addition & 1 deletion packages/cursorless-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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, "\\$")
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/cursorless-neovim/src/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/cursorless-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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",
Expand Down
35 changes: 21 additions & 14 deletions packages/cursorless-vscode/src/VscodeSnippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/cursorless-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export async function activate(
vscodeTutorial,
installationDependencies,
storedTargets,
snippets,
);

void new ReleaseNotes(vscodeApi, context, normalizedIde.messages).maybeShow();
Expand Down
109 changes: 109 additions & 0 deletions packages/cursorless-vscode/src/migrateSnippets.ts
Original file line number Diff line number Diff line change
@@ -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<string, SnippetVariableLegacy>,
): 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<SnippetMap> {
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();
}
phillco marked this conversation as resolved.
Show resolved Hide resolved
}
5 changes: 5 additions & 0 deletions packages/cursorless-vscode/src/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,6 +41,7 @@ export function registerCommands(
tutorial: VscodeTutorial,
installationDependencies: InstallationDependencies,
storedTargets: StoredTargetMap,
snippets: VscodeSnippets,
): void {
const runCommandWrapper = async (run: () => Promise<unknown>) => {
try {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading