diff --git a/cursorless-talon-dev/src/cursorless_test.talon b/cursorless-talon-dev/src/cursorless_test.talon index e7a4c00c59..561d620ee0 100644 --- a/cursorless-talon-dev/src/cursorless_test.talon +++ b/cursorless-talon-dev/src/cursorless_test.talon @@ -35,3 +35,9 @@ test api extract decorated marks : user.private_cursorless_test_extract_decorated_marks(cursorless_target) test api alternate highlight nothing: user.private_cursorless_test_alternate_highlight_nothing() + +test api parsed: user.cursorless_custom_command("chuck block") +test api parsed : + user.cursorless_custom_command("chuck block ", cursorless_target) +test api parsed plus : + user.cursorless_custom_command("bring block after ", cursorless_target_1, cursorless_target_2) diff --git a/cursorless-talon/src/public_api.py b/cursorless-talon/src/public_api.py index eebaf6dfa0..c332ad1cf3 100644 --- a/cursorless-talon/src/public_api.py +++ b/cursorless-talon/src/public_api.py @@ -1,4 +1,6 @@ -from talon import Module +from typing import Any, Optional + +from talon import Module, actions from .targets.target_types import ( CursorlessDestination, @@ -20,3 +22,21 @@ def cursorless_create_destination( ) -> CursorlessDestination: """Cursorless: Create destination from target""" return PrimitiveDestination(insertion_mode, target) + + +@mod.action_class +class CommandActions: + def cursorless_custom_command( + content: str, # pyright: ignore [reportGeneralTypeIssues] + arg1: Optional[Any] = None, + arg2: Optional[Any] = None, + arg3: Optional[Any] = None, + ): + """Cursorless: Run custom parsed command""" + actions.user.private_cursorless_command_and_wait( + { + "name": "parsed", + "content": content, + "arguments": [arg for arg in [arg1, arg2, arg3] if arg is not None], + } + ) diff --git a/data/fixtures/recorded/actions/parsed/bigBringAirPlusCap.yml b/data/fixtures/recorded/actions/parsed/bigBringAirPlusCap.yml new file mode 100644 index 0000000000..0b5cf6c715 --- /dev/null +++ b/data/fixtures/recorded/actions/parsed/bigBringAirPlusCap.yml @@ -0,0 +1,64 @@ +languageId: plaintext +command: + version: 7 + spokenForm: big bring air plus cap + action: + name: parsed + content: bring block after + arguments: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: c} + usePrePhraseSnapshot: true +spokenFormError: Action 'parsed' +initialState: + documentContents: |- + aaa + bbb + + ccc + ddd + + eee + fff + selections: + - anchor: {line: 7, character: 3} + active: {line: 7, character: 3} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.c: + start: {line: 3, character: 0} + end: {line: 3, character: 3} +finalState: + documentContents: |- + aaa + bbb + + ccc + ddd + + aaa + bbb + + eee + fff + selections: + - anchor: {line: 10, character: 3} + active: {line: 10, character: 3} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 6, character: 0} + end: {line: 7, character: 3} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/parsed/destroyAir.yml b/data/fixtures/recorded/actions/parsed/destroyAir.yml new file mode 100644 index 0000000000..7cb54d5698 --- /dev/null +++ b/data/fixtures/recorded/actions/parsed/destroyAir.yml @@ -0,0 +1,40 @@ +languageId: plaintext +command: + version: 7 + spokenForm: destroy air + action: + name: parsed + content: chuck block + arguments: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + usePrePhraseSnapshot: true +spokenFormError: Action 'parsed' +initialState: + documentContents: |- + aaa + bbb + + ccc + ddd + selections: + - anchor: {line: 4, character: 3} + active: {line: 4, character: 3} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |- + ccc + ddd + selections: + - anchor: {line: 1, character: 3} + active: {line: 1, character: 3} + thatMark: + - type: RawSelectionTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 0} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/parsed/destroyAirAndEachPastGust.yml b/data/fixtures/recorded/actions/parsed/destroyAirAndEachPastGust.yml new file mode 100644 index 0000000000..7d6ded68b0 --- /dev/null +++ b/data/fixtures/recorded/actions/parsed/destroyAirAndEachPastGust.yml @@ -0,0 +1,69 @@ +languageId: plaintext +command: + version: 7 + spokenForm: destroy air and each past gust + action: + name: parsed + content: chuck block + arguments: + - type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + - type: range + anchor: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: e} + active: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: g} + excludeAnchor: false + excludeActive: false + usePrePhraseSnapshot: true +spokenFormError: Action 'parsed' +initialState: + documentContents: |- + aaa + bbb + + ccc + ddd + + eee + fff + + ggg + hhh + selections: + - anchor: {line: 10, character: 3} + active: {line: 10, character: 3} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.e: + start: {line: 6, character: 0} + end: {line: 6, character: 3} + default.g: + start: {line: 9, character: 0} + end: {line: 9, character: 3} +finalState: + documentContents: |- + ccc + ddd + selections: + - anchor: {line: 1, character: 3} + active: {line: 1, character: 3} + thatMark: + - type: RawSelectionTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 0} + isReversed: false + hasExplicitRange: true + - type: RawSelectionTarget + contentRange: + start: {line: 1, character: 3} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/parsed/destruction.yml b/data/fixtures/recorded/actions/parsed/destruction.yml new file mode 100644 index 0000000000..29a4dad08c --- /dev/null +++ b/data/fixtures/recorded/actions/parsed/destruction.yml @@ -0,0 +1,35 @@ +languageId: plaintext +command: + version: 7 + spokenForm: destruction + action: + name: parsed + content: chuck block + arguments: [] + usePrePhraseSnapshot: true +spokenFormError: Action 'parsed' +initialState: + documentContents: |- + aaa + bbb + + ccc + ddd + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: |- + ccc + ddd + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + thatMark: + - type: RawSelectionTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 0} + isReversed: false + hasExplicitRange: true diff --git a/packages/common/src/types/command/ActionDescriptor.ts b/packages/common/src/types/command/ActionDescriptor.ts index 82d6c179ea..1d288ef5bd 100644 --- a/packages/common/src/types/command/ActionDescriptor.ts +++ b/packages/common/src/types/command/ActionDescriptor.ts @@ -7,7 +7,7 @@ import { DestinationDescriptor } from "./DestinationDescriptor.types"; /** * A simple action takes only a single target and no other arguments. */ -const simpleActionNames = [ +export const simpleActionNames = [ "breakLine", "clearAndSetSelection", "copyToClipboard", @@ -72,6 +72,7 @@ const complexActionNames = [ "swapTargets", "wrapWithPairedDelimiter", "wrapWithSnippet", + "parsed", ] as const; export const actionNames = [ @@ -219,6 +220,12 @@ export interface GetTextActionDescriptor { target: PartialTargetDescriptor; } +interface ParsedActionDescriptor { + name: "parsed"; + content: string; + arguments: unknown[]; +} + export type ActionDescriptor = | SimpleActionDescriptor | BringMoveActionDescriptor @@ -233,4 +240,5 @@ export type ActionDescriptor = | WrapWithSnippetActionDescriptor | WrapWithPairedDelimiterActionDescriptor | EditNewActionDescriptor - | GetTextActionDescriptor; + | GetTextActionDescriptor + | ParsedActionDescriptor; diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index fcb1e390f6..3589945b1c 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -22,6 +22,13 @@ export interface LastCursorPositionMark { type: "lastCursorPosition"; } +export type SimplePartialMark = + | ThatMark + | KeyboardMark + | SourceMark + | NothingMark + | LastCursorPositionMark; + export interface DecoratedSymbolMark { type: "decoratedSymbol"; symbolColor: string; @@ -39,14 +46,16 @@ export interface LineNumberMark { /** * Constructs a range between {@link anchor} and {@link active} */ -export interface RangeMark { +export interface RangeMarkFor { type: "range"; - anchor: PartialMark; - active: PartialMark; + anchor: T; + active: T; excludeAnchor: boolean; excludeActive: boolean; } +export type PartialRangeMark = RangeMarkFor; + interface SimplePosition { readonly line: number; readonly character: number; @@ -69,6 +78,22 @@ export interface ExplicitMark { 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". + */ +export interface PartialTargetMark { + type: "target"; + + /** + * The target descriptor that will be used to generate the targets output by + * this mark. + */ + target: PartialTargetDescriptor; +} + export type PartialMark = | CursorMark | ThatMark @@ -77,8 +102,9 @@ export type PartialMark = | DecoratedSymbolMark | NothingMark | LineNumberMark - | RangeMark - | ExplicitMark; + | PartialRangeMark + | ExplicitMark + | PartialTargetMark; export const simpleSurroundingPairNames = [ "angleBrackets", diff --git a/packages/cursorless-engine/src/CommandHistory.ts b/packages/cursorless-engine/src/CommandHistory.ts index 6b3b4e3239..24d5f031aa 100644 --- a/packages/cursorless-engine/src/CommandHistory.ts +++ b/packages/cursorless-engine/src/CommandHistory.ts @@ -197,6 +197,7 @@ function sanitizeActionInPlace(action: ActionDescriptor): void { case "wrapWithPairedDelimiter": case "findInDocument": case "private.setKeyboardTarget": + case "parsed": break; default: { diff --git a/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts b/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts index 426b5462bf..34738cd1e2 100644 --- a/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts +++ b/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts @@ -9,6 +9,7 @@ import { } from "@cursorless/common"; import { CommandRunner } from "../../CommandRunner"; import { ActionRecord, ActionReturnValue } from "../../actions/actions.types"; +import { parseAndFillOutAction } from "../../customCommandGrammar/parseAndFillOutAction"; import { StoredTargetMap } from "../../index"; import { TargetPipelineRunner } from "../../processTargets"; import { ModifierStage } from "../../processTargets/PipelineStages.types"; @@ -197,6 +198,14 @@ export class CommandRunnerImpl implements CommandRunner { actionDescriptor.options, ); + case "parsed": + return this.runAction( + parseAndFillOutAction( + actionDescriptor.content, + actionDescriptor.arguments, + ), + ); + default: { const action = this.actions[actionDescriptor.name]; this.finalStages = action.getFinalStages?.() ?? []; diff --git a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV5ToV6/upgradeV5ToV6.ts b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV5ToV6/upgradeV5ToV6.ts index 7d26cc9dcd..0bfbfddfc6 100644 --- a/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV5ToV6/upgradeV5ToV6.ts +++ b/packages/cursorless-engine/src/core/commandVersionUpgrades/upgradeV5ToV6/upgradeV5ToV6.ts @@ -132,6 +132,8 @@ function upgradeAction( options: action.args?.[0] as GetTextActionOptions | undefined, target: upgradeTarget(targets[0]), }; + case "parsed": + throw Error("Parsed action should not be present in V5"); default: return { name, diff --git a/packages/cursorless-engine/src/core/getCommandFallback.ts b/packages/cursorless-engine/src/core/getCommandFallback.ts index 941a02c0eb..0474062c13 100644 --- a/packages/cursorless-engine/src/core/getCommandFallback.ts +++ b/packages/cursorless-engine/src/core/getCommandFallback.ts @@ -89,6 +89,7 @@ export async function getCommandFallback( case "insertSnippet": case "generateSnippet": case "wrapWithSnippet": + case "parsed": return null; default: diff --git a/packages/cursorless-engine/src/core/indexArrayStrict.ts b/packages/cursorless-engine/src/core/indexArrayStrict.ts new file mode 100644 index 0000000000..3081d82ccb --- /dev/null +++ b/packages/cursorless-engine/src/core/indexArrayStrict.ts @@ -0,0 +1,9 @@ +export function indexArrayStrict(arr: T[], idx: number, name: string): T { + if (idx >= arr.length) { + throw Error( + `Expected at least ${idx + 1} ${name} but received only ${arr.length}`, + ); + } + + return arr[idx]; +} diff --git a/packages/cursorless-engine/src/core/inferFullTargetDescriptor.ts b/packages/cursorless-engine/src/core/inferFullTargetDescriptor.ts index 25248c4d1e..04991d9f59 100644 --- a/packages/cursorless-engine/src/core/inferFullTargetDescriptor.ts +++ b/packages/cursorless-engine/src/core/inferFullTargetDescriptor.ts @@ -1,6 +1,7 @@ import { Modifier, PartialListTargetDescriptor, + PartialMark, PartialPrimitiveTargetDescriptor, PartialRangeTargetDescriptor, PartialTargetDescriptor, @@ -100,12 +101,14 @@ function inferPrimitiveTarget( target: PartialPrimitiveTargetDescriptor, previousTargets: PartialTargetDescriptor[], ): PrimitiveTargetDescriptor { - const mark = target.mark ?? - (shouldInferPreviousMark(target) - ? getPreviousMark(previousTargets) - : null) ?? { - type: "cursor", - }; + const mark = handleTargetMark( + target.mark ?? + (shouldInferPreviousMark(target) + ? getPreviousMark(previousTargets) + : null) ?? { + type: "cursor", + }, + ); const modifiers = getPreservedModifiers(target) ?? @@ -171,7 +174,7 @@ function getLineNumberMarkModifiers( * @returns True if this target has a line number mark */ function isLineNumberMark(target: PartialPrimitiveTargetDescriptor): boolean { - const isLineNumber = (mark?: Mark) => mark?.type === "lineNumber"; + const isLineNumber = (mark?: PartialMark) => mark?.type === "lineNumber"; if (isLineNumber(target.mark)) { return true; } @@ -183,7 +186,7 @@ function isLineNumberMark(target: PartialPrimitiveTargetDescriptor): boolean { function getPreviousMark( previousTargets: PartialTargetDescriptor[], -): Mark | undefined { +): PartialMark | undefined { return getPreviousTargetAttribute( previousTargets, (target: PartialPrimitiveTargetDescriptor) => target.mark, @@ -254,3 +257,21 @@ function getPreviousTargetAttribute( } return undefined; } + +function handleTargetMark(mark: PartialMark): Mark { + switch (mark.type) { + case "range": + return { + ...mark, + anchor: handleTargetMark(mark.anchor), + active: handleTargetMark(mark.active), + }; + case "target": + return { + type: "target", + target: inferFullTargetDescriptor(mark.target, []), + }; + default: + return mark; + } +} diff --git a/packages/cursorless-engine/src/customCommandGrammar/CommandLexer.ts b/packages/cursorless-engine/src/customCommandGrammar/CommandLexer.ts new file mode 100644 index 0000000000..7d2c5a2022 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/CommandLexer.ts @@ -0,0 +1,72 @@ +import moo, { + type Lexer as MooLexer, + type LexerState as MooLexerState, + type Rules, + type Token as MooToken, +} from "moo"; + +export interface NearleyToken { + value: any; + [key: string]: any; +} + +export interface NearleyLexer { + reset: (chunk: any, info?: any) => this; + next: () => NearleyToken | undefined; + save: () => any; + formatError: (token: any, message: string) => string; + has: (tokenType: any) => boolean; + transform({ value }: NearleyToken): string; +} + +interface State { + mooState: MooLexerState; +} + +export class CommandLexer implements NearleyLexer { + private mooLexer: MooLexer; + + constructor(rules: Rules) { + this.mooLexer = moo.compile(rules); + } + + reset(chunk?: string, state?: State): this { + const { mooState } = state ?? {}; + + this.mooLexer.reset(chunk, mooState); + + return this; + } + + formatError(token: MooToken, message?: string): string { + return this.mooLexer.formatError(token, message); + } + + has(tokenType: string): boolean { + return this.mooLexer.has(tokenType); + } + + save(): State { + return { + mooState: this.mooLexer.save(), + }; + } + + next(): NearleyToken | undefined { + const token = this.mooLexer.next(); + + if (this.skipToken(token)) { + return this.next(); + } + + return token; + } + + transform({ value }: NearleyToken) { + return value; + } + + private skipToken(token: MooToken | undefined) { + return token?.type === "ws"; + } +} diff --git a/packages/cursorless-engine/src/customCommandGrammar/WithPlaceholders.ts b/packages/cursorless-engine/src/customCommandGrammar/WithPlaceholders.ts new file mode 100644 index 0000000000..4bb713ea87 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/WithPlaceholders.ts @@ -0,0 +1,22 @@ +export interface Placeholder { + type: "placeholder"; + index: number; +} + +/** + * This type recursively adds union with `Placeholder` to all fields of + * {@link input}. + */ +export type WithPlaceholders = T extends object + ? T extends any[] + ? + | { + [K in keyof T]: WithPlaceholders; + } + | Placeholder + : + | { + [K in keyof T]: WithPlaceholders; + } + | Placeholder + : T | Placeholder; diff --git a/packages/cursorless-engine/src/customCommandGrammar/fillPlaceholders.ts b/packages/cursorless-engine/src/customCommandGrammar/fillPlaceholders.ts new file mode 100644 index 0000000000..0d03f70276 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/fillPlaceholders.ts @@ -0,0 +1,44 @@ +import { indexArrayStrict } from "../core/indexArrayStrict"; +import { Placeholder, WithPlaceholders } from "./WithPlaceholders"; + +/** + * Given an input with placeholders, fills in the placeholders with the given + * values. + * + * @param input The input to fill placeholders in + * @param values The values to fill the placeholders with + * @returns The input with the placeholders filled in + */ +export function fillPlaceholders( + input: WithPlaceholders, + values: unknown[], +): T { + if (Array.isArray(input)) { + return input.map((item) => fillPlaceholders(item, values)) as T; + } + + if (typeof input === "object" && input != null) { + if (isPlaceholder(input)) { + return indexArrayStrict(values, input.index, "placeholder value") as T; + } + + const result: Partial = {}; + for (const key in input) { + if (Object.prototype.hasOwnProperty.call(input, key)) { + result[key as keyof T] = fillPlaceholders((input as any)[key], values); + } + } + return result as T; + } + + return input as T; +} + +function isPlaceholder(value: unknown): value is Placeholder { + return ( + typeof value === "object" && + value != null && + "type" in value && + value.type === "placeholder" + ); +} diff --git a/packages/cursorless-engine/src/customCommandGrammar/generated/grammar.ts b/packages/cursorless-engine/src/customCommandGrammar/generated/grammar.ts index a1fce0acec..4b7062b59e 100644 --- a/packages/cursorless-engine/src/customCommandGrammar/generated/grammar.ts +++ b/packages/cursorless-engine/src/customCommandGrammar/generated/grammar.ts @@ -3,11 +3,27 @@ // Bypasses TS6133. Allow declared but unused functions. // @ts-ignore function id(d: any[]): any { return d[0]; } +declare var simpleActionName: any; +declare var bringMove: any; +declare var insertionMode: any; declare var simpleScopeTypeType: any; declare var pairedDelimiter: any; +declare var simpleMarkType: any; +declare var placeholderTarget: any; import { capture } from "../../util/grammarHelpers"; import { lexer } from "../lexer"; +import { + bringMoveActionDescriptor, + containingScopeModifier, + partialPrimitiveTargetDescriptor, + createPlaceholderTarget, + primitiveDestinationDescriptor, + simpleActionDescriptor, + simplePartialMark, + simpleScopeType, + surroundingPairScopeType, +} from "../grammarUtil"; interface NearleyToken { value: any; @@ -39,10 +55,48 @@ interface Grammar { const grammar: Grammar = { Lexer: lexer, ParserRules: [ - {"name": "main", "symbols": ["scopeType"]}, - {"name": "scopeType", "symbols": [(lexer.has("simpleScopeTypeType") ? {type: "simpleScopeTypeType"} : simpleScopeTypeType)], "postprocess": capture("type")}, + {"name": "main", "symbols": ["action"], "postprocess": id}, + {"name": "action", "symbols": [(lexer.has("simpleActionName") ? {type: "simpleActionName"} : simpleActionName), "target"], "postprocess": + ([simpleActionName, target]) => simpleActionDescriptor(simpleActionName, target) + }, + {"name": "action", "symbols": [(lexer.has("bringMove") ? {type: "bringMove"} : bringMove), "target", "destination"], "postprocess": + ([bringMove, target, destination]) => bringMoveActionDescriptor(bringMove, target, destination) + }, + {"name": "destination", "symbols": ["primitiveDestination"], "postprocess": id}, + {"name": "destination", "symbols": [(lexer.has("insertionMode") ? {type: "insertionMode"} : insertionMode), "target"], "postprocess": + ([insertionMode, target]) => primitiveDestinationDescriptor(insertionMode, target) + }, + {"name": "target", "symbols": ["primitiveTarget"], "postprocess": id}, + {"name": "primitiveTarget$ebnf$1", "symbols": ["modifier"]}, + {"name": "primitiveTarget$ebnf$1", "symbols": ["primitiveTarget$ebnf$1", "modifier"], "postprocess": (d) => d[0].concat([d[1]])}, + {"name": "primitiveTarget", "symbols": ["primitiveTarget$ebnf$1"], "postprocess": + ([modifiers]) => partialPrimitiveTargetDescriptor(modifiers, undefined) + }, + {"name": "primitiveTarget", "symbols": ["mark"], "postprocess": + ([mark]) => partialPrimitiveTargetDescriptor(undefined, mark) + }, + {"name": "primitiveTarget$ebnf$2", "symbols": ["modifier"]}, + {"name": "primitiveTarget$ebnf$2", "symbols": ["primitiveTarget$ebnf$2", "modifier"], "postprocess": (d) => d[0].concat([d[1]])}, + {"name": "primitiveTarget", "symbols": ["primitiveTarget$ebnf$2", "mark"], "postprocess": + ([modifiers, mark]) => partialPrimitiveTargetDescriptor(modifiers, mark) + }, + {"name": "modifier", "symbols": ["containingScopeModifier"], "postprocess": + ([containingScopeModifier]) => containingScopeModifier + }, + {"name": "containingScopeModifier", "symbols": ["scopeType"], "postprocess": + ([scopeType]) => containingScopeModifier(scopeType) + }, + {"name": "scopeType", "symbols": [(lexer.has("simpleScopeTypeType") ? {type: "simpleScopeTypeType"} : simpleScopeTypeType)], "postprocess": + ([simpleScopeTypeType]) => simpleScopeType(simpleScopeTypeType) + }, {"name": "scopeType", "symbols": [(lexer.has("pairedDelimiter") ? {type: "pairedDelimiter"} : pairedDelimiter)], "postprocess": - ([delimiter]) => ({ type: "surroundingPair", delimiter }) + ([delimiter]) => surroundingPairScopeType(delimiter) + }, + {"name": "mark", "symbols": [(lexer.has("simpleMarkType") ? {type: "simpleMarkType"} : simpleMarkType)], "postprocess": + ([simpleMarkType]) => simplePartialMark(simpleMarkType) + }, + {"name": "mark", "symbols": [(lexer.has("placeholderTarget") ? {type: "placeholderTarget"} : placeholderTarget)], "postprocess": + ([placeholderTarget]) => createPlaceholderTarget(placeholderTarget) } ], ParserStart: "main", diff --git a/packages/cursorless-engine/src/customCommandGrammar/grammar.ne b/packages/cursorless-engine/src/customCommandGrammar/grammar.ne index e03867b7e2..7db0e84d7a 100644 --- a/packages/cursorless-engine/src/customCommandGrammar/grammar.ne +++ b/packages/cursorless-engine/src/customCommandGrammar/grammar.ne @@ -2,13 +2,82 @@ @{% import { capture } from "../../util/grammarHelpers"; import { lexer } from "../lexer"; +import { + bringMoveActionDescriptor, + containingScopeModifier, + partialPrimitiveTargetDescriptor, + createPlaceholderTarget, + primitiveDestinationDescriptor, + simpleActionDescriptor, + simplePartialMark, + simpleScopeType, + surroundingPairScopeType, +} from "../grammarUtil"; %} @lexer lexer -main -> scopeType +main -> action {% id %} + +# --------------------------- Actions --------------------------- + +action -> %simpleActionName target {% + ([simpleActionName, target]) => simpleActionDescriptor(simpleActionName, target) +%} + +action -> %bringMove target destination {% + ([bringMove, target, destination]) => bringMoveActionDescriptor(bringMove, target, destination) +%} + +# --------------------------- Destinations --------------------------- + +destination -> primitiveDestination {% id %} + +destination -> %insertionMode target {% + ([insertionMode, target]) => primitiveDestinationDescriptor(insertionMode, target) +%} + +# --------------------------- Targets --------------------------- + +target -> primitiveTarget {% id %} + +primitiveTarget -> modifier:+ {% + ([modifiers]) => partialPrimitiveTargetDescriptor(modifiers, undefined) +%} + +primitiveTarget -> mark {% + ([mark]) => partialPrimitiveTargetDescriptor(undefined, mark) +%} + +primitiveTarget -> modifier:+ mark {% + ([modifiers, mark]) => partialPrimitiveTargetDescriptor(modifiers, mark) +%} + +# --------------------------- Modifiers --------------------------- + +modifier -> containingScopeModifier {% + ([containingScopeModifier]) => containingScopeModifier +%} + +containingScopeModifier -> scopeType {% + ([scopeType]) => containingScopeModifier(scopeType) +%} # --------------------------- Scope types --------------------------- -scopeType -> %simpleScopeTypeType {% capture("type") %} + +scopeType -> %simpleScopeTypeType {% + ([simpleScopeTypeType]) => simpleScopeType(simpleScopeTypeType) +%} + scopeType -> %pairedDelimiter {% - ([delimiter]) => ({ type: "surroundingPair", delimiter }) + ([delimiter]) => surroundingPairScopeType(delimiter) +%} + +# --------------------------- Marks --------------------------- + +mark -> %simpleMarkType {% + ([simpleMarkType]) => simplePartialMark(simpleMarkType) +%} + +mark -> %placeholderTarget {% + ([placeholderTarget]) => createPlaceholderTarget(placeholderTarget) %} diff --git a/packages/cursorless-engine/src/customCommandGrammar/grammarAction.test.ts b/packages/cursorless-engine/src/customCommandGrammar/grammarAction.test.ts new file mode 100644 index 0000000000..e5fd63b686 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/grammarAction.test.ts @@ -0,0 +1,143 @@ +import { type ActionDescriptor } from "@cursorless/common"; +import assert from "assert"; +import { parseAction } from "./parseCommand"; +import { WithPlaceholders } from "./WithPlaceholders"; + +interface TestCase { + input: string; + expectedOutput: WithPlaceholders; +} + +const testCases: TestCase[] = [ + { + input: "chuck funk", + expectedOutput: { + name: "remove", + target: { + type: "primitive", + modifiers: [ + { type: "containingScope", scopeType: { type: "namedFunction" } }, + ], + }, + }, + }, + { + input: "change this", + expectedOutput: { + name: "clearAndSetSelection", + target: { + type: "primitive", + mark: { type: "cursor" }, + }, + }, + }, + { + input: "copy token line state", + expectedOutput: { + name: "copyToClipboard", + target: { + type: "primitive", + modifiers: [ + { type: "containingScope", scopeType: { type: "token" } }, + { type: "containingScope", scopeType: { type: "line" } }, + { type: "containingScope", scopeType: { type: "statement" } }, + ], + }, + }, + }, + { + input: "take block ", + expectedOutput: { + name: "setSelection", + target: { + type: "primitive", + modifiers: [ + { type: "containingScope", scopeType: { type: "paragraph" } }, + ], + mark: { type: "target", target: { type: "placeholder", index: 0 } }, + }, + }, + }, + { + input: "move after line ", + expectedOutput: { + name: "moveToTarget", + source: { + type: "primitive", + mark: { + type: "target", + target: { + type: "placeholder", + index: 0, + }, + }, + }, + destination: { + type: "primitive", + insertionMode: "after", + target: { + type: "primitive", + modifiers: [ + { + type: "containingScope", + scopeType: { + type: "line", + }, + }, + ], + mark: { + type: "target", + target: { + type: "placeholder", + index: 1, + }, + }, + }, + }, + }, + }, + { + input: "bring token to line", + expectedOutput: { + name: "replaceWithTarget", + source: { + type: "primitive", + modifiers: [ + { + type: "containingScope", + scopeType: { + type: "token", + }, + }, + ], + }, + destination: { + type: "primitive", + insertionMode: "to", + target: { + type: "primitive", + modifiers: [ + { + type: "containingScope", + scopeType: { + type: "line", + }, + }, + ], + }, + }, + }, + }, +]; + +suite("custom grammar: actions", () => { + testCases.forEach(({ input, expectedOutput }) => { + test(input, () => { + assert.deepStrictEqual( + parseAction(input), + expectedOutput, + JSON.stringify(parseAction(input), null, 4), + ); + }); + }); +}); diff --git a/packages/cursorless-engine/src/customCommandGrammar/grammarScopeType.test.ts b/packages/cursorless-engine/src/customCommandGrammar/grammarScopeType.test.ts index 7159c4553d..c85843163e 100644 --- a/packages/cursorless-engine/src/customCommandGrammar/grammarScopeType.test.ts +++ b/packages/cursorless-engine/src/customCommandGrammar/grammarScopeType.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; import { ScopeType } from "@cursorless/common"; -import { parseScopeType } from "./parseScopeType"; +import { parseScopeType } from "./parseCommand"; interface TestCase { input: string; diff --git a/packages/cursorless-engine/src/customCommandGrammar/grammarUtil.ts b/packages/cursorless-engine/src/customCommandGrammar/grammarUtil.ts new file mode 100644 index 0000000000..3fce5baf23 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/grammarUtil.ts @@ -0,0 +1,102 @@ +import { + BringMoveActionDescriptor, + DestinationDescriptor, + InsertionMode, + PartialListTargetDescriptor, + PartialRangeTargetDescriptor, + PartialTargetMark, + PrimitiveDestinationDescriptor, + type ContainingScopeModifier, + type Modifier, + type PartialMark, + type PartialPrimitiveTargetDescriptor, + type PartialTargetDescriptor, + type ScopeType, + type SimpleActionDescriptor, + type SimpleActionName, + type SimplePartialMark, + type SimpleScopeType, + type SimpleScopeTypeType, + type SurroundingPairName, + type SurroundingPairScopeType, +} from "@cursorless/common"; +import { WithPlaceholders } from "./WithPlaceholders"; + +export function simpleActionDescriptor( + name: SimpleActionName, + target: WithPlaceholders, +): WithPlaceholders { + return { name, target }; +} + +export function bringMoveActionDescriptor( + name: BringMoveActionDescriptor["name"], + source: WithPlaceholders, + destination: WithPlaceholders, +): WithPlaceholders { + return { name, source, destination }; +} + +export function partialPrimitiveTargetDescriptor( + modifiers: Modifier[] | undefined, + mark: WithPlaceholders | undefined, +): WithPlaceholders { + const target: WithPlaceholders = { + type: "primitive", + }; + if (modifiers != null) { + target.modifiers = modifiers; + } + if (mark != null) { + target.mark = mark; + } + return target; +} + +export function primitiveDestinationDescriptor( + insertionMode: InsertionMode, + target: WithPlaceholders< + | PartialPrimitiveTargetDescriptor + | PartialListTargetDescriptor + | PartialRangeTargetDescriptor + >, +): WithPlaceholders { + return { type: "primitive", insertionMode, target }; +} + +export function containingScopeModifier( + scopeType: ScopeType, +): ContainingScopeModifier { + return { + type: "containingScope", + scopeType, + }; +} + +export function simpleScopeType(type: SimpleScopeTypeType): SimpleScopeType { + return { type }; +} + +export function surroundingPairScopeType( + delimiter: SurroundingPairName, +): SurroundingPairScopeType { + return { type: "surroundingPair", delimiter }; +} + +export function simplePartialMark( + type: SimplePartialMark["type"], +): SimplePartialMark { + return { type }; +} + +export function createPlaceholderTarget( + index: string, +): WithPlaceholders { + return { + type: "target", + target: { + type: "placeholder", + index: index.length === 0 ? 0 : parseInt(index) - 1, + }, + }; +} diff --git a/packages/cursorless-engine/src/customCommandGrammar/lexer.test.ts b/packages/cursorless-engine/src/customCommandGrammar/lexer.test.ts index 09f6d84ebb..96ecdb67ad 100644 --- a/packages/cursorless-engine/src/customCommandGrammar/lexer.test.ts +++ b/packages/cursorless-engine/src/customCommandGrammar/lexer.test.ts @@ -1,18 +1,23 @@ import * as assert from "assert"; import { unitTestSetup } from "../test/unitTestSetup"; +import { NearleyLexer, NearleyToken } from "./CommandLexer"; import { lexer } from "./lexer"; -interface Token { - type: string; - value: string; -} - interface Fixture { input: string; - expectedOutput: Token[]; + expectedOutput: NearleyToken[]; } const fixtures: Fixture[] = [ + { + input: "chuck", + expectedOutput: [ + { + type: "simpleActionName", + value: "remove", + }, + ], + }, { input: "funk", expectedOutput: [ @@ -38,10 +43,6 @@ const fixtures: Fixture[] = [ type: "simpleScopeTypeType", value: "statement", }, - { - type: "ws", - value: " ", - }, { type: "simpleScopeTypeType", value: "name", @@ -57,15 +58,41 @@ const fixtures: Fixture[] = [ }, ], }, + { + input: "this", + expectedOutput: [ + { + type: "simpleMarkType", + value: "cursor", + }, + ], + }, + { + input: " ", + expectedOutput: [ + { + type: "placeholderTarget", + value: "", + }, + { + type: "placeholderTarget", + value: "1", + }, + { + type: "placeholderTarget", + value: "2", + }, + ], + }, ]; -suite("custom grammar lexer", () => { +suite("custom grammar: lexer", () => { unitTestSetup(); fixtures.forEach(({ input, expectedOutput }) => { test(input, () => { assert.deepStrictEqual( - Array.from(lexer.reset(input)).map(({ type, value }) => ({ + Array.from(iterateTokens(lexer, input)).map(({ type, value }) => ({ type, value, })), @@ -74,3 +101,16 @@ suite("custom grammar lexer", () => { }); }); }); + +function* iterateTokens(lexer: NearleyLexer, input: string) { + lexer.reset(input); + + let token; + while (true) { + token = lexer.next(); + if (!token) { + break; + } + yield token; + } +} diff --git a/packages/cursorless-engine/src/customCommandGrammar/lexer.ts b/packages/cursorless-engine/src/customCommandGrammar/lexer.ts index 147cf35f02..154c269416 100644 --- a/packages/cursorless-engine/src/customCommandGrammar/lexer.ts +++ b/packages/cursorless-engine/src/customCommandGrammar/lexer.ts @@ -1,6 +1,15 @@ -import { simpleScopeTypeTypes, surroundingPairNames } from "@cursorless/common"; -import moo from "moo"; +import { + BringMoveActionDescriptor, + InsertionMode, + simpleActionNames, + simpleScopeTypeTypes, + surroundingPairNames, +} from "@cursorless/common"; +import { actions } from "../generateSpokenForm/defaultSpokenForms/actions"; +import { marks } from "../generateSpokenForm/defaultSpokenForms/marks"; import { defaultSpokenFormMap } from "../spokenForms/defaultSpokenFormMap"; +import { connectives } from "../generateSpokenForm/defaultSpokenForms/connectives"; +import { CommandLexer } from "./CommandLexer"; interface Token { type: string; @@ -11,6 +20,44 @@ const tokens: Record = {}; // FIXME: Remove the duplication below? +for (const simpleActionName of simpleActionNames) { + const spokenForm = actions[simpleActionName]; + if (spokenForm != null) { + tokens[spokenForm] = { + type: "simpleActionName", + value: simpleActionName, + }; + } +} + +const bringMoveActionNames: BringMoveActionDescriptor["name"][] = [ + "replaceWithTarget", + "moveToTarget", +]; + +for (const bringMoveActionName of bringMoveActionNames) { + const spokenForm = actions[bringMoveActionName]; + if (spokenForm != null) { + tokens[spokenForm] = { + type: "bringMove", + value: bringMoveActionName, + }; + } +} + +const insertionModes: InsertionMode[] = ["before", "after", "to"]; + +for (const insertionMode of insertionModes) { + const spokenForm = + connectives[ + insertionMode === "to" ? "sourceDestinationConnective" : insertionMode + ]; + tokens[spokenForm] = { + type: "insertionMode", + value: insertionMode, + }; +} + for (const simpleScopeTypeType of simpleScopeTypeTypes) { const { spokenForms } = defaultSpokenFormMap.simpleScopeTypeType[simpleScopeTypeType]; @@ -32,13 +79,24 @@ for (const pairedDelimiter of surroundingPairNames) { } } -export const lexer = moo.compile({ +for (const [mark, spokenForm] of Object.entries(marks)) { + if (spokenForm != null) { + tokens[spokenForm] = { + type: "simpleMarkType", + value: mark, + }; + } +} + +export const lexer = new CommandLexer({ ws: /[ \t]+/, + placeholderTarget: { + match: //, + value: (text) => text.slice(7, -1), + }, token: { match: Object.keys(tokens), type: (text) => tokens[text].type, value: (text) => tokens[text].value, }, }); - -(lexer as any).transform = (token: { value: string }) => token.value; diff --git a/packages/cursorless-engine/src/customCommandGrammar/parseAndFillOutAction.ts b/packages/cursorless-engine/src/customCommandGrammar/parseAndFillOutAction.ts new file mode 100644 index 0000000000..8c62911389 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/parseAndFillOutAction.ts @@ -0,0 +1,20 @@ +import { ActionDescriptor } from "@cursorless/common"; +import { fillPlaceholders } from "./fillPlaceholders"; +import { parseAction } from "./parseCommand"; + +/** + * Given a content string and a list of arguments, parse the content string into + * an action descriptor and fill out the placeholders with the arguments. + * + * @param content The content to parse + * @param args The arguments to fill out the placeholders with + * @returns An action descriptor with the placeholders filled out using + * {@link args} + */ +export function parseAndFillOutAction( + content: string, + args: unknown[], +): ActionDescriptor { + const parsed = parseAction(content); + return fillPlaceholders(parsed, args); +} diff --git a/packages/cursorless-engine/src/customCommandGrammar/parseCommand.ts b/packages/cursorless-engine/src/customCommandGrammar/parseCommand.ts new file mode 100644 index 0000000000..f4c1220210 --- /dev/null +++ b/packages/cursorless-engine/src/customCommandGrammar/parseCommand.ts @@ -0,0 +1,53 @@ +import { ScopeType, type ActionDescriptor } from "@cursorless/common"; +import { Grammar, Parser } from "nearley"; +import { WithPlaceholders } from "./WithPlaceholders"; +import grammar from "./generated/grammar"; + +function getScopeTypeParser(): Parser { + return new Parser( + // eslint-disable-next-line @typescript-eslint/naming-convention + Grammar.fromCompiled({ ...grammar, ParserStart: "scopeType" }), + ); +} + +function getActionParser(): Parser { + return new Parser(Grammar.fromCompiled(grammar)); +} + +/** + * Given a textual representation of a scope type, parse it into a scope type. + * + * @param input A textual representation of a scope type + * @returns A parsed scope type + */ +export function parseScopeType(input: string): ScopeType { + const parser = getScopeTypeParser(); + parser.feed(input); + + if (parser.results.length !== 1) { + throw new Error( + `Expected exactly one result, got ${parser.results.length}`, + ); + } + + return parser.results[0] as ScopeType; +} + +/** + * Given a textual representation of a action, parse it into an action descriptor. + * + * @param input A textual representation of a action + * @returns A parsed action descriptor + */ +export function parseAction(input: string): WithPlaceholders { + const parser = getActionParser(); + parser.feed(input); + + if (parser.results.length !== 1) { + throw new Error( + `Expected exactly one result, got ${parser.results.length}`, + ); + } + + return parser.results[0] as WithPlaceholders; +} diff --git a/packages/cursorless-engine/src/customCommandGrammar/parseScopeType.ts b/packages/cursorless-engine/src/customCommandGrammar/parseScopeType.ts deleted file mode 100644 index 6fafc291f8..0000000000 --- a/packages/cursorless-engine/src/customCommandGrammar/parseScopeType.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Parser, Grammar } from "nearley"; -import grammar from "./generated/grammar"; -import { ScopeType } from "@cursorless/common"; - -function getScopeTypeParser(): Parser { - return new Parser( - // eslint-disable-next-line @typescript-eslint/naming-convention - Grammar.fromCompiled({ ...grammar, ParserStart: "scopeType" }), - ); -} - -/** - * Given a textual representation of a scope type, parse it into a scope type. - * - * @param input A textual representation of a scope type - * @returns A parsed scope type - */ -export function parseScopeType(input: string): ScopeType { - const parser = getScopeTypeParser(); - parser.feed(input); - - if (parser.results.length !== 1) { - throw new Error( - `Expected exactly one result, got ${parser.results.length}`, - ); - } - - return parser.results[0] as ScopeType; -} diff --git a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/actions.ts b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/actions.ts index 04a72fd1d0..97fcfaf8f4 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/actions.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/actions.ts @@ -66,6 +66,7 @@ export const actions = { executeCommand: null, getText: null, replace: null, + parsed: null, ["private.getTargets"]: null, ["private.setKeyboardTarget"]: null, diff --git a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/marks.ts b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/marks.ts index bebec62330..10b56e0d37 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/marks.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/marks.ts @@ -40,6 +40,7 @@ export const marks = { decoratedSymbol: null, lineNumber: null, range: null, + target: null, } as const satisfies Record; export const lineDirections = { diff --git a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts index 51a2dac566..1601e6ea41 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/generateSpokenForm.ts @@ -103,6 +103,7 @@ export class SpokenFormGenerator { case "getText": case "replace": case "executeCommand": + case "parsed": case "private.getTargets": case "private.setKeyboardTarget": throw new NoSpokenFormError(`Action '${action.name}'`); diff --git a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts index f9695c8399..e05a180c0f 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/primitiveTargetToSpokenForm.ts @@ -315,6 +315,7 @@ export class PrimitiveTargetSpokenFormGenerator { } case "explicit": case "keyboard": + case "target": throw new NoSpokenFormError(`Mark '${mark.type}'`); default: diff --git a/packages/cursorless-engine/src/processTargets/marks/RangeMarkStage.ts b/packages/cursorless-engine/src/processTargets/marks/RangeMarkStage.ts index 4758b045cc..fb62012e49 100644 --- a/packages/cursorless-engine/src/processTargets/marks/RangeMarkStage.ts +++ b/packages/cursorless-engine/src/processTargets/marks/RangeMarkStage.ts @@ -1,4 +1,4 @@ -import { RangeMark } from "@cursorless/common"; +import { RangeMark } from "../../typings/TargetDescriptor"; import { Target } from "../../typings/target.types"; import { MarkStageFactory } from "../MarkStageFactory"; import { MarkStage } from "../PipelineStages.types"; diff --git a/packages/cursorless-engine/src/test/fixtures/talonApi.fixture.ts b/packages/cursorless-engine/src/test/fixtures/talonApi.fixture.ts index 05e2c5cfaf..d2493b23af 100644 --- a/packages/cursorless-engine/src/test/fixtures/talonApi.fixture.ts +++ b/packages/cursorless-engine/src/test/fixtures/talonApi.fixture.ts @@ -140,6 +140,61 @@ function getTextAction(options: GetTextActionOptions): ActionDescriptor { }; } +const parsedActionNoTargets: ActionDescriptor = { + name: "parsed", + content: "chuck block", + arguments: [], +}; +const parsedActionAir: ActionDescriptor = { + name: "parsed", + content: "chuck block ", + arguments: [ + { + type: "list", + elements: [ + { + type: "primitive", + mark: { + type: "decoratedSymbol", + symbolColor: "default", + character: "a", + }, + }, + { + type: "primitive", + mark: { + type: "decoratedSymbol", + symbolColor: "default", + character: "b", + }, + }, + ], + }, + ], +}; +const parsedActionAirPlusBat: ActionDescriptor = { + name: "parsed", + content: "bring block after ", + arguments: [ + { + type: "primitive", + mark: { + type: "decoratedSymbol", + symbolColor: "default", + character: "a", + }, + }, + { + type: "primitive", + mark: { + type: "decoratedSymbol", + symbolColor: "default", + character: "b", + }, + }, + ], +}; + /** * These test our Talon api using dummy spoken forms defined in * cursorless-talon-dev/src/cursorless_test.talon @@ -191,6 +246,9 @@ export const talonApiFixture = [ "test api alternate highlight nothing", alternateHighlightNothingAction, ), + spokenFormTest("test api parsed", parsedActionNoTargets), + spokenFormTest("test api parsed air and bat", parsedActionAir), + spokenFormTest("test api parsed air plus bat", parsedActionAirPlusBat), ]; function decoratedPrimitiveTarget( diff --git a/packages/cursorless-engine/src/testUtil/extractTargetKeys.ts b/packages/cursorless-engine/src/testUtil/extractTargetKeys.ts index 4f33e98be4..0717578ae6 100644 --- a/packages/cursorless-engine/src/testUtil/extractTargetKeys.ts +++ b/packages/cursorless-engine/src/testUtil/extractTargetKeys.ts @@ -1,9 +1,10 @@ import { + PartialMark, PartialPrimitiveTargetDescriptor, PartialTargetDescriptor, getKey, } from "@cursorless/common"; -import { PrimitiveTargetDescriptor } from "../typings/TargetDescriptor"; +import { Mark, PrimitiveTargetDescriptor } from "../typings/TargetDescriptor"; export function extractTargetKeys(target: PartialTargetDescriptor): string[] { switch (target.type) { @@ -27,12 +28,20 @@ export function extractTargetKeys(target: PartialTargetDescriptor): string[] { function extractPrimitiveTargetKeys( ...targets: (PrimitiveTargetDescriptor | PartialPrimitiveTargetDescriptor)[] ) { - const keys: string[] = []; - targets.forEach((target) => { - if (target.mark?.type === "decoratedSymbol") { - const { character, symbolColor } = target.mark; - keys.push(getKey(symbolColor, character)); - } - }); - return keys; + return targets.flatMap((target) => + target.mark == null ? [] : extractMarkKeys(target.mark), + ); +} + +function extractMarkKeys(mark: PartialMark | Mark): string[] { + switch (mark.type) { + case "range": + return [...extractMarkKeys(mark.anchor), ...extractMarkKeys(mark.active)]; + case "target": + return extractTargetKeys(mark.target); + case "decoratedSymbol": + return [getKey(mark.symbolColor, mark.character)]; + default: + return []; + } } diff --git a/packages/cursorless-engine/src/typings/TargetDescriptor.ts b/packages/cursorless-engine/src/typings/TargetDescriptor.ts index bcf96035fa..f11a70b9b9 100644 --- a/packages/cursorless-engine/src/typings/TargetDescriptor.ts +++ b/packages/cursorless-engine/src/typings/TargetDescriptor.ts @@ -2,11 +2,19 @@ import { ImplicitTargetDescriptor, Modifier, PartialMark, + PartialRangeMark, PartialRangeType, + PartialTargetMark, + RangeMarkFor, ScopeType, } from "@cursorless/common"; -export type Mark = PartialMark | TargetMark; +export type Mark = + | Exclude + | TargetMark + | RangeMark; + +export type RangeMark = RangeMarkFor; export interface PrimitiveTargetDescriptor { type: "primitive"; diff --git a/packages/cursorless-engine/src/util/getPartialTargetDescriptors.ts b/packages/cursorless-engine/src/util/getPartialTargetDescriptors.ts index 0b088c3aa5..0b5697305a 100644 --- a/packages/cursorless-engine/src/util/getPartialTargetDescriptors.ts +++ b/packages/cursorless-engine/src/util/getPartialTargetDescriptors.ts @@ -3,6 +3,7 @@ import { DestinationDescriptor, PartialTargetDescriptor, } from "@cursorless/common"; +import { parseAndFillOutAction } from "../customCommandGrammar/parseAndFillOutAction"; export function getPartialTargetDescriptors( action: ActionDescriptor, @@ -23,6 +24,10 @@ export function getPartialTargetDescriptors( case "replace": case "editNew": return getPartialTargetDescriptorsFromDestination(action.destination); + case "parsed": + return getPartialTargetDescriptors( + parseAndFillOutAction(action.content, action.arguments), + ); default: return [action.target]; } diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts index c1b85fc7a8..a0e68d6694 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts @@ -193,6 +193,7 @@ export default class KeyboardCommandsTargeted { case "replace": case "editNew": case "getText": + case "parsed": throw Error(`Unsupported keyboard action: ${name}`); case "replaceWithTarget": case "moveToTarget":