diff --git a/data/fixtures/cursorless-snippets/link.cursorless-snippets b/data/fixtures/cursorless-snippets/link.cursorless-snippets new file mode 100644 index 0000000000..01a4eca0d7 --- /dev/null +++ b/data/fixtures/cursorless-snippets/link.cursorless-snippets @@ -0,0 +1,30 @@ +{ + "link": { + "definitions": [ + { + "scope": { + "langIds": [ + "markdown" + ] + }, + "body": [ + "[$text](${url:$CLIPBOARD})" + ] + }, + { + "scope": { + "langIds": [ + "typescript", + "javascript", + "typescriptreact", + "javascriptreact" + ] + }, + "body": [ + "{@link $text}" + ] + } + ], + "description": "Link" + } +} diff --git a/data/fixtures/cursorless-snippets/multipleSnippets.cursorless-snippets b/data/fixtures/cursorless-snippets/multipleSnippets.cursorless-snippets new file mode 100644 index 0000000000..54765bff11 --- /dev/null +++ b/data/fixtures/cursorless-snippets/multipleSnippets.cursorless-snippets @@ -0,0 +1,149 @@ +{ + "mobxConstructor": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "javascript", + "typescriptreact", + "javascriptreact" + ] + }, + "body": [ + "constructor($parameters) {", + "\tmakeAutoObservable(this);", + "}" + ] + } + ], + "description": "Constructor using makeAutoObservable", + "insertionScopeTypes": [ + "namedFunction" + ] + }, + "constantDeclaration": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "javascript", + "typescriptreact", + "javascriptreact" + ] + }, + "body": [ + "const $name = ${value/^([^;]*);?$/$1/};" + ], + "variables": { + "name": { + "formatter": "camelCase" + } + } + } + ], + "description": "Constant variable declaration", + "insertionScopeTypes": [ + "statement" + ], + "variables": { + "value": { + "wrapperScopeType": "statement" + } + } + }, + "constantDeclarationWithType": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "javascript", + "typescriptreact", + "javascriptreact" + ] + }, + "body": [ + "const $name: $type = ${value/^([^;]*);?$/$1/};" + ], + "variables": { + "name": { + "formatter": "camelCase" + } + } + } + ], + "description": "Constant variable declaration with type", + "insertionScopeTypes": [ + "statement" + ], + "variables": { + "value": { + "wrapperScopeType": "statement" + } + } + }, + "letDeclaration": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "javascript", + "typescriptreact", + "javascriptreact" + ] + }, + "body": [ + "let $name = ${value/^([^;]*);?$/$1/};" + ], + "variables": { + "name": { + "formatter": "camelCase" + } + } + } + ], + "description": "Let variable declaration", + "insertionScopeTypes": [ + "statement" + ], + "variables": { + "value": { + "wrapperScopeType": "statement" + } + } + }, + "letDeclarationWithType": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "javascript", + "typescriptreact", + "javascriptreact" + ] + }, + "body": [ + "let $name: $type = ${value/^([^;]*);?$/$1/};" + ], + "variables": { + "name": { + "formatter": "camelCase" + } + } + } + ], + "description": "Let variable declaration with type", + "insertionScopeTypes": [ + "statement" + ], + "variables": { + "value": { + "wrapperScopeType": "statement" + } + } + } +} diff --git a/packages/cursorless-vscode/src/migrateSnippets.ts b/packages/cursorless-vscode/src/migrateSnippets.ts index b4e7f31c90..dc6fe24da0 100644 --- a/packages/cursorless-vscode/src/migrateSnippets.ts +++ b/packages/cursorless-vscode/src/migrateSnippets.ts @@ -21,7 +21,7 @@ interface Result { skipped: string[]; } -interface SpokenForms { +export interface SpokenForms { insertion: Record; insertionWithPhrase: Record; wrapper: Record; @@ -37,10 +37,7 @@ export async function migrateSnippets( const spokenFormsInverted: SpokenForms = { insertion: swapKeyValue(spokenForms.insertion), - insertionWithPhrase: swapKeyValue( - spokenForms.insertionWithPhrase, - (name) => name.split(".")[0], - ), + insertionWithPhrase: swapKeyValue(spokenForms.insertionWithPhrase), wrapper: swapKeyValue(spokenForms.wrapper), }; @@ -64,23 +61,71 @@ async function migrateFile( filePath: string, ) { const fileName = path.basename(filePath, CURSORLESS_SNIPPETS_SUFFIX); - const snippetFile = await readLegacyFile(filePath); + const legacySnippetFile = await readLegacyFile(filePath); + + const [communitySnippetFile, hasSkippedSnippet] = migrateLegacySnippet( + spokenForms, + legacySnippetFile, + ); + + if (communitySnippetFile.snippets.length === 0) { + result.skipped.push(fileName); + return; + } + + const destinationName = await saveSnippetFile( + communitySnippetFile, + targetDirectory, + fileName, + ); + + if (hasSkippedSnippet) { + result.migratedPartially[fileName] = destinationName; + } else { + result.migrated[fileName] = destinationName; + } +} + +export function migrateLegacySnippet( + spokenForms: SpokenForms, + legacySnippetFile: SnippetMap, +): [SnippetFile, boolean] { const communitySnippetFile: SnippetFile = { snippets: [] }; + const snippetNames = Object.keys(legacySnippetFile); + const useHeader = snippetNames.length === 1; let hasSkippedSnippet = false; - for (const snippetName in snippetFile) { - const snippet = snippetFile[snippetName]; - const phrase = - spokenForms.insertion[snippetName] ?? - spokenForms.insertionWithPhrase[snippetName]; + for (const snippetName of snippetNames) { + const snippet = legacySnippetFile[snippetName]; + let phrase = spokenForms.insertion[snippetName]; - communitySnippetFile.header = { - name: snippetName, - description: snippet.description, - phrases: phrase ? [phrase] : undefined, - variables: parseVariables(spokenForms, snippetName, snippet.variables), - insertionScopes: snippet.insertionScopeTypes, - }; + if (!phrase) { + const key = Object.keys(spokenForms.insertionWithPhrase).find((key) => + key.startsWith(`${snippetName}.`), + ); + if (key) { + phrase = spokenForms.insertionWithPhrase[key]; + } + } + + const phrases = phrase ? [phrase] : undefined; + + if (useHeader) { + communitySnippetFile.header = { + name: snippetName, + description: snippet.description, + phrases: phrases, + variables: parseVariables({ + spokenForms, + snippetName, + snippetVariables: snippet.variables, + defVariables: undefined, + addMissingPhrases: true, + addMissingInsertionFormatters: false, + }), + insertionScopes: snippet.insertionScopeTypes, + }; + } for (const def of snippet.definitions) { if ( @@ -91,62 +136,99 @@ async function migrateFile( continue; } communitySnippetFile.snippets.push({ - body: def.body.map((line) => line.replaceAll("\t", " ")), + name: useHeader ? undefined : snippetName, + description: useHeader ? undefined : snippet.description, + phrases: useHeader ? undefined : phrases, + insertionScopes: useHeader ? undefined : snippet.insertionScopeTypes, languages: def.scope?.langIds, - variables: parseVariables(spokenForms, snippetName, def.variables), + variables: parseVariables({ + spokenForms, + snippetName, + snippetVariables: useHeader ? undefined : snippet.variables, + defVariables: def.variables, + addMissingPhrases: !useHeader, + addMissingInsertionFormatters: true, + }), // SKIP: def.scope?.scopeTypes // SKIP: def.scope?.excludeDescendantScopeTypes + body: def.body.map((line) => line.replaceAll("\t", " ")), }); } } - if (communitySnippetFile.snippets.length === 0) { - result.skipped.push(fileName); - return; - } - - let destinationName: string; - - try { - destinationName = `${fileName}.snippet`; - const destinationPath = path.join(targetDirectory, destinationName); - await writeCommunityFile(communitySnippetFile, destinationPath, "wx"); - } catch (error: any) { - if (error.code === "EEXIST") { - destinationName = `${fileName}_CONFLICT.snippet`; - const destinationPath = path.join(targetDirectory, destinationName); - await writeCommunityFile(communitySnippetFile, destinationPath, "w"); - } else { - throw error; - } - } + return [communitySnippetFile, hasSkippedSnippet]; +} - if (hasSkippedSnippet) { - result.migratedPartially[fileName] = destinationName; - } else { - result.migrated[fileName] = destinationName; - } +interface ParseVariablesOpts { + spokenForms: SpokenForms; + snippetName: string; + snippetVariables: Record | undefined; + defVariables: Record | undefined; + addMissingPhrases: boolean; + addMissingInsertionFormatters: boolean; } -function parseVariables( - spokenForms: SpokenForms, - snippetName: string, - variables?: Record, -): SnippetVariable[] { - return Object.entries(variables ?? {}).map( - ([name, variable]): SnippetVariable => { +function parseVariables({ + spokenForms, + snippetName, + snippetVariables, + defVariables, + addMissingPhrases, + addMissingInsertionFormatters, +}: ParseVariablesOpts): SnippetVariable[] { + const map: Record = {}; + + const add = (name: string, variable: SnippetVariableLegacy) => { + if (!map[name]) { const phrase = spokenForms.wrapper[`${snippetName}.${name}`]; - return { + map[name] = { name, wrapperPhrases: phrase ? [phrase] : undefined, - wrapperScope: variable.wrapperScopeType, - insertionFormatters: variable.formatter - ? getFormatter(variable.formatter) - : undefined, - // SKIP: variable.description }; - }, + } + if (variable.wrapperScopeType) { + map[name].wrapperScope = variable.wrapperScopeType; + } + if (variable.formatter) { + map[name].insertionFormatters = getFormatter(variable.formatter); + } + // SKIP: variable.description + }; + + Object.entries(snippetVariables ?? {}).forEach(([name, variable]) => + add(name, variable), ); + Object.entries(defVariables ?? {}).forEach(([name, variable]) => + add(name, variable), + ); + + if (addMissingPhrases) { + for (const key in spokenForms.wrapper) { + const [snipName, variableName] = key.split("."); + if (snipName === snippetName && !map[variableName]) { + map[variableName] = { + name: variableName, + wrapperPhrases: [spokenForms.wrapper[key]], + }; + } + } + } + + if (addMissingInsertionFormatters) { + for (const key in spokenForms.insertionWithPhrase) { + const [snipName, variableName] = key.split("."); + if (snipName === snippetName) { + if (!map[variableName]) { + map[variableName] = { name: variableName }; + } + if (!map[variableName].insertionFormatters) { + map[variableName].insertionFormatters = ["NOOP"]; + } + } + } + } + + return Object.values(map); } // Convert Cursorless formatters to Talon community formatters @@ -165,6 +247,30 @@ function getFormatter(formatter: string): string[] { } } +async function saveSnippetFile( + communitySnippetFile: SnippetFile, + targetDirectory: string, + fileName: string, +) { + let destinationName: string; + + try { + destinationName = `${fileName}.snippet`; + const destinationPath = path.join(targetDirectory, destinationName); + await writeCommunityFile(communitySnippetFile, destinationPath, "wx"); + } catch (error: any) { + if (error.code === "EEXIST") { + destinationName = `${fileName}_CONFLICT.snippet`; + const destinationPath = path.join(targetDirectory, destinationName); + await writeCommunityFile(communitySnippetFile, destinationPath, "w"); + } else { + throw error; + } + } + + return destinationName; +} + async function openResultDocument( result: Result, sourceDirectory: string, @@ -238,11 +344,8 @@ async function writeCommunityFile( } } -function swapKeyValue( - obj: Record, - map?: (value: string) => string, -): Record { +function swapKeyValue(obj: Record): Record { return Object.fromEntries( - Object.entries(obj).map(([key, value]) => [map?.(value) ?? value, key]), + Object.entries(obj).map(([key, value]) => [value, key]), ); } diff --git a/packages/cursorless-vscode/src/migrateSnippets.vscode.test.ts b/packages/cursorless-vscode/src/migrateSnippets.vscode.test.ts new file mode 100644 index 0000000000..ea32077bbb --- /dev/null +++ b/packages/cursorless-vscode/src/migrateSnippets.vscode.test.ts @@ -0,0 +1,238 @@ +import type { SnippetMap } from "@cursorless/common"; +import assert from "node:assert"; +import { serializeSnippetFile } from "talon-snippets"; +import { migrateLegacySnippet, type SpokenForms } from "./migrateSnippets"; + +interface Fixture { + name: string; + input: SnippetMap; + output: string; +} + +const spokenForms: SpokenForms = { + insertion: { + mySnippet: "snip", + myPythonSnippet: "snip py", + }, + insertionWithPhrase: { + "myPhraseSnippet.foo": "phrase", + }, + wrapper: { + "myWrapperSnippet.foo": "foo", + }, +}; + +const fixtures: Fixture[] = [ + { + name: "Empty map", + input: {}, + output: "", + }, + + { + name: "Empty definitions", + input: { + mySnippet: { + definitions: [], + }, + }, + output: `\ +name: mySnippet +phrase: snip +--- +`, + }, + + { + name: "Basic", + input: { + mySnippet: { + description: "Example description", + definitions: [ + { + scope: { langIds: ["plaintext"] }, + body: ["Hello, $0, world!"], + }, + ], + }, + }, + output: `\ +name: mySnippet +description: Example description +phrase: snip +--- + +language: plaintext +- +Hello, $0, world! +--- +`, + }, + + { + name: "Insertion phrase", + input: { + myPhraseSnippet: { + description: "Example description", + definitions: [ + { + scope: { langIds: ["plaintext"] }, + body: ["Hello, $foo, world!"], + variables: { + foo: { formatter: "snakeCase" }, + }, + }, + ], + }, + }, + output: `\ +name: myPhraseSnippet +description: Example description +phrase: phrase +--- + +language: plaintext + +$foo.insertionFormatter: SNAKE_CASE +- +Hello, $foo, world! +--- +`, + }, + + { + name: "Insertion phrase noop", + input: { + myPhraseSnippet: { + description: "Example description", + definitions: [ + { + scope: { langIds: ["plaintext"] }, + body: ["Hello, $foo, world!"], + }, + ], + }, + }, + output: `\ +name: myPhraseSnippet +description: Example description +phrase: phrase +--- + +language: plaintext + +$foo.insertionFormatter: NOOP +- +Hello, $foo, world! +--- +`, + }, + + { + name: "Wrapper phrase", + input: { + myWrapperSnippet: { + definitions: [ + { + scope: { langIds: ["plaintext"] }, + body: ["Hello, $foo, world!"], + }, + ], + }, + }, + output: `\ +name: myWrapperSnippet + +$foo.wrapperPhrase: foo +--- + +language: plaintext +- +Hello, $foo, world! +--- +`, + }, + + { + name: "Multiple definitions", + input: { + mySnippet: { + definitions: [ + { + scope: { langIds: ["plaintext"] }, + body: ["Hello, $0 plain world!"], + }, + { + scope: { langIds: ["python"] }, + body: ["Hello, $0 python world!"], + }, + ], + }, + }, + output: `\ +name: mySnippet +phrase: snip +--- + +language: plaintext +- +Hello, $0 plain world! +--- + +language: python +- +Hello, $0 python world! +--- +`, + }, + + { + name: "Multiple snippets", + input: { + mySnippet: { + definitions: [ + { + scope: { langIds: ["plaintext"] }, + body: ["Hello, $0 plain world!"], + }, + ], + }, + myPythonSnippet: { + definitions: [ + { + scope: { langIds: ["python"] }, + body: ["Hello, $0 python world!"], + }, + ], + }, + }, + output: `\ +name: mySnippet +language: plaintext +phrase: snip +- +Hello, $0 plain world! +--- + +name: myPythonSnippet +language: python +phrase: snip py +- +Hello, $0 python world! +--- +`, + }, +]; + +suite("Migrate snippets", async function () { + fixtures.forEach((fixture) => { + test(fixture.name, () => runTest(fixture.input, fixture.output)); + }); +}); + +function runTest(input: SnippetMap, expectedOutput: string) { + const [snippetFile] = migrateLegacySnippet(spokenForms, input); + const actualOutput = serializeSnippetFile(snippetFile); + + assert.equal(actualOutput, expectedOutput); +}