diff --git a/src/cmd_line/commands/normal.ts b/src/cmd_line/commands/normal.ts new file mode 100644 index 00000000000..8638bb536e7 --- /dev/null +++ b/src/cmd_line/commands/normal.ts @@ -0,0 +1,32 @@ +import { Parser, all, whitespace } from 'parsimmon'; +import { VimState } from '../../state/vimState'; +import { ExCommand } from '../../vimscript/exCommand'; +import { LineRange } from '../../vimscript/lineRange'; + +export class NormalCommand extends ExCommand { + // TODO: support to parse `:normal!` + public static readonly argParser: Parser = whitespace + .then(all) + .map((keystrokes) => new NormalCommand(keystrokes)); + + private readonly keystrokes: string; + constructor(argument: string) { + super(); + this.keystrokes = argument; + } + + override async execute(vimState: VimState): Promise { + vimState.recordedState.transformer.addTransformation({ + type: 'executeNormal', + keystrokes: this.keystrokes, + }); + } + + override async executeWithRange(vimState: VimState, lineRange: LineRange): Promise { + vimState.recordedState.transformer.addTransformation({ + type: 'executeNormal', + keystrokes: this.keystrokes, + range: lineRange, + }); + } +} diff --git a/src/mode/mode.ts b/src/mode/mode.ts index 1b756766f92..b4c491fd888 100644 --- a/src/mode/mode.ts +++ b/src/mode/mode.ts @@ -27,6 +27,12 @@ export enum VSCodeVimCursorType { UnderlineThin, } +export enum NormalCommandState { + Waiting, + Executing, + Finished, +} + export enum DotCommandStatus { Waiting, Executing, diff --git a/src/mode/modeHandler.ts b/src/mode/modeHandler.ts index 86f8f6893b0..24a7e5e76b2 100644 --- a/src/mode/modeHandler.ts +++ b/src/mode/modeHandler.ts @@ -51,6 +51,7 @@ import { TextEditor } from './../textEditor'; import { DotCommandStatus, Mode, + NormalCommandState, ReplayMode, VSCodeVimCursorType, getCursorStyle, @@ -789,6 +790,10 @@ export class ModeHandler implements vscode.Disposable, IModeHandler { if (action.createsUndoPoint) { ranRepeatableAction = true; } + + if (this.vimState.normalCommandState === NormalCommandState.Finished) { + ranRepeatableAction = true; + } } else if (action instanceof BaseOperator) { recordedState.operatorCount = recordedState.count; } else { @@ -806,7 +811,11 @@ export class ModeHandler implements vscode.Disposable, IModeHandler { this.vimState.currentMode === Mode.Normal && prevMode !== Mode.SearchInProgressMode && prevMode !== Mode.EasyMotionInputMode && - prevMode !== Mode.EasyMotionMode + prevMode !== Mode.EasyMotionMode && + !( + prevMode === Mode.CommandlineInProgress && + this.vimState.normalCommandState === NormalCommandState.Executing + ) ) { ranRepeatableAction = true; } @@ -929,12 +938,17 @@ export class ModeHandler implements vscode.Disposable, IModeHandler { if ( ranRepeatableAction && !this.vimState.isReplayingMacro && + this.vimState.normalCommandState !== NormalCommandState.Executing && this.vimState.dotCommandStatus !== DotCommandStatus.Executing && !this.remapState.isCurrentlyPerformingRemapping ) { this.vimState.historyTracker.finishCurrentStep(); } + if (this.vimState.normalCommandState === NormalCommandState.Finished) { + this.vimState.normalCommandState = NormalCommandState.Waiting; + } + recordedState.actionKeys = []; this.vimState.currentRegisterMode = undefined; diff --git a/src/state/vimState.ts b/src/state/vimState.ts index ffcd752a647..429d336bb90 100644 --- a/src/state/vimState.ts +++ b/src/state/vimState.ts @@ -8,7 +8,7 @@ import { SurroundState } from '../actions/plugins/surround'; import { ExCommandLine, SearchCommandLine } from '../cmd_line/commandLine'; import { Cursor } from '../common/motion/cursor'; import { configuration } from '../configuration/configuration'; -import { DotCommandStatus, Mode } from '../mode/mode'; +import { DotCommandStatus, Mode, NormalCommandState } from '../mode/mode'; import { ModeData } from '../mode/modeData'; import { Logger } from '../util/logger'; import { SearchDirection } from '../vimscript/pattern'; @@ -104,6 +104,7 @@ export class VimState implements vscode.Disposable { public dotCommandStatus: DotCommandStatus = DotCommandStatus.Waiting; public isReplayingMacro: boolean = false; + public normalCommandState: NormalCommandState = NormalCommandState.Waiting; /** * The last visual selection before running the dot command diff --git a/src/transformations/execute.ts b/src/transformations/execute.ts index f6cda625c86..e3907927380 100644 --- a/src/transformations/execute.ts +++ b/src/transformations/execute.ts @@ -3,16 +3,20 @@ import { ExCommandLine } from '../cmd_line/commandLine'; import { Cursor } from '../common/motion/cursor'; import { PositionDiff } from '../common/motion/position'; import { Globals } from '../globals'; -import { Mode } from '../mode/mode'; +import { Mode, NormalCommandState } from '../mode/mode'; import { Register } from '../register/register'; import { globalState } from '../state/globalState'; import { RecordedState } from '../state/recordedState'; import { VimState } from '../state/vimState'; import { TextEditor } from '../textEditor'; import { Logger } from '../util/logger'; -import { keystrokesExpressionParser } from '../vimscript/expression'; +import { + keystrokesExpressionForMacroParser, + keystrokesExpressionParser, +} from '../vimscript/expression'; import { Dot, + ExecuteNormalTransformation, InsertTextVSCodeTransformation, TextTransformations, Transformation, @@ -29,6 +33,7 @@ export interface IModeHandler { updateView(args?: { drawSelection: boolean; revealRange: boolean }): Promise; runMacro(recordedMacro: RecordedState): Promise; handleMultipleKeyEvents(keys: string[]): Promise; + handleKeyEvent(key: string): Promise; rerunRecordedState(transformation: Dot): Promise; } @@ -160,7 +165,7 @@ export async function executeTransformations( return; } else if (typeof recordedMacro === 'string') { // A string was set to the register. We need to execute the characters as if they were typed (in normal mode). - const keystrokes = keystrokesExpressionParser.parse(recordedMacro); + const keystrokes = keystrokesExpressionForMacroParser.parse(recordedMacro); if (!keystrokes.status) { throw new Error(`Failed to execute macro: ${recordedMacro}`); } @@ -233,6 +238,10 @@ export async function executeTransformations( vimState.editor.selection = new vscode.Selection(newPos, newPos); break; + case 'executeNormal': + await doExecuteNormal(modeHandler, transformation); + break; + case 'vscodeCommand': // eslint-disable-next-line @typescript-eslint/no-unsafe-argument await vscode.commands.executeCommand(transformation.command, ...transformation.args); @@ -292,3 +301,47 @@ export async function executeTransformations( vimState.recordedState.transformer = new Transformer(); } + +const doExecuteNormal = async ( + modeHandler: IModeHandler, + transformation: ExecuteNormalTransformation, +) => { + const vimState = modeHandler.vimState; + const { keystrokes, range } = transformation; + const strokes = keystrokesExpressionParser.parse(keystrokes); + if (!strokes.status) { + throw new Error(`Failed to execute normal command: ${keystrokes}`); + } + + const resultLineNumbers: number[] = []; + if (range) { + const { start: startLineNumber, end: endLineNumber } = range.resolve(vimState); + for (let i = startLineNumber; i <= endLineNumber; i++) { + resultLineNumbers.push(i); + } + } else { + const selectionList = vimState.editor.selections; + for (const selection of selectionList) { + const { start, end } = selection; + + for (let i = start.line; i <= end.line; i++) { + resultLineNumbers.push(i); + } + } + } + + vimState.normalCommandState = NormalCommandState.Executing; + vimState.recordedState = new RecordedState(); + await vimState.setCurrentMode(Mode.Normal); + for (const lineNumber of resultLineNumbers) { + if (range) { + vimState.cursorStopPosition = vimState.cursorStartPosition = + TextEditor.getFirstNonWhitespaceCharOnLine(vimState.document, lineNumber); + } + await modeHandler.handleMultipleKeyEvents(strokes.value); + if (vimState.currentMode === Mode.Insert) { + await modeHandler.handleKeyEvent(''); + } + } + vimState.normalCommandState = NormalCommandState.Finished; +}; diff --git a/src/transformations/transformations.ts b/src/transformations/transformations.ts index a7415695f77..558601ea326 100644 --- a/src/transformations/transformations.ts +++ b/src/transformations/transformations.ts @@ -1,5 +1,6 @@ import { Position, Range, TextDocumentContentChangeEvent } from 'vscode'; import { RecordedState } from '../state/recordedState'; +import { LineRange } from '../vimscript/lineRange'; import { PositionDiff } from './../common/motion/position'; /** @@ -186,6 +187,12 @@ export interface ContentChangeTransformation { diff: PositionDiff; } +export interface ExecuteNormalTransformation { + type: 'executeNormal'; + keystrokes: string; + range?: LineRange; +} + export type Transformation = | InsertTextTransformation | InsertTextVSCodeTransformation @@ -195,6 +202,7 @@ export type Transformation = | Dot | Macro | ContentChangeTransformation + | ExecuteNormalTransformation | VSCodeCommandTransformation; /** diff --git a/src/vimscript/exCommandParser.ts b/src/vimscript/exCommandParser.ts index f1be690d33a..293535d6461 100644 --- a/src/vimscript/exCommandParser.ts +++ b/src/vimscript/exCommandParser.ts @@ -19,6 +19,7 @@ import { DeleteMarksCommand, MarksCommand } from '../cmd_line/commands/marks'; import { ExploreCommand } from '../cmd_line/commands/explore'; import { MoveCommand } from '../cmd_line/commands/move'; import { NohlCommand } from '../cmd_line/commands/nohl'; +import { NormalCommand } from '../cmd_line/commands/normal'; import { OnlyCommand } from '../cmd_line/commands/only'; import { PrintCommand } from '../cmd_line/commands/print'; import { PutExCommand } from '../cmd_line/commands/put'; @@ -373,7 +374,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['noh', 'lsearch'], succeed(new NohlCommand())], [['norea', 'bbrev'], undefined], [['noreme', 'nu'], undefined], - [['norm', 'al'], undefined], + [['norm', 'al'], NormalCommand.argParser], [['nos', 'wapfile'], undefined], [['nu', 'mber'], PrintCommand.argParser({ printNumbers: true, printText: true })], [['nun', 'map'], undefined], diff --git a/src/vimscript/expression.ts b/src/vimscript/expression.ts index dcff8259f3c..bc254896ee7 100644 --- a/src/vimscript/expression.ts +++ b/src/vimscript/expression.ts @@ -22,6 +22,12 @@ const escapedParser = string('\\') }); export const keystrokesExpressionParser: Parser = alt( + escapedParser, + specialCharacterParser, + any, +).many(); + +export const keystrokesExpressionForMacroParser: Parser = alt( escapedParser, specialCharacterParser, noneOf('"'), diff --git a/test/normalCommand.test.ts b/test/normalCommand.test.ts new file mode 100644 index 00000000000..aef5184c81b --- /dev/null +++ b/test/normalCommand.test.ts @@ -0,0 +1,93 @@ +import { newTest, newTestSkip } from './testSimplifier'; +import { cleanUpWorkspace, setupWorkspace } from './testUtils'; + +suite('Execute normal command', () => { + setup(setupWorkspace); + + teardown(cleanUpWorkspace); + + newTest({ + title: 'One liner', + start: ['foo =| bar = 1'], + keysPressed: ':normal f=i!=\n', + end: ['foo = bar !|== 1'], + }); + + newTest({ + title: 'One liner with selection', + start: ['foo =| bar = 1'], + keysPressed: 'V:normal f=i!=\n', + end: ['foo !|== bar = 1'], + }); + + newTest({ + title: 'Multiple liner with selection', + start: ['foo =| bar = 1', 'foo = bar = 2'], + keysPressed: 'Vj:normal f=i!=\n', + end: ['foo !== bar = 1', 'foo !|== bar = 2'], + }); + + newTest({ + title: 'Multiple liner with line range', + start: ['foo =| bar = 1', 'foo = bar = 2', 'foo = bar = 3'], + keysPressed: ':2,3normal f=i!=\n', + end: ['foo = bar = 1', 'foo !== bar = 2', 'foo !|== bar = 3'], + }); + + newTest({ + title: 'One liner with dot', + start: ['foo =| bar = 1', 'foo = bar = 2'], + keysPressed: 'f=i!=j^f=:normal .\n', + end: ['foo = bar !== 1', 'foo !|== bar = 2'], + }); + + newTest({ + title: 'One liner with multiple dot', + start: ['foo =| bar = 1', 'foo = bar = 2'], + keysPressed: 'f=i!=j^f=:normal 2.\n', + end: ['foo = bar !== 1', 'foo !=!|== bar = 2'], + }); + + newTest({ + title: 'One liner with macro', + start: ['|1. one, 2. two, 3. three, 4. four'], + keysPressed: 'qaf.r)q:normal @a\n', + end: ['1) one, 2|) two, 3. three, 4. four'], + }); + + newTest({ + title: 'One liner with multiple macro', + start: ['|1. one, 2. two, 3. three, 4. four'], + keysPressed: 'qaf.r)q:normal 3@a\n', + end: ['1) one, 2) two, 3) three, 4|) four'], + }); + + newTest({ + title: 'Multiple liner with multiple macro', + start: ['|0. zero', '1. one, 2. two, 3. three, 4. four', '5. five, 6. six, 7. seven, 8. eight'], + keysPressed: 'qaf.r)qjVj:normal 4@a\n', + end: ['0) zero', '1) one, 2) two, 3) three, 4) four', '5) five, 6) six, 7) seven, 8|) eight'], + }); + + newTest({ + title: 'Incomplete operation', + start: ['foo =| bar = 1', 'foo = bar = 2'], + keysPressed: ':normal ddd\n', + end: ['|foo = bar = 2'], + }); + + // TODO: implement to stop when operation fails + newTestSkip({ + title: 'Operation stops after command fails', + start: ['foo =| bar = 1', 'foo = bar = 2'], + keysPressed: ':normal llllllllllllllllllllllllllllll j\n', + end: ['foo = bar = |1', 'foo = bar = 2'], + }); + + newTest({ + title: 'Multiple liner with selection and undo', + start: ['foo =| bar = 1', 'foo = bar = 2'], + keysPressed: 'Vj:normal f=i!=\nu', + end: ['foo |= bar = 1', 'foo = bar = 2'], + }); +});