diff --git a/images/squareRepackHarp.gif b/images/squareRepackHarp.gif new file mode 100644 index 0000000000..dbc5cec573 Binary files /dev/null and b/images/squareRepackHarp.gif differ diff --git a/src/actions/Rewrap.ts b/src/actions/Rewrap.ts new file mode 100644 index 0000000000..475fa471f9 --- /dev/null +++ b/src/actions/Rewrap.ts @@ -0,0 +1,65 @@ +import { TextEditor } from "vscode"; +import { + Action, + ActionPreferences, + ActionReturnValue, + Graph, + SelectionWithContext, + TypedSelection, +} from "../typings/Types"; +import { repeat } from "../util/array"; + +export default class Rewrap implements Action { + getTargetPreferences: () => ActionPreferences[] = () => [ + { + insideOutsideType: "inside", + modifier: { + type: "surroundingPair", + delimiter: "any", + delimiterInclusion: undefined, + }, + }, + ]; + + constructor(private graph: Graph) { + this.run = this.run.bind(this); + } + + run( + [targets]: [TypedSelection[]], + left: string, + right: string + ): Promise { + const boundaries: TypedSelection[] = targets.flatMap((target) => { + const boundary = target.selectionContext.boundary; + + if (boundary == null || boundary.length !== 2) { + throw Error("Target must have an opening and closing delimiter"); + } + + return boundary.map((edge) => + constructSimpleTypedSelection(target.selection.editor, edge) + ); + }); + + const replacementTexts = repeat([left, right], targets.length); + + return this.graph.actions.replace.run([boundaries], replacementTexts); + } +} + +function constructSimpleTypedSelection( + editor: TextEditor, + selection: SelectionWithContext +): TypedSelection { + return { + selection: { + selection: selection.selection, + editor, + }, + selectionType: "token", + selectionContext: selection.context, + insideOutsideType: null, + position: "contents", + }; +} diff --git a/src/actions/index.ts b/src/actions/index.ts index 77e0a969dd..7ae79a593a 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -29,6 +29,7 @@ import { Sort, Reverse } from "./Sort"; import Call from "./Call"; import WrapWithSnippet from "./WrapWithSnippet"; import Deselect from "./Deselect"; +import Rewrap from "./Rewrap"; class Actions implements ActionRecord { constructor(private graph: Graph) {} @@ -57,6 +58,7 @@ class Actions implements ActionRecord { replace = new Replace(this.graph); replaceWithTarget = new Bring(this.graph); reverseTargets = new Reverse(this.graph); + rewrapWithPairedDelimiter = new Rewrap(this.graph); scrollToBottom = new ScrollToBottom(this.graph); scrollToCenter = new ScrollToCenter(this.graph); scrollToTop = new ScrollToTop(this.graph); diff --git a/src/processTargets/modifiers/surroundingPair/extractSelectionFromSurroundingPairOffsets.ts b/src/processTargets/modifiers/surroundingPair/extractSelectionFromSurroundingPairOffsets.ts index 65f655eeb7..236e0d8c96 100644 --- a/src/processTargets/modifiers/surroundingPair/extractSelectionFromSurroundingPairOffsets.ts +++ b/src/processTargets/modifiers/surroundingPair/extractSelectionFromSurroundingPairOffsets.ts @@ -20,6 +20,45 @@ export function extractSelectionFromSurroundingPairOffsets( surroundingPairOffsets: SurroundingPairOffsets, delimiterInclusion: DelimiterInclusion ): SelectionWithContext[] { + const interior = [ + { + selection: new Selection( + document.positionAt( + baseOffset + surroundingPairOffsets.leftDelimiter.end + ), + document.positionAt( + baseOffset + surroundingPairOffsets.rightDelimiter.start + ) + ), + context: {}, + }, + ]; + + const boundary = [ + { + selection: new Selection( + document.positionAt( + baseOffset + surroundingPairOffsets.leftDelimiter.start + ), + document.positionAt( + baseOffset + surroundingPairOffsets.leftDelimiter.end + ) + ), + context: {}, + }, + { + selection: new Selection( + document.positionAt( + baseOffset + surroundingPairOffsets.rightDelimiter.start + ), + document.positionAt( + baseOffset + surroundingPairOffsets.rightDelimiter.end + ) + ), + context: {}, + }, + ]; + // If delimiter inclusion is null, do default behavior and include the // delimiters if (delimiterInclusion == null) { @@ -33,50 +72,18 @@ export function extractSelectionFromSurroundingPairOffsets( baseOffset + surroundingPairOffsets.rightDelimiter.end ) ), - context: {}, + context: { + boundary, + interior, + }, }, ]; } switch (delimiterInclusion) { case "interiorOnly": - return [ - { - selection: new Selection( - document.positionAt( - baseOffset + surroundingPairOffsets.leftDelimiter.end - ), - document.positionAt( - baseOffset + surroundingPairOffsets.rightDelimiter.start - ) - ), - context: {}, - }, - ]; + return interior; case "excludeInterior": - return [ - { - selection: new Selection( - document.positionAt( - baseOffset + surroundingPairOffsets.leftDelimiter.start - ), - document.positionAt( - baseOffset + surroundingPairOffsets.leftDelimiter.end - ) - ), - context: {}, - }, - { - selection: new Selection( - document.positionAt( - baseOffset + surroundingPairOffsets.rightDelimiter.start - ), - document.positionAt( - baseOffset + surroundingPairOffsets.rightDelimiter.end - ) - ), - context: {}, - }, - ]; + return boundary; } } diff --git a/src/processTargets/processSelectionType.ts b/src/processTargets/processSelectionType.ts index 59574b4d55..a58e394ae7 100644 --- a/src/processTargets/processSelectionType.ts +++ b/src/processTargets/processSelectionType.ts @@ -226,11 +226,11 @@ function getTokenSelectionContext( } return { + ...selectionContext, isInDelimitedList, containingListDelimiter: " ", leadingDelimiterRange: isInDelimitedList ? leadingDelimiterRange : null, trailingDelimiterRange: isInDelimitedList ? trailingDelimiterRange : null, - outerSelection: selectionContext.outerSelection, }; } diff --git a/src/test/suite/fixtures/recorded/actions/curlyRepackRound.yml b/src/test/suite/fixtures/recorded/actions/curlyRepackRound.yml new file mode 100644 index 0000000000..79f8a5c968 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/curlyRepackRound.yml @@ -0,0 +1,38 @@ +languageId: plaintext +command: + version: 1 + spokenForm: curly repack round + action: rewrapWithPairedDelimiter + targets: + - type: primitive + modifier: {type: surroundingPair, delimiter: parentheses} + extraArgs: ['{', '}'] +initialState: + documentContents: |- + ([hello]) + (there) + selections: + - anchor: {line: 0, character: 5} + active: {line: 0, character: 5} + - anchor: {line: 1, character: 5} + active: {line: 1, character: 5} + marks: {} +finalState: + documentContents: |- + {[hello]} + {there} + selections: + - anchor: {line: 0, character: 5} + active: {line: 0, character: 5} + - anchor: {line: 1, character: 5} + active: {line: 1, character: 5} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 1} + - anchor: {line: 0, character: 8} + active: {line: 0, character: 9} + - anchor: {line: 1, character: 0} + active: {line: 1, character: 1} + - anchor: {line: 1, character: 6} + active: {line: 1, character: 7} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: surroundingPair, delimiter: parentheses}}] diff --git a/src/test/suite/fixtures/recorded/actions/squareRepackHarp.yml b/src/test/suite/fixtures/recorded/actions/squareRepackHarp.yml new file mode 100644 index 0000000000..db35d3a243 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/squareRepackHarp.yml @@ -0,0 +1,31 @@ +languageId: plaintext +command: + version: 1 + spokenForm: square repack harp + action: rewrapWithPairedDelimiter + targets: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: h} + extraArgs: ['[', ']'] +initialState: + documentContents: | + (hello) + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.h: + start: {line: 0, character: 1} + end: {line: 0, character: 6} +finalState: + documentContents: | + [hello] + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 1} + - anchor: {line: 0, character: 6} + active: {line: 0, character: 7} +fullTargets: [{type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: h}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: surroundingPair, delimiter: any}}] diff --git a/src/test/suite/fixtures/recorded/actions/squareRepackLeper.yml b/src/test/suite/fixtures/recorded/actions/squareRepackLeper.yml new file mode 100644 index 0000000000..c31058814b --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/squareRepackLeper.yml @@ -0,0 +1,31 @@ +languageId: plaintext +command: + version: 1 + spokenForm: square repack leper + action: rewrapWithPairedDelimiter + targets: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: (} + extraArgs: ['[', ']'] +initialState: + documentContents: | + (hello) + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.(: + start: {line: 0, character: 0} + end: {line: 0, character: 1} +finalState: + documentContents: | + [hello] + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 1} + - anchor: {line: 0, character: 6} + active: {line: 0, character: 7} +fullTargets: [{type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: (}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: surroundingPair, delimiter: any}}] diff --git a/src/test/suite/fixtures/recorded/actions/squareRepackPair.yml b/src/test/suite/fixtures/recorded/actions/squareRepackPair.yml new file mode 100644 index 0000000000..4240ef6046 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/squareRepackPair.yml @@ -0,0 +1,28 @@ +languageId: plaintext +command: + version: 1 + spokenForm: square repack pair + action: rewrapWithPairedDelimiter + targets: + - type: primitive + modifier: {type: surroundingPair, delimiter: any} + extraArgs: ['[', ']'] +initialState: + documentContents: | + (hello) + selections: + - anchor: {line: 0, character: 2} + active: {line: 0, character: 2} + marks: {} +finalState: + documentContents: | + [hello] + selections: + - anchor: {line: 0, character: 2} + active: {line: 0, character: 2} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 1} + - anchor: {line: 0, character: 6} + active: {line: 0, character: 7} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: surroundingPair, delimiter: any}}] diff --git a/src/test/suite/fixtures/recorded/actions/squareRepackThis.yml b/src/test/suite/fixtures/recorded/actions/squareRepackThis.yml new file mode 100644 index 0000000000..d01e156217 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/squareRepackThis.yml @@ -0,0 +1,26 @@ +languageId: plaintext +command: + version: 1 + spokenForm: square repack this + action: rewrapWithPairedDelimiter + targets: + - type: primitive + mark: {type: cursor} + extraArgs: ['[', ']'] +initialState: + documentContents: (hello) + selections: + - anchor: {line: 0, character: 4} + active: {line: 0, character: 4} + marks: {} +finalState: + documentContents: "[hello]" + selections: + - anchor: {line: 0, character: 4} + active: {line: 0, character: 4} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 1} + - anchor: {line: 0, character: 6} + active: {line: 0, character: 7} +fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: surroundingPair, delimiter: any}}] diff --git a/src/test/suite/fixtures/recorded/surroundingPair/textual/chuckPairHarp.yml b/src/test/suite/fixtures/recorded/surroundingPair/textual/chuckPairHarp.yml new file mode 100644 index 0000000000..4f8d8c8885 --- /dev/null +++ b/src/test/suite/fixtures/recorded/surroundingPair/textual/chuckPairHarp.yml @@ -0,0 +1,29 @@ +languageId: plaintext +command: + version: 1 + spokenForm: chuck pair harp + action: remove + targets: + - type: primitive + modifier: {type: surroundingPair, delimiter: any} + mark: {type: decoratedSymbol, symbolColor: default, character: h} +initialState: + documentContents: | + (hello) (there) + selections: + - anchor: {line: 0, character: 13} + active: {line: 0, character: 13} + marks: + default.h: + start: {line: 0, character: 1} + end: {line: 0, character: 6} +finalState: + documentContents: | + (there) + selections: + - anchor: {line: 0, character: 5} + active: {line: 0, character: 5} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} +fullTargets: [{type: primitive, mark: {type: decoratedSymbol, symbolColor: default, character: h}, selectionType: token, position: contents, insideOutsideType: outside, modifier: {type: surroundingPair, delimiter: any}}] diff --git a/src/typings/Types.ts b/src/typings/Types.ts index deea857001..f53a9833ad 100644 --- a/src/typings/Types.ts +++ b/src/typings/Types.ts @@ -265,6 +265,20 @@ export interface SelectionContext { trailingDelimiterRange?: vscode.Range | null; isNotebookCell?: boolean; + + /** + * Represents the boundary ranges of this selection. For example, for a + * surrounding pair this would be the opening and closing delimiter. For an if + * statement this would be the line of the guard as well as the closing brace. + */ + boundary?: SelectionWithContext[]; + + /** + * Represents the interior ranges of this selection. For example, for a + * surrounding pair this would exclude the opening and closing delimiter. For an if + * statement this would be the statements in the body. + */ + interior?: SelectionWithContext[]; } export interface TypedSelection { @@ -340,6 +354,7 @@ export type ActionType = | "replace" | "replaceWithTarget" | "reverseTargets" + | "rewrapWithPairedDelimiter" | "scrollToBottom" | "scrollToCenter" | "scrollToTop" diff --git a/src/util/array.ts b/src/util/array.ts new file mode 100644 index 0000000000..138b611772 --- /dev/null +++ b/src/util/array.ts @@ -0,0 +1,11 @@ +import { range } from "lodash"; + +/** + * Creates a new array repeating the given array n times + * @param array The array to repeat + * @param n The number of times to repeat the array + * @returns The new array + */ +export function repeat(array: T[], n: number) { + return range(n).flatMap(() => array); +}