diff --git a/cursorless-talon/src/actions/actions.py b/cursorless-talon/src/actions/actions.py index c9e75cf750..01281df341 100644 --- a/cursorless-talon/src/actions/actions.py +++ b/cursorless-talon/src/actions/actions.py @@ -55,7 +55,6 @@ # Don't wait for these actions to finish, usually because they hang on some kind of user interaction no_wait_actions = [ - "generateSnippet", "rename", ] @@ -99,6 +98,8 @@ def cursorless_command(action_name: str, target: CursorlessExplicitTarget): # p ) elif action_name == "callAsFunction": actions.user.private_cursorless_call(target) + elif action_name == "generateSnippet": + actions.user.private_cursorless_generate_snippet_action(target) elif action_name in no_wait_actions: action = {"name": action_name, "target": target} actions.user.private_cursorless_command_no_wait(action) diff --git a/cursorless-talon/src/actions/generate_snippet.py b/cursorless-talon/src/actions/generate_snippet.py new file mode 100644 index 0000000000..ef5452d2ea --- /dev/null +++ b/cursorless-talon/src/actions/generate_snippet.py @@ -0,0 +1,71 @@ +import glob +from pathlib import Path + +from talon import Context, Module, actions, settings + +from ..targets.target_types import CursorlessExplicitTarget + +mod = Module() + +ctx = Context() +ctx.matches = r""" +tag: user.cursorless_use_community_snippets +""" + + +@mod.action_class +class Actions: + 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( + { + "name": "generateSnippet", + "target": target, + } + ) + + +@ctx.action_class("user") +class UserActions: + def private_cursorless_generate_snippet_action(target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues] + actions.user.private_cursorless_command_no_wait( + { + "name": "generateSnippet", + "target": target, + "directory": str(get_directory_path()), + } + ) + + +def get_directory_path() -> Path: + settings_dir = get_setting_dir() + if settings_dir is not None: + return settings_dir + return get_community_snippets_dir() + + +def get_setting_dir() -> Path | None: + try: + setting_dir = settings.get("user.snippets_dir") + if not setting_dir: + return None + + dir = Path(str(setting_dir)) + + if not dir.is_absolute(): + user_dir = Path(actions.path.talon_user()) + dir = user_dir / dir + + return dir.resolve() + except Exception: + return None + + +def get_community_snippets_dir() -> Path: + files = glob.iglob( + f"{actions.path.talon_user()}/**/snippets/snippets/*.snippet", + recursive=True, + ) + for file in files: + return Path(file).parent + raise ValueError("Could not find community snippets directory") diff --git a/data/fixtures/recorded/actions/snippets/snipMakeFunk2.yml b/data/fixtures/recorded/actions/snippets/snipMakeFunk2.yml new file mode 100644 index 0000000000..5706bff670 --- /dev/null +++ b/data/fixtures/recorded/actions/snippets/snipMakeFunk2.yml @@ -0,0 +1,55 @@ +languageId: typescript +command: + version: 7 + spokenForm: snippet make funk + action: + name: generateSnippet + directory: "" + snippetName: snippetTest1 + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: namedFunction} + usePrePhraseSnapshot: true +spokenFormError: generateSnippet.snippetName +initialState: + documentContents: |2- + function helloWorld() { + const whatever = "hello"; + + if (whatever == null) { + console.log("hello") + } + } + selections: + - anchor: {line: 0, character: 13} + active: {line: 0, character: 23} + - anchor: {line: 3, character: 8} + active: {line: 5, character: 9} + marks: {} +finalState: + documentContents: | + name: snippetTest1 + language: typescript + phrase: + + $1.wrapperPhrase: + $0.wrapperPhrase: + - + function $1() { + const whatever = "hello"; + + $0 + } + --- + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 4} + end: {line: 6, character: 5} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/snippets/snipMakeState2.yml b/data/fixtures/recorded/actions/snippets/snipMakeState2.yml new file mode 100644 index 0000000000..b75d95a968 --- /dev/null +++ b/data/fixtures/recorded/actions/snippets/snipMakeState2.yml @@ -0,0 +1,46 @@ +languageId: typescript +command: + version: 7 + spokenForm: snippet make state + action: + name: generateSnippet + directory: "" + snippetName: snippetTest1 + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: statement} + usePrePhraseSnapshot: true +spokenFormError: generateSnippet.snippetName +initialState: + documentContents: |- + if () { + console.log("hello") + } + selections: + - anchor: {line: 0, character: 4} + active: {line: 0, character: 4} + marks: {} +finalState: + documentContents: | + name: snippetTest1 + language: typescript + phrase: + + $0.wrapperPhrase: + - + if ($0) { + console.log("hello") + } + --- + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 2, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/snippets/testSnippetMakeLine2.yml b/data/fixtures/recorded/actions/snippets/testSnippetMakeLine2.yml new file mode 100644 index 0000000000..28bc982668 --- /dev/null +++ b/data/fixtures/recorded/actions/snippets/testSnippetMakeLine2.yml @@ -0,0 +1,41 @@ +languageId: plaintext +command: + version: 7 + spokenForm: test snippet make line + action: + name: generateSnippet + directory: "" + snippetName: testSnippet + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + usePrePhraseSnapshot: true +spokenFormError: generateSnippet.snippetName +initialState: + documentContents: \textbf{$foo} + selections: + - anchor: {line: 0, character: 9} + active: {line: 0, character: 12} + marks: {} +finalState: + documentContents: | + name: testSnippet + language: plaintext + phrase: + + $0.wrapperPhrase: + - + \textbf{\$$0} + --- + selections: + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 13} + isReversed: false + hasExplicitRange: true diff --git a/packages/common/src/types/command/ActionDescriptor.ts b/packages/common/src/types/command/ActionDescriptor.ts index c064f28339..8b067b37f2 100644 --- a/packages/common/src/types/command/ActionDescriptor.ts +++ b/packages/common/src/types/command/ActionDescriptor.ts @@ -135,6 +135,7 @@ export interface PasteActionDescriptor { export interface GenerateSnippetActionDescriptor { name: "generateSnippet"; + directory?: string; snippetName?: string; target: PartialTargetDescriptor; } diff --git a/packages/cursorless-engine/package.json b/packages/cursorless-engine/package.json index aff61cb103..fc6cf065a8 100644 --- a/packages/cursorless-engine/package.json +++ b/packages/cursorless-engine/package.json @@ -30,6 +30,7 @@ "lodash-es": "^4.17.21", "moo": "0.5.2", "nearley": "2.20.1", + "talon-snippets": "1.1.0", "uuid": "^10.0.0", "zod": "3.23.8" }, diff --git a/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippet.ts b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippet.ts index 791c368730..b7e0aacde2 100644 --- a/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippet.ts +++ b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippet.ts @@ -1,49 +1,9 @@ -import { FlashStyle, matchAll, Range } from "@cursorless/common"; import type { Snippets } from "../../core/Snippets"; -import { ide } from "../../singletons/ide.singleton"; import type { Target } from "../../typings/target.types"; -import { ensureSingleTarget, flashTargets } from "../../util/targetUtils"; import type { ActionReturnValue } from "../actions.types"; -import { constructSnippetBody } from "./constructSnippetBody"; -import { editText } from "./editText"; -import type { Offsets } from "./Offsets"; -import Substituter from "./Substituter"; +import GenerateSnippetCommunity from "./GenerateSnippetCommunity"; +import GenerateSnippetLegacy from "./GenerateSnippetLegacy"; -/** - * This action can be used to automatically create a snippet from a target. Any - * cursor selections inside the target will become placeholders in the final - * snippet. This action creates a new file, and inserts a snippet that the user - * can fill out to construct their desired snippet. - * - * Note that there are two snippets involved in this implementation: - * - * - The snippet that the user is trying to create. We refer to this snippet as - * the user snippet. - * - The snippet that we insert that the user can use to build their snippet. We - * refer to this as the meta snippet. - * - * We proceed as follows: - * - * 1. Ask user for snippet name if not provided as arg - * 2. Find all cursor selections inside target - these will become the user - * snippet variables - * 3. Extract text of target - * 4. Replace cursor selections in text with random ids that won't be affected - * by json serialization. After serialization we'll replace these id's by - * snippet placeholders. - * 4. Construct the user snippet body as a list of strings - * 5. Construct a javascript object that will be json-ified to become the meta - * snippet - * 6. Serialize the javascript object to json - * 7. Perform replacements on the random id's appearing in this json to get the - * text we desire. This modified json output is the meta snippet. - * 8. Open a new document in user custom snippets dir to hold the new snippet. - * 9. Insert the meta snippet so that the user can construct their snippet. - * - * Note that we avoid using JS interpolation strings here because the syntax is - * very similar to snippet placeholders, so we would end up with lots of - * confusing escaping. - */ export default class GenerateSnippet { constructor(private snippets: Snippets) { this.run = this.run.bind(this); @@ -51,213 +11,15 @@ export default class GenerateSnippet { async run( targets: Target[], + directory?: string, snippetName?: string, ): Promise { - const target = ensureSingleTarget(targets); - const editor = target.editor; - - // NB: We don't await the pending edit decoration so that if the user - // immediately starts saying the name of the snippet (eg command chain - // "snippet make funk camel my function"), we're more likely to - // win the race and have the input box ready for them - void flashTargets(ide(), targets, FlashStyle.referenced); - - if (snippetName == null) { - snippetName = await ide().showInputBox({ - prompt: "Name of snippet", - placeHolder: "helloWorld", - }); - } - - // User cancelled; don't do anything - if (snippetName == null) { - return {}; - } - - /** The next placeholder index to use for the meta snippet */ - let currentPlaceholderIndex = 1; - - const baseOffset = editor.document.offsetAt(target.contentRange.start); - - /** - * The variables that will appear in the user snippet. Note that - * `placeholderIndex` here is the placeholder index in the meta snippet not - * the user snippet. - */ - const variables: Variable[] = editor.selections - .filter((selection) => target.contentRange.contains(selection)) - .map((selection, index) => ({ - offsets: { - start: editor.document.offsetAt(selection.start) - baseOffset, - end: editor.document.offsetAt(selection.end) - baseOffset, - }, - defaultName: `variable${index + 1}`, - placeholderIndex: currentPlaceholderIndex++, - })); - - /** - * Constructs random ids that can be put into the text that won't be - * modified by json serialization. - */ - const substituter = new Substituter(); - - /** - * Text before the start of the snippet in the snippet start line. We need - * to pass this to {@link constructSnippetBody} so that it knows the - * baseline indentation of the snippet - */ - const linePrefix = editor.document.getText( - new Range( - target.contentRange.start.with(undefined, 0), - target.contentRange.start, - ), - ); - - const originalText = editor.document.getText(target.contentRange); - - /** - * The text of the snippet, with placeholders inserted for variables and - * special characters `$`, `\`, and `}` escaped twice to make it through - * both meta snippet and user snippet. - */ - const snippetBodyText = editText(originalText, [ - ...matchAll(originalText, /\$|\\/g, (match) => ({ - offsets: { - start: match.index!, - end: match.index! + match[0].length, - }, - text: match[0] === "\\" ? `\\${match[0]}` : `\\\\${match[0]}`, - })), - ...variables.map(({ offsets, defaultName, placeholderIndex }) => ({ - offsets, - // Note that the reason we use the substituter here is primarily so - // that the `\` below doesn't get escaped upon conversion to json. - text: substituter.addSubstitution( - [ - // This `\$` will end up being a `$` in the final document. It - // indicates the start of a variable in the user snippet. We need - // the `\` so that the meta-snippet doesn't see it as one of its - // placeholders. - "\\$", - - // The remaining text here is a placeholder in the meta-snippet - // that the user can use to name their snippet variable that will - // be in the user snippet. - "${", - placeholderIndex, - ":", - defaultName, - "}", - ].join(""), - ), - })), - ]); - - const snippetLines = constructSnippetBody(snippetBodyText, linePrefix); - - /** - * Constructs a key-value entry for use in the variable description section - * of the user snippet definition. It contains tabstops for use in the - * meta-snippet. - * @param variable The variable - * @returns A [key, value] pair for use in the meta-snippet - */ - const constructVariableDescriptionEntry = ({ - placeholderIndex, - }: Variable): [string, string] => { - // The key will have the same placeholder index as the other location - // where this variable appears. - const key = "$" + placeholderIndex; - - // The value will end up being an empty object with a tabstop in the - // middle so that the user can add information about the variable, such - // as wrapperScopeType. Ie the output will look like `{|}` (with the `|` - // representing a tabstop in the meta-snippet) - // - // NB: We use the substituter here, with `isQuoted=true` because in order - // to make this work for the meta-snippet, we want to end up with - // something like `{$3}`, which is not valid json. So we instead arrange - // to end up with json like `"hgidfsivhs"`, and then replace the whole - // string (including quotes) with `{$3}` after json-ification - const value = substituter.addSubstitution( - "{$" + currentPlaceholderIndex++ + "}", - true, - ); - - return [key, value]; - }; - - /** An object that will be json-ified to become the meta-snippet */ - const snippet = { - [snippetName]: { - definitions: [ - { - scope: { - langIds: [editor.document.languageId], - }, - body: snippetLines, - }, - ], - description: "$" + currentPlaceholderIndex++, - variables: - variables.length === 0 - ? undefined - : Object.fromEntries( - variables.map(constructVariableDescriptionEntry), - ), - }, - }; - - /** - * 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 snippetText = substituter.makeSubstitutions( - JSON.stringify(snippet, null, 2), - ); - - const editableEditor = ide().getEditableTextEditor(editor); - - if (ide().runMode === "test") { - // If we're testing, we just overwrite the current document - await editableEditor.setSelections([ - editor.document.range.toSelection(false), - ]); - } else { - // Otherwise, we create and open a new document for the snippet in the - // user snippets dir - await this.snippets.openNewSnippetFile(snippetName); + if (directory == null) { + const action = new GenerateSnippetLegacy(this.snippets); + return action.run(targets, snippetName); } - // Insert the meta-snippet - await editableEditor.insertSnippet(snippetText); - - return { - thatSelections: targets.map(({ editor, contentSelection }) => ({ - editor, - selection: contentSelection, - })), - }; + const action = new GenerateSnippetCommunity(this.snippets); + return action.run(targets, directory, snippetName); } } - -interface Variable { - /** - * The start an end offsets of the variable relative to the text of the - * snippet that contains it - */ - offsets: Offsets; - - /** - * The default name for the given variable that will appear as the placeholder - * text in the meta snippet - */ - defaultName: string; - - /** - * The placeholder to use when filling out the name of this variable in the - * meta snippet. - */ - placeholderIndex: number; -} diff --git a/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetCommunity.ts b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetCommunity.ts new file mode 100644 index 0000000000..8f77d8a4d5 --- /dev/null +++ b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetCommunity.ts @@ -0,0 +1,241 @@ +import { + FlashStyle, + matchAll, + Range, + type EditableTextEditor, + type Selection, + type TextEditor, +} from "@cursorless/common"; +import { + getHeaderSnippet, + parseSnippetFile, + serializeSnippetFile, + type SnippetDocument, + type SnippetVariable, +} from "talon-snippets"; +import type { Snippets } from "../../core/Snippets"; +import { ide } from "../../singletons/ide.singleton"; +import type { Target } from "../../typings/target.types"; +import { ensureSingleTarget, flashTargets } from "../../util/targetUtils"; +import type { ActionReturnValue } from "../actions.types"; +import { constructSnippetBody } from "./constructSnippetBody"; +import { editText } from "./editText"; +import type { Offsets } from "./Offsets"; + +/** + * This action can be used to automatically create a snippet from a target. Any + * cursor selections inside the target will become placeholders in the final + * snippet. This action creates a new file, and inserts a snippet that the user + * can fill out to construct their desired snippet. + * + * Note that there are two snippets involved in this implementation: + * + * - The snippet that the user is trying to create. We refer to this snippet as + * the user snippet. + * - The snippet that we insert that the user can use to build their snippet. We + * refer to this as the meta snippet. + * + * We proceed as follows: + * + * 1. Ask user for snippet name if not provided as arg + * 2. Find all cursor selections inside target - these will become the user + * snippet variables + * 3. Extract text of target + * 4. Replace cursor selections in text with snippet variables + * 4. Construct the user snippet body as a list of strings + * 5. Construct a javascript object that will be serialized to become the meta + * snippet + * 6. Serialize the javascript object + * 7. Escape dollar signs and replace placeholder text with snippet placeholders. + * This modified json output is the meta snippet. + * 8. Open a new document in the snippets dir to hold the new snippet. + * 9. Insert the meta snippet so that the user can construct their snippet. + */ +export default class GenerateSnippetCommunity { + constructor(private snippets: Snippets) { + this.run = this.run.bind(this); + } + + async run( + targets: Target[], + directory: string, + snippetName?: string, + ): Promise { + const target = ensureSingleTarget(targets); + const editor = target.editor; + + // NB: We don't await the pending edit decoration so that if the user + // immediately starts saying the name of the snippet (eg command chain + // "snippet make funk camel my function"), we're more likely to + // win the race and have the input box ready for them + void flashTargets(ide(), targets, FlashStyle.referenced); + + if (snippetName == null) { + snippetName = await ide().showInputBox({ + prompt: "Name of snippet", + placeHolder: "helloWorld", + }); + + // User cancelled; do nothing + if (!snippetName) { + return {}; + } + } + + const baseOffset = editor.document.offsetAt(target.contentRange.start); + + /** + * The variables that will appear in the user snippet. + */ + const selections = getsSnippetSelections(editor, target.contentRange); + const variables = selections.map( + (selection, index): Variable => ({ + offsets: { + start: editor.document.offsetAt(selection.start) - baseOffset, + end: editor.document.offsetAt(selection.end) - baseOffset, + }, + name: index === selections.length - 1 ? "0" : `${index + 1}`, + }), + ); + + /** + * Text before the start of the snippet in the snippet start line. We need + * to pass this to {@link constructSnippetBody} so that it knows the + * baseline indentation of the snippet + */ + const linePrefix = editor.document.getText( + new Range( + target.contentRange.start.with(undefined, 0), + target.contentRange.start, + ), + ); + + const originalText = editor.document.getText(target.contentRange); + + const snippetBodyText = editText(originalText, [ + ...matchAll(originalText, /\$|\\/g, (match) => ({ + offsets: { + start: match.index!, + end: match.index! + match[0].length, + }, + text: `\\${match[0]}`, + })), + ...variables.map(({ offsets, name }) => ({ + offsets, + text: `$${name}`, + })), + ]); + + const snippetLines = constructSnippetBody(snippetBodyText, linePrefix); + + let editableEditor: EditableTextEditor; + let snippetDocuments: SnippetDocument[]; + + 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()); + } + + 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 phrases = + headerSnippet?.phrases != null + ? undefined + : [`${PLACEHOLDER}${currentPlaceholderIndex++}`]; + + const createVariable = (variable: Variable): SnippetVariable => { + const hasPhrase = headerSnippet?.variables?.some( + (v) => v.name === variable.name && v.wrapperPhrases != null, + ); + return { + name: variable.name, + wrapperPhrases: hasPhrase + ? undefined + : [`${PLACEHOLDER}${currentPlaceholderIndex++}`], + }; + }; + + const snippet: SnippetDocument = { + name: headerSnippet?.name === snippetName ? undefined : snippetName, + phrases, + languages: getSnippetLanguages(editor, headerSnippet), + body: snippetLines, + variables: variables.map(createVariable), + }; + + snippetDocuments.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) + // Escape dollar signs in the snippet text so that they don't get used as + // placeholders in the meta snippet + .replace(/\$/g, "\\$") + // Replace constant with dollar sign for meta snippet placeholders + .replaceAll(PLACEHOLDER, "$"); + + // Insert the meta-snippet + await editableEditor.insertSnippet(metaSnippetText); + + return { + thatSelections: targets.map(({ editor, contentSelection }) => ({ + editor, + selection: contentSelection, + })), + }; + } +} + +function getSnippetLanguages( + editor: TextEditor, + header: SnippetDocument | undefined, +): string[] | undefined { + if (header?.languages?.includes(editor.document.languageId)) { + return undefined; + } + return [editor.document.languageId]; +} + +function getsSnippetSelections(editor: TextEditor, range: Range): Selection[] { + const selections = editor.selections.filter((selection) => + range.contains(selection), + ); + selections.sort((a, b) => a.start.compareTo(b.start)); + return selections; +} + +// Used to temporarily escape the $1, $2 snippet holes (the "meta snippet" holes +// that become live snippets when the user edits) so we can use traditional +// backslash escaping for the holes in the underlying snippet itself (the "user +// snippet" holes that will be saved as part of their new template). +const PLACEHOLDER = "PLACEHOLDER_VFA77zcbLD6wXNmfMAay"; + +interface Variable { + /** + * The start an end offsets of the variable relative to the text of the + * snippet that contains it + */ + offsets: Offsets; + + /** + * The name for the variable + */ + name: string; +} diff --git a/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetLegacy.ts b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetLegacy.ts new file mode 100644 index 0000000000..76410fbba2 --- /dev/null +++ b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippetLegacy.ts @@ -0,0 +1,263 @@ +import { FlashStyle, matchAll, Range } from "@cursorless/common"; +import type { Snippets } from "../../core/Snippets"; +import { ide } from "../../singletons/ide.singleton"; +import type { Target } from "../../typings/target.types"; +import { ensureSingleTarget, flashTargets } from "../../util/targetUtils"; +import type { ActionReturnValue } from "../actions.types"; +import { constructSnippetBody } from "./constructSnippetBody"; +import { editText } from "./editText"; +import type { Offsets } from "./Offsets"; +import Substituter from "./Substituter"; + +/** + * This action can be used to automatically create a snippet from a target. Any + * cursor selections inside the target will become placeholders in the final + * snippet. This action creates a new file, and inserts a snippet that the user + * can fill out to construct their desired snippet. + * + * Note that there are two snippets involved in this implementation: + * + * - The snippet that the user is trying to create. We refer to this snippet as + * the user snippet. + * - The snippet that we insert that the user can use to build their snippet. We + * refer to this as the meta snippet. + * + * We proceed as follows: + * + * 1. Ask user for snippet name if not provided as arg + * 2. Find all cursor selections inside target - these will become the user + * snippet variables + * 3. Extract text of target + * 4. Replace cursor selections in text with random ids that won't be affected + * by json serialization. After serialization we'll replace these id's by + * snippet placeholders. + * 4. Construct the user snippet body as a list of strings + * 5. Construct a javascript object that will be json-ified to become the meta + * snippet + * 6. Serialize the javascript object to json + * 7. Perform replacements on the random id's appearing in this json to get the + * text we desire. This modified json output is the meta snippet. + * 8. Open a new document in user custom snippets dir to hold the new snippet. + * 9. Insert the meta snippet so that the user can construct their snippet. + * + * Note that we avoid using JS interpolation strings here because the syntax is + * very similar to snippet placeholders, so we would end up with lots of + * confusing escaping. + */ +export default class GenerateSnippetLegacy { + constructor(private snippets: Snippets) { + this.run = this.run.bind(this); + } + + async run( + targets: Target[], + snippetName?: string, + ): Promise { + const target = ensureSingleTarget(targets); + const editor = target.editor; + + // NB: We don't await the pending edit decoration so that if the user + // immediately starts saying the name of the snippet (eg command chain + // "snippet make funk camel my function"), we're more likely to + // win the race and have the input box ready for them + void flashTargets(ide(), targets, FlashStyle.referenced); + + if (snippetName == null) { + snippetName = await ide().showInputBox({ + prompt: "Name of snippet", + placeHolder: "helloWorld", + }); + } + + // User cancelled; don't do anything + if (snippetName == null) { + return {}; + } + + /** The next placeholder index to use for the meta snippet */ + let currentPlaceholderIndex = 1; + + const baseOffset = editor.document.offsetAt(target.contentRange.start); + + /** + * The variables that will appear in the user snippet. Note that + * `placeholderIndex` here is the placeholder index in the meta snippet not + * the user snippet. + */ + const variables: Variable[] = editor.selections + .filter((selection) => target.contentRange.contains(selection)) + .map((selection, index) => ({ + offsets: { + start: editor.document.offsetAt(selection.start) - baseOffset, + end: editor.document.offsetAt(selection.end) - baseOffset, + }, + defaultName: `variable${index + 1}`, + placeholderIndex: currentPlaceholderIndex++, + })); + + /** + * Constructs random ids that can be put into the text that won't be + * modified by json serialization. + */ + const substituter = new Substituter(); + + /** + * Text before the start of the snippet in the snippet start line. We need + * to pass this to {@link constructSnippetBody} so that it knows the + * baseline indentation of the snippet + */ + const linePrefix = editor.document.getText( + new Range( + target.contentRange.start.with(undefined, 0), + target.contentRange.start, + ), + ); + + const originalText = editor.document.getText(target.contentRange); + + /** + * The text of the snippet, with placeholders inserted for variables and + * special characters `$`, `\`, and `}` escaped twice to make it through + * both meta snippet and user snippet. + */ + const snippetBodyText = editText(originalText, [ + ...matchAll(originalText, /\$|\\/g, (match) => ({ + offsets: { + start: match.index!, + end: match.index! + match[0].length, + }, + text: match[0] === "\\" ? `\\${match[0]}` : `\\\\${match[0]}`, + })), + ...variables.map(({ offsets, defaultName, placeholderIndex }) => ({ + offsets, + // Note that the reason we use the substituter here is primarily so + // that the `\` below doesn't get escaped upon conversion to json. + text: substituter.addSubstitution( + [ + // This `\$` will end up being a `$` in the final document. It + // indicates the start of a variable in the user snippet. We need + // the `\` so that the meta-snippet doesn't see it as one of its + // placeholders. + "\\$", + + // The remaining text here is a placeholder in the meta-snippet + // that the user can use to name their snippet variable that will + // be in the user snippet. + "${", + placeholderIndex, + ":", + defaultName, + "}", + ].join(""), + ), + })), + ]); + + const snippetLines = constructSnippetBody(snippetBodyText, linePrefix); + + /** + * Constructs a key-value entry for use in the variable description section + * of the user snippet definition. It contains tabstops for use in the + * meta-snippet. + * @param variable The variable + * @returns A [key, value] pair for use in the meta-snippet + */ + const constructVariableDescriptionEntry = ({ + placeholderIndex, + }: Variable): [string, string] => { + // The key will have the same placeholder index as the other location + // where this variable appears. + const key = "$" + placeholderIndex; + + // The value will end up being an empty object with a tabstop in the + // middle so that the user can add information about the variable, such + // as wrapperScopeType. Ie the output will look like `{|}` (with the `|` + // representing a tabstop in the meta-snippet) + // + // NB: We use the substituter here, with `isQuoted=true` because in order + // to make this work for the meta-snippet, we want to end up with + // something like `{$3}`, which is not valid json. So we instead arrange + // to end up with json like `"hgidfsivhs"`, and then replace the whole + // string (including quotes) with `{$3}` after json-ification + const value = substituter.addSubstitution( + "{$" + currentPlaceholderIndex++ + "}", + true, + ); + + return [key, value]; + }; + + /** An object that will be json-ified to become the meta-snippet */ + const snippet = { + [snippetName]: { + definitions: [ + { + scope: { + langIds: [editor.document.languageId], + }, + body: snippetLines, + }, + ], + description: "$" + currentPlaceholderIndex++, + variables: + variables.length === 0 + ? undefined + : Object.fromEntries( + variables.map(constructVariableDescriptionEntry), + ), + }, + }; + + /** + * 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 snippetText = substituter.makeSubstitutions( + JSON.stringify(snippet, null, 2), + ); + + const editableEditor = ide().getEditableTextEditor(editor); + + if (ide().runMode === "test") { + // If we're testing, we just overwrite the current document + await editableEditor.setSelections([ + editor.document.range.toSelection(false), + ]); + } else { + // Otherwise, we create and open a new document for the snippet in the + // user snippets dir + await this.snippets.openNewSnippetFile(snippetName); + } + + // Insert the meta-snippet + await editableEditor.insertSnippet(snippetText); + + return { + thatSelections: targets.map(({ editor, contentSelection }) => ({ + editor, + selection: contentSelection, + })), + }; + } +} + +interface Variable { + /** + * The start an end offsets of the variable relative to the text of the + * snippet that contains it + */ + offsets: Offsets; + + /** + * The default name for the given variable that will appear as the placeholder + * text in the meta snippet + */ + defaultName: string; + + /** + * The placeholder to use when filling out the name of this variable in the + * meta snippet. + */ + placeholderIndex: number; +} diff --git a/packages/cursorless-engine/src/actions/actions.types.ts b/packages/cursorless-engine/src/actions/actions.types.ts index 8ded7fe1e3..a0eeef4810 100644 --- a/packages/cursorless-engine/src/actions/actions.types.ts +++ b/packages/cursorless-engine/src/actions/actions.types.ts @@ -124,7 +124,11 @@ export interface ActionRecord extends Record { }; generateSnippet: { - run(targets: Target[], snippetName?: string): Promise; + run( + targets: Target[], + directory?: string, + snippetName?: string, + ): Promise; }; insertSnippet: { diff --git a/packages/cursorless-engine/src/core/Snippets.ts b/packages/cursorless-engine/src/core/Snippets.ts index 5b041dc93d..190cb1e9ab 100644 --- a/packages/cursorless-engine/src/core/Snippets.ts +++ b/packages/cursorless-engine/src/core/Snippets.ts @@ -1,4 +1,4 @@ -import type { Snippet, SnippetMap } from "@cursorless/common"; +import type { Snippet, SnippetMap, TextEditor } from "@cursorless/common"; /** * Handles all cursorless snippets, including core, third-party and @@ -29,8 +29,13 @@ export interface Snippets { getSnippetStrict(snippetName: string): Snippet; /** - * Opens a new snippet file in the users snippet directory. + * Opens a new snippet file * @param snippetName The name of the snippet + * @param directory The path to the directory where the snippet should be created + * @returns The text editor of the newly created snippet file */ - openNewSnippetFile(snippetName: string): Promise; + openNewSnippetFile( + snippetName: string, + directory?: string, + ): Promise; } diff --git a/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts b/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts index 77bfe82bc3..ede4ea49c4 100644 --- a/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts +++ b/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts @@ -169,6 +169,7 @@ export class CommandRunnerImpl implements CommandRunner { case "generateSnippet": return this.actions.generateSnippet.run( this.getTargets(actionDescriptor.target), + actionDescriptor.directory, actionDescriptor.snippetName, ); diff --git a/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts b/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts index f9377c7251..af33393be1 100644 --- a/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts +++ b/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts @@ -1,4 +1,4 @@ -import type { SnippetMap, Snippet } from "@cursorless/common"; +import type { Snippet, SnippetMap, TextEditor } from "@cursorless/common"; import type { Snippets } from "../core/Snippets"; export class DisabledSnippets implements Snippets { @@ -17,7 +17,10 @@ export class DisabledSnippets implements Snippets { throw new Error("Snippets are not implemented."); } - openNewSnippetFile(_snippetName: string): Promise { + openNewSnippetFile( + _snippetName: string, + _directory?: string, + ): Promise { throw new Error("Snippets are not implemented."); } } diff --git a/packages/cursorless-neovim-e2e/src/shouldRunTest.ts b/packages/cursorless-neovim-e2e/src/shouldRunTest.ts index 5f24849b29..b2d4262772 100644 --- a/packages/cursorless-neovim-e2e/src/shouldRunTest.ts +++ b/packages/cursorless-neovim-e2e/src/shouldRunTest.ts @@ -50,6 +50,8 @@ function isFailingFixture(name: string, fixture: TestCaseFixtureLegacy) { return true; case "wrapWithSnippet": return true; + case "generateSnippet": + return true; // "recorded/actions/insertEmptyLines/floatThis*" -> wrong fixture.finalState.selections and fixture.thatMark.contentRange case "breakLine": return true; diff --git a/packages/cursorless-vscode/src/VscodeSnippets.ts b/packages/cursorless-vscode/src/VscodeSnippets.ts index 37bbae3ad9..0a24f446ad 100644 --- a/packages/cursorless-vscode/src/VscodeSnippets.ts +++ b/packages/cursorless-vscode/src/VscodeSnippets.ts @@ -1,4 +1,4 @@ -import type { Snippet, SnippetMap } from "@cursorless/common"; +import type { Snippet, SnippetMap, TextEditor } from "@cursorless/common"; import { mergeStrict, showError, type IDE } from "@cursorless/common"; import { mergeSnippets, type Snippets } from "@cursorless/cursorless-engine"; import { walkFiles } from "@cursorless/node-common"; @@ -235,18 +235,28 @@ export class VscodeSnippets implements Snippets { return snippet; } - async openNewSnippetFile(snippetName: string) { - const userSnippetsDir = this.ide.configuration.getOwnConfiguration( - "experimental.snippetsDir", - ); + async openNewSnippetFile( + snippetName: string, + directory?: string, + ): Promise { + const path = (() => { + if (directory != null) { + return join(directory, `${snippetName}.snippet`); + } - if (!userSnippetsDir) { - throw new Error("User snippets dir not configured."); - } + const userSnippetsDir = this.ide.configuration.getOwnConfiguration( + "experimental.snippetsDir", + ); + + if (!userSnippetsDir) { + throw new Error("User snippets dir not configured."); + } + + return join(userSnippetsDir, `${snippetName}.cursorless-snippets`); + })(); - const path = join(userSnippetsDir, `${snippetName}.cursorless-snippets`); await touch(path); - await this.ide.openTextDocument(path); + return this.ide.openTextDocument(path); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b76027bcff..f879ab8f41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,9 @@ importers: nearley: specifier: 2.20.1 version: 2.20.1(patch_hash=mg2fc7wgvzub3myuz6m74hllma) + talon-snippets: + specifier: 1.1.0 + version: 1.1.0 uuid: specifier: ^10.0.0 version: 10.0.0 @@ -9242,6 +9245,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + talon-snippets@1.1.0: + resolution: {integrity: sha512-NOkb/8KOlezJXP2TVzYF4AJBdrew1c1636EqEUxEyese8Qpb1yQyRkZtY16YzAoTpwcEg4KYxX6vl8SaRlHyDA==} + tapable@1.1.3: resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==} engines: {node: '>=6'} @@ -20843,6 +20849,8 @@ snapshots: transitivePeerDependencies: - ts-node + talon-snippets@1.1.0: {} + tapable@1.1.3: {} tapable@2.2.1: {}