diff --git a/cursorless-talon-dev/src/cursorless_dev.py b/cursorless-talon-dev/src/cursorless_dev.py index 1b659977a9..ae7e8752e2 100644 --- a/cursorless-talon-dev/src/cursorless_dev.py +++ b/cursorless-talon-dev/src/cursorless_dev.py @@ -38,5 +38,10 @@ def cursorless_record_silent_test(): def private_cursorless_make_snippet_test(target: Any): """Test generating a snippet""" actions.user.private_cursorless_command_no_wait( - {"name": "generateSnippet", "snippetName": "testSnippet", "target": target} + { + "name": "generateSnippet", + "dirPath": "", + "snippetName": "testSnippet", + "target": target, + } ) diff --git a/cursorless-talon/src/actions/actions.py b/cursorless-talon/src/actions/actions.py index c9e75cf750..b82e6414cb 100644 --- a/cursorless-talon/src/actions/actions.py +++ b/cursorless-talon/src/actions/actions.py @@ -10,6 +10,7 @@ ) from .bring_move import BringMoveTargets from .execute_command import cursorless_execute_command_action +from .generate_snippet import cursorless_generate_snippet_action from .homophones import cursorless_homophones_action from .replace import cursorless_replace_action @@ -50,12 +51,12 @@ ] callback_actions: dict[str, Callable[[CursorlessExplicitTarget], None]] = { + "generateSnippet": cursorless_generate_snippet_action, "nextHomophone": cursorless_homophones_action, } # Don't wait for these actions to finish, usually because they hang on some kind of user interaction no_wait_actions = [ - "generateSnippet", "rename", ] diff --git a/cursorless-talon/src/actions/generate_snippet.py b/cursorless-talon/src/actions/generate_snippet.py new file mode 100644 index 0000000000..ef102e36d4 --- /dev/null +++ b/cursorless-talon/src/actions/generate_snippet.py @@ -0,0 +1,50 @@ +import glob +from pathlib import Path + +from talon import actions, settings + +from ..targets.target_types import CursorlessExplicitTarget + + +def cursorless_generate_snippet_action(target: CursorlessExplicitTarget): + actions.user.private_cursorless_command_no_wait( + { + "name": "generateSnippet", + "target": target, + "dirPath": get_dir_path(), + } + ) + + +def get_dir_path() -> str: + settings_dir = get_setting_dir() + if settings_dir is not None: + return settings_dir + return get_community_snippets_dir() + + +def get_community_snippets_dir() -> str: + files = glob.iglob( + f"{actions.path.talon_user()}/**/snippets/snippets/*.snippet", + recursive=True, + ) + for file in files: + return str(Path(file).parent) + raise ValueError("Could not find community snippets directory") + + +def get_setting_dir() -> str | None: + try: + setting_dir = settings.get("user.snippets_dir") + if not setting_dir: + return None + + dir = Path(setting_dir) + + if not dir.is_absolute(): + user_dir = Path(actions.path.talon_user()) + dir = user_dir / dir + + return str(dir.resolve()) + except Exception: + return None diff --git a/cursorless-talon/src/versions.py b/cursorless-talon/src/versions.py index 056299a93a..7b8e0e0908 100644 --- a/cursorless-talon/src/versions.py +++ b/cursorless-talon/src/versions.py @@ -1 +1 @@ -COMMAND_VERSION = 7 +COMMAND_VERSION = 8 diff --git a/data/fixtures/recorded/actions/snippets/snipMakeFunk.yml b/data/fixtures/recorded/actions/snippets/snipMakeFunk.yml index d56f3b0fc4..d378e87f8d 100644 --- a/data/fixtures/recorded/actions/snippets/snipMakeFunk.yml +++ b/data/fixtures/recorded/actions/snippets/snipMakeFunk.yml @@ -1,9 +1,10 @@ languageId: typescript command: - version: 6 + version: 8 spokenForm: snippet make funk action: name: generateSnippet + dirPath: "" snippetName: snippetTest1 target: type: primitive @@ -28,37 +29,23 @@ initialState: active: {line: 5, character: 9} marks: {} finalState: - documentContents: |- - { - "snippetTest1": { - "definitions": [ - { - "scope": { - "langIds": [ - "typescript" - ] - }, - "body": [ - "function $variable1() {", - "\tconst whatever = \"hello\";", - "", - "\t$variable2", - "}" - ] - } - ], - "description": "", - "variables": { - "variable1": {}, - "variable2": {} - } - } + documentContents: | + name: snippetTest1 + language: typescript + phrase: + + $1.wrapperPhrase: + $0.wrapperPhrase: + - + function $1() { + const whatever = "hello"; + + $0 } + --- selections: - - anchor: {line: 10, character: 21} - active: {line: 10, character: 30} - - anchor: {line: 20, character: 7} - active: {line: 20, character: 16} + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} thatMark: - type: UntypedTarget contentRange: diff --git a/data/fixtures/recorded/actions/snippets/snipMakeState.yml b/data/fixtures/recorded/actions/snippets/snipMakeState.yml index c0503658af..a553f959d0 100644 --- a/data/fixtures/recorded/actions/snippets/snipMakeState.yml +++ b/data/fixtures/recorded/actions/snippets/snipMakeState.yml @@ -1,9 +1,10 @@ languageId: typescript command: - version: 6 + version: 8 spokenForm: snippet make state action: name: generateSnippet + dirPath: "" snippetName: snippetTest1 target: type: primitive @@ -22,34 +23,20 @@ initialState: active: {line: 0, character: 4} marks: {} finalState: - documentContents: |- - { - "snippetTest1": { - "definitions": [ - { - "scope": { - "langIds": [ - "typescript" - ] - }, - "body": [ - "if ($variable1) {", - "\tconsole.log(\"hello\")", - "}" - ] - } - ], - "description": "", - "variables": { - "variable1": {} - } - } + documentContents: | + name: snippetTest1 + language: typescript + phrase: + + $0.wrapperPhrase: + - + if ($0) { + console.log("hello") } + --- selections: - - anchor: {line: 10, character: 16} - active: {line: 10, character: 25} - - anchor: {line: 18, character: 7} - active: {line: 18, character: 16} + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} thatMark: - type: UntypedTarget contentRange: diff --git a/data/fixtures/recorded/actions/snippets/testSnippetMakeLine.yml b/data/fixtures/recorded/actions/snippets/testSnippetMakeLine.yml index 828d06aa2b..e06f2a87cd 100644 --- a/data/fixtures/recorded/actions/snippets/testSnippetMakeLine.yml +++ b/data/fixtures/recorded/actions/snippets/testSnippetMakeLine.yml @@ -1,9 +1,10 @@ languageId: plaintext command: - version: 6 + version: 8 spokenForm: test snippet make line action: name: generateSnippet + dirPath: "" snippetName: testSnippet target: type: primitive @@ -19,32 +20,18 @@ initialState: active: {line: 0, character: 12} marks: {} finalState: - documentContents: |- - { - "testSnippet": { - "definitions": [ - { - "scope": { - "langIds": [ - "plaintext" - ] - }, - "body": [ - "\\textbf{\\$$variable1}" - ] - } - ], - "description": "", - "variables": { - "variable1": {} - } - } - } + documentContents: | + name: testSnippet + language: plaintext + phrase: + + $0.wrapperPhrase: + - + \textbf{\$$0} + --- selections: - - anchor: {line: 10, character: 24} - active: {line: 10, character: 33} - - anchor: {line: 16, character: 7} - active: {line: 16, character: 16} + - anchor: {line: 2, character: 8} + active: {line: 2, character: 8} thatMark: - type: UntypedTarget contentRange: diff --git a/data/fixtures/recorded/testCaseRecorder/takeHarp.yml b/data/fixtures/recorded/testCaseRecorder/takeHarp.yml index 429d8b32cf..21303b7170 100644 --- a/data/fixtures/recorded/testCaseRecorder/takeHarp.yml +++ b/data/fixtures/recorded/testCaseRecorder/takeHarp.yml @@ -1,6 +1,6 @@ languageId: plaintext command: - version: 7 + version: 8 spokenForm: take harp action: name: setSelection diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 3721c82a07..eab9d1c94a 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -14,19 +14,19 @@ export * from "./ide/types/Clipboard"; export * from "./ide/types/CommandHistoryStorage"; export * from "./ide/types/CommandId"; export * from "./ide/types/Configuration"; -export * from "./ide/types/events.types"; export * from "./ide/types/Events"; +export * from "./ide/types/events.types"; export * from "./ide/types/FileSystem.types"; export * from "./ide/types/FlashDescriptor"; export * from "./ide/types/Hats"; export * from "./ide/types/HatStability"; export * from "./ide/types/hatStyles.types"; export * from "./ide/types/ide.types"; +export * from "./ide/types/KeyValueStore"; export * from "./ide/types/Messages"; export * from "./ide/types/Paths"; export * from "./ide/types/QuickPickOptions"; export * from "./ide/types/RawTreeSitterQueryProvider"; -export * from "./ide/types/KeyValueStore"; export * from "./ide/types/TutorialContentProvider"; export * from "./ide/util/messages"; export * from "./scopeSupportFacets/languageScopeSupport"; @@ -46,8 +46,7 @@ export * from "./testUtil/TestCaseSnapshot"; export * from "./testUtil/testConstants"; export * from "./types/command/ActionDescriptor"; export * from "./types/command/command.types"; -export * from "./types/command/CommandV6.types"; -export * from "./types/command/CommandV7.types"; +export * from "./types/command/CommandV8.types"; export * from "./types/command/DestinationDescriptor.types"; export * from "./types/command/legacy/ActionCommandV5"; export * from "./types/command/legacy/CommandV0V1.types"; @@ -55,6 +54,8 @@ export * from "./types/command/legacy/CommandV2.types"; export * from "./types/command/legacy/CommandV3.types"; export * from "./types/command/legacy/CommandV4.types"; export * from "./types/command/legacy/CommandV5.types"; +export * from "./types/command/legacy/CommandV6.types"; +export * from "./types/command/legacy/CommandV7.types"; export * from "./types/command/legacy/PartialTargetDescriptorV3.types"; export * from "./types/command/legacy/PartialTargetDescriptorV4.types"; export * from "./types/command/legacy/PartialTargetDescriptorV5.types"; diff --git a/packages/common/src/testUtil/serializeTestFixture.ts b/packages/common/src/testUtil/serializeTestFixture.ts index 2ad2d2a2d0..9815107f2f 100644 --- a/packages/common/src/testUtil/serializeTestFixture.ts +++ b/packages/common/src/testUtil/serializeTestFixture.ts @@ -1,11 +1,12 @@ import type { TestCaseFixtureLegacy } from "../types/TestCaseFixture"; import type { ActionDescriptor } from "../types/command/ActionDescriptor"; -import type { CommandV6 } from "../types/command/CommandV6.types"; -import type { CommandV7 } from "../types/command/CommandV7.types"; +import type { CommandV6 } from "../types/command/legacy/CommandV6.types"; +import type { CommandV7 } from "../types/command/legacy/CommandV7.types"; import type { Command } from "../types/command/command.types"; import type { CommandV5 } from "../types/command/legacy/CommandV5.types"; import type { EnforceUndefined } from "../util/typeUtils"; import { serialize } from "./serialize"; +import type { ActionDescriptorV6 } from "../types/command/legacy/ActionDescriptorV6"; function reorderFields( fixture: TestCaseFixtureLegacy, @@ -41,6 +42,8 @@ function reorderCommandFields(command: Command): Command { return reorderCommandV6Fields(command); case 7: return reorderCommandV7Fields(command); + case 8: + return command; } } @@ -81,7 +84,7 @@ function reorderCommandV7Fields( }; } -function reorderActionFields(action: ActionDescriptor): ActionDescriptor { +function reorderActionFields(action: ActionDescriptorV6): ActionDescriptor { const { name, ...rest } = action; return { name, diff --git a/packages/common/src/types/SpokenFormType.ts b/packages/common/src/types/SpokenFormType.ts index 9032d40171..dd29b93efc 100644 --- a/packages/common/src/types/SpokenFormType.ts +++ b/packages/common/src/types/SpokenFormType.ts @@ -16,7 +16,6 @@ export interface SpokenFormMapKeyTypes { pairedDelimiter: SpeakableSurroundingPairName; simpleScopeTypeType: SimpleScopeTypeType; complexScopeTypeType: "glyph"; - surroundingPairForceDirection: "left" | "right"; /** * These modifier types are spoken by directly saying the spoken form for the diff --git a/packages/common/src/types/command/ActionDescriptor.ts b/packages/common/src/types/command/ActionDescriptor.ts index c064f28339..403796802b 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"; + dirPath: string; snippetName?: string; target: PartialTargetDescriptor; } diff --git a/packages/common/src/types/command/CommandV8.types.ts b/packages/common/src/types/command/CommandV8.types.ts new file mode 100644 index 0000000000..06c02ded15 --- /dev/null +++ b/packages/common/src/types/command/CommandV8.types.ts @@ -0,0 +1,27 @@ +import type { ActionDescriptor } from "./ActionDescriptor"; + +export interface CommandV8 { + /** + * The version number of the command API + */ + version: 8; + + /** + * The spoken form of the command if issued from a voice command system + */ + spokenForm?: string; + + /** + * If the command is issued from a voice command system, this boolean indicates + * whether we should use the pre phrase snapshot. Only set this to true if the + * voice command system issues a pre phrase signal at the start of every + * phrase. + */ + usePrePhraseSnapshot: boolean; + + /** + * The action to perform. This field contains everything necessary to actually + * perform the action. The other fields are just metadata. + */ + action: ActionDescriptor; +} diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index e09d94d230..db25023799 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -232,12 +232,6 @@ export interface SurroundingPairScopeType { type: "surroundingPair"; delimiter: SurroundingPairName; - /** - * @deprecated Not supported by next-gen surrounding pairs; we don't believe - * anyone uses this - */ - forceDirection?: SurroundingPairDirection; - /** * If `true`, then only accept pairs where the pair completely contains the * selection, ie without the edges touching. diff --git a/packages/common/src/types/command/command.types.ts b/packages/common/src/types/command/command.types.ts index baf934734f..fb1ae9c4cd 100644 --- a/packages/common/src/types/command/command.types.ts +++ b/packages/common/src/types/command/command.types.ts @@ -1,16 +1,17 @@ import type { ActionDescriptor } from "./ActionDescriptor"; -import type { CommandV6 } from "./CommandV6.types"; -import type { CommandV7 } from "./CommandV7.types"; +import type { CommandV8 } from "./CommandV8.types"; import type { Modifier } from "./PartialTargetDescriptor.types"; import type { CommandV0, CommandV1 } from "./legacy/CommandV0V1.types"; import type { CommandV2 } from "./legacy/CommandV2.types"; import type { CommandV3 } from "./legacy/CommandV3.types"; import type { CommandV4 } from "./legacy/CommandV4.types"; import type { CommandV5 } from "./legacy/CommandV5.types"; +import type { CommandV6 } from "./legacy/CommandV6.types"; +import type { CommandV7 } from "./legacy/CommandV7.types"; export type CommandComplete = Required> & Pick; -export const LATEST_VERSION = 7; +export const LATEST_VERSION = 8; export type CommandLatest = Command & { version: typeof LATEST_VERSION; @@ -26,7 +27,8 @@ export type Command = | CommandV4 | CommandV5 | CommandV6 - | CommandV7; + | CommandV7 + | CommandV8; export type CommandResponse = { returnValue: unknown } | { fallback: Fallback }; diff --git a/packages/common/src/types/command/legacy/ActionDescriptorV6.ts b/packages/common/src/types/command/legacy/ActionDescriptorV6.ts new file mode 100644 index 0000000000..784276d9a7 --- /dev/null +++ b/packages/common/src/types/command/legacy/ActionDescriptorV6.ts @@ -0,0 +1,222 @@ +import type { DestinationDescriptorV6 } from "./DestinationDescriptorV6.types"; +import type { + PartialTargetDescriptorV6, + ScopeTypeV6, +} from "./PartialTargetDescriptorV6.types"; + +/** + * A simple action takes only a single target and no other arguments. + */ +const _simpleActionNames = [ + "addSelection", + "addSelectionAfter", + "addSelectionBefore", + "breakLine", + "clearAndSetSelection", + "copyToClipboard", + "cutToClipboard", + "decrement", + "deselect", + "editNewLineAfter", + "editNewLineBefore", + "experimental.setInstanceReference", + "extractVariable", + "findInDocument", + "findInWorkspace", + "foldRegion", + "followLink", + "followLinkAside", + "increment", + "indentLine", + "insertCopyAfter", + "insertCopyBefore", + "insertEmptyLineAfter", + "insertEmptyLineBefore", + "insertEmptyLinesAround", + "joinLines", + "outdentLine", + "randomizeTargets", + "remove", + "rename", + "revealDefinition", + "revealTypeDefinition", + "reverseTargets", + "scrollToBottom", + "scrollToCenter", + "scrollToTop", + "setSelection", + "setSelectionAfter", + "setSelectionBefore", + "showDebugHover", + "showHover", + "showQuickFix", + "showReferences", + "sortTargets", + "toggleLineBreakpoint", + "toggleLineComment", + "unfoldRegion", + "private.getTargets", + "private.setKeyboardTarget", + "private.showParseTree", +] as const; + +type SimpleActionName = (typeof _simpleActionNames)[number]; + +/** + * A simple action takes only a single target and no other arguments. + */ +interface SimpleActionDescriptor { + name: SimpleActionName; + target: PartialTargetDescriptorV6; +} + +interface BringMoveActionDescriptor { + name: "replaceWithTarget" | "moveToTarget"; + source: PartialTargetDescriptorV6; + destination: DestinationDescriptorV6; +} + +interface CallActionDescriptor { + name: "callAsFunction"; + + /** + * The target to use as the function to be called. + */ + callee: PartialTargetDescriptorV6; + + /** + * The target to wrap in a function call. + */ + argument: PartialTargetDescriptorV6; +} + +interface SwapActionDescriptor { + name: "swapTargets"; + target1: PartialTargetDescriptorV6; + target2: PartialTargetDescriptorV6; +} + +interface WrapWithPairedDelimiterActionDescriptor { + name: "wrapWithPairedDelimiter" | "rewrapWithPairedDelimiter"; + left: string; + right: string; + target: PartialTargetDescriptorV6; +} + +interface PasteActionDescriptor { + name: "pasteFromClipboard"; + destination: DestinationDescriptorV6; +} + +interface GenerateSnippetActionDescriptor { + name: "generateSnippet"; + dirPath?: string; + snippetName?: string; + target: PartialTargetDescriptorV6; +} + +interface NamedInsertSnippetArg { + type: "named"; + name: string; + substitutions?: Record; +} +interface CustomInsertSnippetArg { + type: "custom"; + body: string; + scopeTypes?: ScopeTypeV6[]; + substitutions?: Record; +} +type InsertSnippetArg = NamedInsertSnippetArg | CustomInsertSnippetArg; + +interface InsertSnippetActionDescriptor { + name: "insertSnippet"; + snippetDescription: InsertSnippetArg; + destination: DestinationDescriptorV6; +} + +interface NamedWrapWithSnippetArg { + type: "named"; + name: string; + variableName: string; +} +interface CustomWrapWithSnippetArg { + type: "custom"; + body: string; + variableName?: string; + scopeType?: ScopeTypeV6; +} +type WrapWithSnippetArg = NamedWrapWithSnippetArg | CustomWrapWithSnippetArg; + +interface WrapWithSnippetActionDescriptor { + name: "wrapWithSnippet"; + snippetDescription: WrapWithSnippetArg; + target: PartialTargetDescriptorV6; +} + +interface ExecuteCommandOptions { + commandArgs?: any[]; + ensureSingleEditor?: boolean; + ensureSingleTarget?: boolean; + restoreSelection?: boolean; + showDecorations?: boolean; +} + +interface ExecuteCommandActionDescriptor { + name: "executeCommand"; + commandId: string; + options?: ExecuteCommandOptions; + target: PartialTargetDescriptorV6; +} + +type ReplaceWith = string[] | { start: number }; + +interface ReplaceActionDescriptor { + name: "replace"; + replaceWith: ReplaceWith; + destination: DestinationDescriptorV6; +} + +interface HighlightActionDescriptor { + name: "highlight"; + highlightId?: string; + target: PartialTargetDescriptorV6; +} + +interface EditNewActionDescriptor { + name: "editNew"; + destination: DestinationDescriptorV6; +} + +interface GetTextActionOptions { + showDecorations?: boolean; + ensureSingleTarget?: boolean; +} + +interface GetTextActionDescriptor { + name: "getText"; + options?: GetTextActionOptions; + target: PartialTargetDescriptorV6; +} + +interface ParsedActionDescriptor { + name: "parsed"; + content: string; + arguments: unknown[]; +} + +export type ActionDescriptorV6 = + | SimpleActionDescriptor + | BringMoveActionDescriptor + | SwapActionDescriptor + | CallActionDescriptor + | PasteActionDescriptor + | ExecuteCommandActionDescriptor + | ReplaceActionDescriptor + | HighlightActionDescriptor + | GenerateSnippetActionDescriptor + | InsertSnippetActionDescriptor + | WrapWithSnippetActionDescriptor + | WrapWithPairedDelimiterActionDescriptor + | EditNewActionDescriptor + | GetTextActionDescriptor + | ParsedActionDescriptor; diff --git a/packages/common/src/types/command/CommandV6.types.ts b/packages/common/src/types/command/legacy/CommandV6.types.ts similarity index 87% rename from packages/common/src/types/command/CommandV6.types.ts rename to packages/common/src/types/command/legacy/CommandV6.types.ts index 08b6fe1f30..752f9de871 100644 --- a/packages/common/src/types/command/CommandV6.types.ts +++ b/packages/common/src/types/command/legacy/CommandV6.types.ts @@ -1,4 +1,4 @@ -import type { ActionDescriptor } from "./ActionDescriptor"; +import type { ActionDescriptorV6 } from "./ActionDescriptorV6"; export interface CommandV6 { /** @@ -23,5 +23,5 @@ export interface CommandV6 { * The action to perform. This field contains everything necessary to actually * perform the action. The other fields are just metadata. */ - action: ActionDescriptor; + action: ActionDescriptorV6; } diff --git a/packages/common/src/types/command/CommandV7.types.ts b/packages/common/src/types/command/legacy/CommandV7.types.ts similarity index 100% rename from packages/common/src/types/command/CommandV7.types.ts rename to packages/common/src/types/command/legacy/CommandV7.types.ts diff --git a/packages/common/src/types/command/legacy/DestinationDescriptorV6.types.ts b/packages/common/src/types/command/legacy/DestinationDescriptorV6.types.ts new file mode 100644 index 0000000000..1e547208b0 --- /dev/null +++ b/packages/common/src/types/command/legacy/DestinationDescriptorV6.types.ts @@ -0,0 +1,55 @@ +import type { + PartialListTargetDescriptorV6, + PartialPrimitiveTargetDescriptorV6, + PartialRangeTargetDescriptorV6, +} from "./PartialTargetDescriptorV6.types"; + +/** + * The insertion mode to use when inserting relative to a target. + * - `before` inserts before the target. Depending on the target, a delimiter + * may be inserted after the inserted text. + * - `after` inserts after the target. Depending on the target, a delimiter may + * be inserted before the inserted text. + * - `to` replaces the target. However, this insertion mode may also be used + * when the target is really only a pseudo-target. For example, you could say + * `"bring type air to bat"` even if `bat` doesn't already have a type. In + * that case, `"take type bat"` wouldn't work, so `"type bat"` is really just + * a pseudo-target in that situation. + */ +type InsertionMode = "before" | "after" | "to"; + +interface PrimitiveDestinationDescriptor { + type: "primitive"; + + /** + * The insertion mode to use when inserting relative to {@link target}. + */ + insertionMode: InsertionMode; + + target: + | PartialPrimitiveTargetDescriptorV6 + | PartialRangeTargetDescriptorV6 + | PartialListTargetDescriptorV6; +} + +/** + * A list of destinations. This is used when the user uses more than one insertion mode + * in a single command. For example, `"bring air after bat and before cap"`. + */ +interface ListDestinationDescriptor { + type: "list"; + destinations: PrimitiveDestinationDescriptor[]; +} + +/** + * An implicit destination. This is used for e.g. `"bring air"` (note the user + * doesn't explicitly specify the destination), or `"snip funk"`. + */ +interface ImplicitDestinationDescriptor { + type: "implicit"; +} + +export type DestinationDescriptorV6 = + | ListDestinationDescriptor + | PrimitiveDestinationDescriptor + | ImplicitDestinationDescriptor; diff --git a/packages/common/src/types/command/legacy/PartialTargetDescriptorV6.types.ts b/packages/common/src/types/command/legacy/PartialTargetDescriptorV6.types.ts new file mode 100644 index 0000000000..be97a0f8a8 --- /dev/null +++ b/packages/common/src/types/command/legacy/PartialTargetDescriptorV6.types.ts @@ -0,0 +1,474 @@ +interface CursorMark { + type: "cursor"; +} + +interface ThatMark { + type: "that"; +} + +interface KeyboardMark { + type: "keyboard"; +} + +interface SourceMark { + type: "source"; +} + +interface NothingMark { + type: "nothing"; +} + +interface DecoratedSymbolMark { + type: "decoratedSymbol"; + symbolColor: string; + character: string; +} + +type LineNumberType = "absolute" | "relative" | "modulo100"; + +interface LineNumberMark { + type: "lineNumber"; + lineNumberType: LineNumberType; + lineNumber: number; +} + +/** + * Constructs a range between {@link anchor} and {@link active} + */ +interface RangeMarkFor { + type: "range"; + anchor: T; + active: T; + excludeAnchor: boolean; + excludeActive: boolean; +} + +type PartialRangeMark = RangeMarkFor; + +interface SimplePosition { + readonly line: number; + readonly character: number; +} + +interface SimpleRange { + readonly start: SimplePosition; + readonly end: SimplePosition; +} + +/** + * Used to explicitly provide a range for use as a mark. Today, this mark type + * is only used as a hack to enable us to support allowing other editors to + * maintain their own hat map when using the Cursorless "sidecar"; see + * https://github.com/cursorless-everywhere/notes for more information. + */ +interface ExplicitMark { + type: "explicit"; + editorId: string; + range: SimpleRange; +} + +/** + * Can be used when constructing a primitive target that applies modifiers to + * the output of some other complex target descriptor. For example, we use this + * to apply the hoisted modifiers to the output of a range target when we hoist + * the "every funk" modifier on a command like "take every funk air until bat". + */ +interface PartialTargetMark { + type: "target"; + + /** + * The target descriptor that will be used to generate the targets output by + * this mark. + */ + target: PartialTargetDescriptorV6; +} + +type PartialMark = + | CursorMark + | ThatMark + | SourceMark + | KeyboardMark + | DecoratedSymbolMark + | NothingMark + | LineNumberMark + | PartialRangeMark + | ExplicitMark + | PartialTargetMark; + +const _simpleSurroundingPairNames = [ + "angleBrackets", + "backtickQuotes", + "curlyBrackets", + "doubleQuotes", + "escapedDoubleQuotes", + "escapedParentheses", + "escapedSingleQuotes", + "escapedSquareBrackets", + "parentheses", + "singleQuotes", + "squareBrackets", + "tripleDoubleQuotes", + "tripleSingleQuotes", +] as const; +const _complexSurroundingPairNames = [ + "string", + "any", + "collectionBoundary", +] as const; + +type SimpleSurroundingPairName = (typeof _simpleSurroundingPairNames)[number]; +type ComplexSurroundingPairName = (typeof _complexSurroundingPairNames)[number]; +type SurroundingPairName = + | SimpleSurroundingPairName + | ComplexSurroundingPairName; + +const _simpleScopeTypeTypes = [ + "argumentOrParameter", + "anonymousFunction", + "attribute", + "branch", + "class", + "className", + "collectionItem", + "collectionKey", + "comment", + "private.fieldAccess", + "functionCall", + "functionCallee", + "functionName", + "ifStatement", + "instance", + "list", + "map", + "name", + "namedFunction", + "regularExpression", + "statement", + "string", + "type", + "value", + "condition", + "section", + "sectionLevelOne", + "sectionLevelTwo", + "sectionLevelThree", + "sectionLevelFour", + "sectionLevelFive", + "sectionLevelSix", + "selector", + "private.switchStatementSubject", + "unit", + "xmlBothTags", + "xmlElement", + "xmlEndTag", + "xmlStartTag", + // Latex scope types + "part", + "chapter", + "subSection", + "subSubSection", + "namedParagraph", + "subParagraph", + "environment", + // Text based scopes + "character", + "word", + "token", + "identifier", + "line", + "sentence", + "paragraph", + "boundedParagraph", + "document", + "nonWhitespaceSequence", + "boundedNonWhitespaceSequence", + "url", + "notebookCell", + // Talon + "command", + // Private scope types + "textFragment", + "disqualifyDelimiter", + "pairDelimiter", +] as const; + +type SimpleScopeTypeType = (typeof _simpleScopeTypeTypes)[number]; + +interface SimpleScopeType { + type: SimpleScopeTypeType; +} + +interface CustomRegexScopeType { + type: "customRegex"; + regex: string; + flags?: string; +} + +type SurroundingPairDirection = "left" | "right"; + +interface SurroundingPairScopeType { + type: "surroundingPair"; + delimiter: SurroundingPairName; + + /** + * @deprecated Not supported by next-gen surrounding pairs; we don't believe + * anyone uses this + */ + forceDirection?: SurroundingPairDirection; + + /** + * If `true`, then only accept pairs where the pair completely contains the + * selection, ie without the edges touching. + */ + requireStrongContainment?: boolean; +} + +/** + * This differs from the normal @SurroundingPairScopeType that it always + * uses `requireStrongContainment` and the content range is the pair interior + * */ +interface SurroundingPairInteriorScopeType { + type: "surroundingPairInterior"; + delimiter: SurroundingPairName; + // If true don't yield multiline pairs + requireSingleLine?: boolean; +} + +interface OneOfScopeType { + type: "oneOf"; + scopeTypes: ScopeTypeV6[]; +} + +interface GlyphScopeType { + type: "glyph"; + character: string; +} + +export type ScopeTypeV6 = + | SimpleScopeType + | SurroundingPairScopeType + | SurroundingPairInteriorScopeType + | CustomRegexScopeType + | OneOfScopeType + | GlyphScopeType; + +interface InteriorOnlyModifier { + type: "interiorOnly"; +} + +interface ExcludeInteriorModifier { + type: "excludeInterior"; +} + +interface VisibleModifier { + type: "visible"; +} + +interface ContainingScopeModifier { + type: "containingScope"; + scopeType: ScopeTypeV6; + ancestorIndex?: number; +} + +interface PreferredScopeModifier { + type: "preferredScope"; + scopeType: ScopeTypeV6; +} + +interface EveryScopeModifier { + type: "everyScope"; + scopeType: ScopeTypeV6; +} + +/** + * Refer to scopes by absolute index relative to iteration scope, eg "first + * funk" to refer to the first function in a class. + */ +interface OrdinalScopeModifier { + type: "ordinalScope"; + + scopeType: ScopeTypeV6; + + /** The start of the range. Start from end of iteration scope if `start` is negative */ + start: number; + + /** The number of scopes to include. Will always be positive. If greater than 1, will include scopes after {@link start} */ + length: number; + + /** If true, yields individual targets instead of contiguous range. Defaults to `false` */ + isEvery?: boolean; +} + +type Direction = "forward" | "backward"; + +/** + * Refer to scopes by offset relative to input target, eg "next + * funk" to refer to the first function after the function containing the target input. + */ +interface RelativeScopeModifier { + type: "relativeScope"; + + scopeType: ScopeTypeV6; + + /** Indicates how many scopes away to start relative to the input target. + * Note that if {@link direction} is `"backward"`, then this scope will be the + * end of the output range. */ + offset: number; + + /** The number of scopes to include. Will always be positive. If greater + * than 1, will include scopes in the direction of {@link direction} */ + length: number; + + /** Indicates which direction both {@link offset} and {@link length} go + * relative to input target */ + direction: Direction; + + /** If true use individual targets instead of combined range */ + isEvery?: boolean; +} + +/** + * Converts its input to a raw selection with no type information so for + * example if it is the destination of a bring or move it should inherit the + * type information such as delimiters from its source. + */ +interface RawSelectionModifier { + type: "toRawSelection"; +} + +interface LeadingModifier { + type: "leading"; +} + +interface TrailingModifier { + type: "trailing"; +} + +interface KeepContentFilterModifier { + type: "keepContentFilter"; +} + +interface KeepEmptyFilterModifier { + type: "keepEmptyFilter"; +} + +interface InferPreviousMarkModifier { + type: "inferPreviousMark"; +} + +interface StartOfModifier { + type: "startOf"; +} + +interface EndOfModifier { + type: "endOf"; +} + +interface HeadModifier { + type: "extendThroughStartOf"; + modifiers?: Modifier[]; +} + +interface TailModifier { + type: "extendThroughEndOf"; + modifiers?: Modifier[]; +} + +/** + * Runs {@link modifier} if the target has no explicit scope type, ie if + * {@link Target.hasExplicitScopeType} is `false`. + */ +interface ModifyIfUntypedModifier { + type: "modifyIfUntyped"; + + /** + * The modifier to apply if the target is untyped + */ + modifier: Modifier; +} + +/** + * Tries each of the modifiers in {@link modifiers} in turn until one of them + * doesn't throw an error, returning the output from the first modifier not + * throwing an error. + */ +interface CascadingModifier { + type: "cascading"; + + /** + * The modifiers to try in turn + */ + modifiers: Modifier[]; +} + +/** + * First applies {@link anchor} to input, then independently applies + * {@link active}, and forms a range between the two resulting targets + */ +interface RangeModifier { + type: "range"; + anchor: Modifier; + active: Modifier; + excludeAnchor: boolean; + excludeActive: boolean; +} + +type Modifier = + | StartOfModifier + | EndOfModifier + | InteriorOnlyModifier + | ExcludeInteriorModifier + | VisibleModifier + | ContainingScopeModifier + | PreferredScopeModifier + | EveryScopeModifier + | OrdinalScopeModifier + | RelativeScopeModifier + | HeadModifier + | TailModifier + | LeadingModifier + | TrailingModifier + | RawSelectionModifier + | ModifyIfUntypedModifier + | CascadingModifier + | RangeModifier + | KeepContentFilterModifier + | KeepEmptyFilterModifier + | InferPreviousMarkModifier; + +// continuous is one single continuous selection between the two targets +// vertical puts a selection on each line vertically between the two targets +type PartialRangeType = "continuous" | "vertical"; + +export interface PartialPrimitiveTargetDescriptorV6 { + type: "primitive"; + mark?: PartialMark; + modifiers?: Modifier[]; +} + +export interface PartialRangeTargetDescriptorV6 { + type: "range"; + anchor: PartialPrimitiveTargetDescriptorV6 | ImplicitTargetDescriptor; + active: PartialPrimitiveTargetDescriptorV6; + excludeAnchor: boolean; + excludeActive: boolean; + rangeType?: PartialRangeType; +} + +export interface PartialListTargetDescriptorV6 { + type: "list"; + elements: ( + | PartialPrimitiveTargetDescriptorV6 + | PartialRangeTargetDescriptorV6 + )[]; +} + +interface ImplicitTargetDescriptor { + type: "implicit"; +} + +export type PartialTargetDescriptorV6 = + | PartialPrimitiveTargetDescriptorV6 + | PartialRangeTargetDescriptorV6 + | PartialListTargetDescriptorV6 + | ImplicitTargetDescriptor; 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..b6a1ea42f6 100644 --- a/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippet.ts +++ b/packages/cursorless-engine/src/actions/GenerateSnippet/GenerateSnippet.ts @@ -1,4 +1,17 @@ -import { FlashStyle, matchAll, Range } from "@cursorless/common"; +import { + FlashStyle, + matchAll, + Range, + type EditableTextEditor, + type Selection, + type TextEditor, +} from "@cursorless/common"; +import { + 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"; @@ -7,7 +20,7 @@ import type { ActionReturnValue } from "../actions.types"; import { constructSnippetBody } from "./constructSnippetBody"; import { editText } from "./editText"; import type { Offsets } from "./Offsets"; -import Substituter from "./Substituter"; +import { getHeaderSnippet } from "talon-snippets"; /** * This action can be used to automatically create a snippet from a target. Any @@ -51,6 +64,7 @@ export default class GenerateSnippet { async run( targets: Target[], + dirPath: string, snippetName?: string, ): Promise { const target = ensureSingleTarget(targets); @@ -67,39 +81,28 @@ export default class GenerateSnippet { prompt: "Name of snippet", placeHolder: "helloWorld", }); - } - // User cancelled; don't do anything - if (snippetName == null) { - return {}; + // User cancelled; do nothing + if (!snippetName) { + 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. + * The variables that will appear in the user snippet. */ - const variables: Variable[] = editor.selections - .filter((selection) => target.contentRange.contains(selection)) - .map((selection, index) => ({ + 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, }, - 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(); + name: index === selections.length - 1 ? "0" : `${index + 1}`, + }), + ); /** * Text before the start of the snippet in the snippet start line. We need @@ -115,123 +118,87 @@ export default class GenerateSnippet { 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]}`, + text: `\\${match[0]}`, })), - ...variables.map(({ offsets, defaultName, placeholderIndex }) => ({ + ...variables.map(({ offsets, name }) => ({ 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(""), - ), + text: `$${name}`, })), ]); 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; + let editableEditor: EditableTextEditor; + let snippetDocuments: SnippetDocument[]; - // 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, + 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(dirPath, snippetName), ); + snippetDocuments = parseSnippetFile(editableEditor.document.getText()); + } + + await editableEditor.setSelections([ + editableEditor.document.range.toSelection(false), + ]); - return [key, value]; + 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++}`], + }; }; - /** 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), - ), - }, + 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 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); - } + 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(snippetText); + await editableEditor.insertSnippet(metaSnippetText); return { thatSelections: targets.map(({ editor, contentSelection }) => ({ @@ -242,6 +209,26 @@ export default class GenerateSnippet { } } +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; +} + +const PLACEHOLDER = "PLACEHOLDER_VFA77zcbLD6wXNmfMAay"; + interface Variable { /** * The start an end offsets of the variable relative to the text of the @@ -250,14 +237,7 @@ interface Variable { 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. + * The name for the variable */ - placeholderIndex: number; + name: string; } diff --git a/packages/cursorless-engine/src/actions/GenerateSnippet/Substituter.ts b/packages/cursorless-engine/src/actions/GenerateSnippet/Substituter.ts deleted file mode 100644 index 16b26c4b35..0000000000 --- a/packages/cursorless-engine/src/actions/GenerateSnippet/Substituter.ts +++ /dev/null @@ -1,79 +0,0 @@ -interface Substitution { - randomId: string; - to: string; - isQuoted: boolean; -} - -/** - * This class constructs random strings that can be used as placeholders for the - * strings you'd like to insert into a document. This functionality is useful if - * the strings you'd like to insert might get modified by something like json - * serialization. You proceed by calling {@link addSubstitution} for each string you'd - * like to put into your document. This function returns a random id that you - * can put into your text. When you are done, call {@link makeSubstitutions} - * on the final text to replace the random id's with the original strings you - * desired. - */ -export default class Substituter { - private substitutions: Substitution[] = []; - - /** - * Get a random id that can be put into your text body that will then be - * replaced by {@link to} when you call {@link makeSubstitutions}. - * @param to The string that you'd like to end up in the final document after - * replacements - * @param isQuoted Use this variable to indicate that in the final text the - * variable will end up quoted. This occurs if you use the replacement string - * as a stand alone string in a json document and then you serialize it - * @returns A unique random id that can be put into the document that will - * then be substituted later - */ - addSubstitution(to: string, isQuoted: boolean = false) { - const randomId = makeid(10); - - this.substitutions.push({ - to, - randomId, - isQuoted, - }); - - return randomId; - } - - /** - * Performs substitutions on {@link text}, replacing the random ids generated - * by {@link addSubstitution} with the values passed in for `to`. - * @param text The text to perform substitutions on - * @returns The text with variable substituted for the original values you - * desired - */ - makeSubstitutions(text: string) { - this.substitutions.forEach(({ to, randomId, isQuoted }) => { - const from = isQuoted ? `"${randomId}"` : randomId; - // NB: We use split / join instead of replace because the latter doesn't - // handle dollar signs well - text = text.split(from).join(to); - }); - - return text; - } -} - -/** - * Constructs a random id of the given length. - * - * From https://stackoverflow.com/a/1349426/2605678 - * - * @param length Length of the string to generate - * @returns A string of random digits of length {@param length} - */ -function makeid(length: number) { - let result = ""; - const characters = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - const charactersLength = characters.length; - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return result; -} diff --git a/packages/cursorless-engine/src/actions/actions.types.ts b/packages/cursorless-engine/src/actions/actions.types.ts index 8ded7fe1e3..28c1bf0e19 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[], + dirPath: 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..917609e7e1 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,10 @@ export interface Snippets { getSnippetStrict(snippetName: string): Snippet; /** - * Opens a new snippet file in the users snippet directory. + * Opens a new snippet file + * @param dirPath The path to the directory where the snippet should be created * @param snippetName The name of the snippet + * @returns The text editor of the newly created snippet file */ - openNewSnippetFile(snippetName: string): Promise; + openNewSnippetFile(dirPath: string, snippetName: string): Promise; } diff --git a/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts b/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts index 77bfe82bc3..ff9f235a85 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.dirPath, actionDescriptor.snippetName, ); diff --git a/packages/cursorless-engine/src/core/commandVersionUpgrades/canonicalizeAndValidateCommand.ts b/packages/cursorless-engine/src/core/commandVersionUpgrades/canonicalizeAndValidateCommand.ts index 0543516e34..c9803c3647 100644 --- a/packages/cursorless-engine/src/core/commandVersionUpgrades/canonicalizeAndValidateCommand.ts +++ b/packages/cursorless-engine/src/core/commandVersionUpgrades/canonicalizeAndValidateCommand.ts @@ -17,6 +17,7 @@ import { upgradeV3ToV4 } from "./upgradeV3ToV4"; import { upgradeV4ToV5 } from "./upgradeV4ToV5/upgradeV4ToV5"; import { upgradeV5ToV6 } from "./upgradeV5ToV6"; import { upgradeV6ToV7 } from "./upgradeV6ToV7"; +import { upgradeV7ToV8 } from "./upgradeV7ToV8"; /** * Given a command argument which comes from the client, normalize it so that it @@ -75,6 +76,9 @@ export function upgradeCommand( case 6: command = upgradeV6ToV7(command); break; + case 7: + command = upgradeV7ToV8(command); + break; default: throw new Error( `Can't upgrade from unknown version ${command.version}`, diff --git a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV5ToV6/upgradeV5ToV6.ts b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV5ToV6/upgradeV5ToV6.ts index 6cf2aecff6..b03c3b8cd7 100644 --- a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV5ToV6/upgradeV5ToV6.ts +++ b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV5ToV6/upgradeV5ToV6.ts @@ -1,6 +1,5 @@ import type { ActionCommandV5, - ActionDescriptor, CommandV5, CommandV6, DestinationDescriptor, @@ -45,7 +44,7 @@ export function upgradeV5ToV6(command: CommandV5): EnforceUndefined { function upgradeAction( action: ActionCommandV5, targets: PartialTargetDescriptorV5[], -): EnforceUndefined { +): EnforceUndefined { // We canonicalize once and for all const name = canonicalizeActionName(action.name); diff --git a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV7ToV8.ts b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV7ToV8.ts new file mode 100644 index 0000000000..d513ca5db4 --- /dev/null +++ b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV7ToV8.ts @@ -0,0 +1,14 @@ +import type { CommandV7, CommandV8 } from "@cursorless/common"; + +export function upgradeV7ToV8(command: CommandV7): CommandV8 { + return { ...command, version: 8, action: upgradeAction(command.action) }; +} + +function upgradeAction(action: CommandV7["action"]): CommandV8["action"] { + if (action.name === "generateSnippet") { + throw Error( + `Action "generateSnippet" is not possible to upgrade to api version 8. Please install latest version of cursorless-talon`, + ); + } + return action; +} diff --git a/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts b/packages/cursorless-engine/src/disabledComponents/DisabledSnippets.ts index f9377c7251..f277c63144 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 { SnippetMap, Snippet, 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( + _dirPath: string, + _snippetName: string, + ): Promise { throw new Error("Snippets are not implemented."); } } diff --git a/packages/cursorless-engine/src/generateSpokenForm/getHatMapCommand.ts b/packages/cursorless-engine/src/generateSpokenForm/getHatMapCommand.ts index 96cdbf34c7..6f7e515b3d 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/getHatMapCommand.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/getHatMapCommand.ts @@ -34,7 +34,7 @@ export function getHatMapCommand(marks: string[]): CommandLatest { elements: primitiveTargets, }, }, - version: 7, + version: 8, usePrePhraseSnapshot: false, }; } diff --git a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts index ca22cfc0da..26ad891552 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts @@ -232,6 +232,7 @@ export class PrimitiveTargetSpokenFormGenerator { case "oneOf": case "surroundingPairInterior": throw new NoSpokenFormError(`Scope type '${scopeType.type}'`); + case "glyph": return [ this.spokenFormMap.complexScopeTypeType.glyph, @@ -241,18 +242,9 @@ export class PrimitiveTargetSpokenFormGenerator { scopeType.character, ), ]; - case "surroundingPair": { - const pair = this.spokenFormMap.pairedDelimiter[scopeType.delimiter]; - if (scopeType.forceDirection != null) { - return [ - this.spokenFormMap.surroundingPairForceDirection[ - scopeType.forceDirection - ], - pair, - ]; - } - return pair; - } + + case "surroundingPair": + return this.spokenFormMap.pairedDelimiter[scopeType.delimiter]; case "customRegex": return ( diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairScopeHandler.ts index 615873b833..e6e02358f7 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairScopeHandler.ts @@ -4,9 +4,8 @@ import type { SurroundingPairScopeType, TextEditor, } from "@cursorless/common"; -import { showError, type ScopeType } from "@cursorless/common"; +import { type ScopeType } from "@cursorless/common"; import type { LanguageDefinitions } from "../../../../languages/LanguageDefinitions"; -import { ide } from "../../../../singletons/ide.singleton"; import { BaseScopeHandler } from "../BaseScopeHandler"; import { compareTargetScopes } from "../compareTargetScopes"; import type { TargetScope } from "../scope.types"; @@ -35,16 +34,6 @@ export class SurroundingPairScopeHandler extends BaseScopeHandler { direction: Direction, hints: ScopeIteratorRequirements, ): Iterable { - if (this.scopeType.forceDirection != null) { - // DEPRECATED @ 2024-07-01 - void showError( - ide().messages, - "deprecatedForceDirection", - "forceDirection is deprecated. If this is important to you please file an issue on the cursorless repo.", - ); - return; - } - const delimiterOccurrences = getDelimiterOccurrences( this.languageDefinitions.get(this.languageId), editor.document, diff --git a/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts b/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts index c9a2661173..c42df5f12e 100644 --- a/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts +++ b/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts @@ -111,11 +111,6 @@ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { glyph: "glyph", }, - surroundingPairForceDirection: { - left: "left", - right: "right", - }, - simpleModifier: { excludeInterior: "bounds", toRawSelection: "just", diff --git a/packages/cursorless-everywhere-talon-e2e/src/quickjsTest.ts b/packages/cursorless-everywhere-talon-e2e/src/quickjsTest.ts index 6ca2196dd1..ef04812a19 100644 --- a/packages/cursorless-everywhere-talon-e2e/src/quickjsTest.ts +++ b/packages/cursorless-everywhere-talon-e2e/src/quickjsTest.ts @@ -1,4 +1,4 @@ -import type { ActionDescriptor, CommandLatest } from "@cursorless/common"; +import type { ActionDescriptor, CommandV7 } from "@cursorless/common"; import { activate } from "@cursorless/cursorless-everywhere-talon-core"; import * as std from "std"; import talonMock from "./talonMock"; @@ -67,7 +67,7 @@ async function testChuck() { } function runAction(action: ActionDescriptor) { - const command: CommandLatest = { + const command: CommandV7 = { version: 7, usePrePhraseSnapshot: false, action, diff --git a/packages/cursorless-neovim-e2e/src/shouldRunTest.ts b/packages/cursorless-neovim-e2e/src/shouldRunTest.ts index 5f24849b29..d9a07640e5 100644 --- a/packages/cursorless-neovim-e2e/src/shouldRunTest.ts +++ b/packages/cursorless-neovim-e2e/src/shouldRunTest.ts @@ -47,6 +47,7 @@ function isFailingFixture(name: string, fixture: TestCaseFixtureLegacy) { return true; // "recorded/actions/snippets/*" -> not supported for now case "insertSnippet": + case "generateSnippet": return true; case "wrapWithSnippet": return true; diff --git a/packages/cursorless-vscode/src/VscodeSnippets.ts b/packages/cursorless-vscode/src/VscodeSnippets.ts index 37bbae3ad9..3288b25805 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,13 @@ export class VscodeSnippets implements Snippets { return snippet; } - async openNewSnippetFile(snippetName: string) { - const userSnippetsDir = this.ide.configuration.getOwnConfiguration( - "experimental.snippetsDir", - ); - - if (!userSnippetsDir) { - throw new Error("User snippets dir not configured."); - } - - const path = join(userSnippetsDir, `${snippetName}.cursorless-snippets`); + async openNewSnippetFile( + dirPath: string, + snippetName: string, + ): Promise { + const path = join(dirPath, `${snippetName}.snippet`); await touch(path); - await this.ide.openTextDocument(path); + return this.ide.openTextDocument(path); } } diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts index bbc33833e0..c00317225e 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts @@ -194,6 +194,7 @@ export default class KeyboardCommandsTargeted { case "editNew": case "getText": case "parsed": + case "generateSnippet": throw Error(`Unsupported keyboard action: ${name}`); case "replaceWithTarget": case "moveToTarget": @@ -223,7 +224,6 @@ export default class KeyboardCommandsTargeted { target, }, }; - case "generateSnippet": case "highlight": return { name, 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: {}