diff --git a/src/actions/base.ts b/src/actions/base.ts index 7bd554204cfb..7d5cfaf54694 100644 --- a/src/actions/base.ts +++ b/src/actions/base.ts @@ -132,10 +132,7 @@ export class BaseAction { if (!compareKeypressSequence(this.keys, keysPressed)) { return false; } - if ( - this.mustBeFirstKey && - vimState.recordedState.numberOfKeysInCommandWithoutCountPrefix - keysPressed.length > 0 - ) { + if (this.mustBeFirstKey && vimState.recordedState.isPendingCommandPrefixedWithCount) { return false; } @@ -156,10 +153,7 @@ export class BaseAction { return false; } - if ( - this.mustBeFirstKey && - vimState.recordedState.numberOfKeysInCommandWithoutCountPrefix - keysPressed.length > 0 - ) { + if (this.mustBeFirstKey && vimState.recordedState.isPendingCommandPrefixedWithCount) { return false; } diff --git a/src/actions/operator.ts b/src/actions/operator.ts index e76f7922addd..f92609c1a1e7 100644 --- a/src/actions/operator.ts +++ b/src/actions/operator.ts @@ -35,10 +35,7 @@ export class BaseOperator extends BaseAction { if (!compareKeypressSequence(this.keys, keysPressed)) { return false; } - if ( - this.mustBeFirstKey && - vimState.recordedState.numberOfKeysInCommandWithoutCountPrefix - keysPressed.length > 0 - ) { + if (this.mustBeFirstKey && vimState.recordedState.isPendingCommandPrefixedWithCount) { return false; } if (this instanceof BaseOperator && vimState.recordedState.operator) { @@ -55,10 +52,7 @@ export class BaseOperator extends BaseAction { if (!compareKeypressSequence(this.keys.slice(0, keysPressed.length), keysPressed)) { return false; } - if ( - this.mustBeFirstKey && - vimState.recordedState.numberOfKeysInCommandWithoutCountPrefix - keysPressed.length > 0 - ) { + if (this.mustBeFirstKey && vimState.recordedState.isPendingCommandPrefixedWithCount) { return false; } if (this instanceof BaseOperator && vimState.recordedState.operator) { diff --git a/src/configuration/remapper.ts b/src/configuration/remapper.ts index a236bde028ce..0496384ce5ae 100644 --- a/src/configuration/remapper.ts +++ b/src/configuration/remapper.ts @@ -52,7 +52,7 @@ export class Remappers implements IRemapper { } } -class Remapper implements IRemapper { +export class Remapper implements IRemapper { private readonly _configKey: string; private readonly _remappedModes: ModeName[]; private readonly _recursive: boolean; @@ -81,30 +81,11 @@ class Remapper implements IRemapper { const userDefinedRemappings = this._getRemappings(); - // Check to see if the keystrokes match any user-specified remapping. - let remapping: IKeyRemapping | undefined; - if (vimState.currentMode === ModeName.Insert) { - // In insert mode, we allow users to precede remapped commands - // with extraneous keystrokes (e.g. "hello world jj") - const longestKeySequence = Remapper._getLongestedRemappedKeySequence(userDefinedRemappings); - for (let sliceLength = 1; sliceLength <= longestKeySequence; sliceLength++) { - const slice = keys.slice(-sliceLength); - const result = _.find( - userDefinedRemappings, - remap => remap.before.join('') === slice.join('') - ); - - if (result) { - remapping = result; - break; - } - } - } else { - // In other modes, we have to precisely match the entire keysequence - remapping = _.find(userDefinedRemappings, map => { - return map.before.join('') === keys.join(''); - }); - } + let remapping: IKeyRemapping | undefined = Remapper._findMatchingRemap( + userDefinedRemappings, + keys, + vimState.currentMode + ); if (remapping) { logger.debug( @@ -117,25 +98,25 @@ class Remapper implements IRemapper { vimState.isCurrentlyPerformingRemapping = true; } - // Record length of remapped command - vimState.recordedState.numberOfRemappedKeys += remapping.before.length; - - const numToRemove = remapping.before.length - 1; + const numCharsToRemove = remapping.before.length - 1; // Revert previously inserted characters // (e.g. jj remapped to esc, we have to revert the inserted "jj") if (vimState.currentMode === ModeName.Insert) { // Revert every single inserted character. // We subtract 1 because we haven't actually applied the last key. await vimState.historyTracker.undoAndRemoveChanges( - Math.max(0, numToRemove * vimState.allCursors.length) + Math.max(0, numCharsToRemove * vimState.allCursors.length) ); - vimState.cursorPosition = vimState.cursorPosition.getLeft(numToRemove); + vimState.cursorPosition = vimState.cursorPosition.getLeft(numCharsToRemove); } // We need to remove the keys that were remapped into different keys // from the state. - vimState.recordedState.actionKeys = vimState.recordedState.actionKeys.slice(0, -numToRemove); - vimState.keyHistory = vimState.keyHistory.slice(0, -numToRemove); + vimState.recordedState.actionKeys = vimState.recordedState.actionKeys.slice( + 0, + -numCharsToRemove + ); + vimState.keyHistory = vimState.keyHistory.slice(0, -numCharsToRemove); if (remapping.after) { const count = vimState.recordedState.count || 1; @@ -170,8 +151,8 @@ class Remapper implements IRemapper { } // Check to see if a remapping could potentially be applied when more keys are received - for (let remap of userDefinedRemappings) { - if (keys.join('') === remap.before.slice(0, keys.length).join('')) { + for (let remap of Object.keys(userDefinedRemappings)) { + if (keys.join('') === remap.slice(0, keys.length)) { this._isPotentialRemap = true; break; } @@ -180,8 +161,8 @@ class Remapper implements IRemapper { return false; } - private _getRemappings(): IKeyRemapping[] { - let remappings: IKeyRemapping[] = []; + private _getRemappings(): { [key: string]: IKeyRemapping } { + let remappings: { [key: string]: IKeyRemapping } = {}; for (let remapping of configuration[this._configKey] as IKeyRemapping[]) { let debugMsg = `before=${remapping.before}. `; @@ -197,22 +178,72 @@ class Remapper implements IRemapper { if (!remapping.after && !remapping.commands) { logger.error( - `Remapper: ${this._configKey}. Invalid configuration. Missing remapped 'after' key or 'command'. ${debugMsg}` + `Remapper: ${ + this._configKey + }. Invalid configuration. Missing 'after' key or 'command'. ${debugMsg}` ); - } else { - logger.debug(`Remapper: ${this._configKey}. ${debugMsg}`); - remappings.push(remapping); + continue; + } + + const keys = remapping.before.join(''); + if (keys in remappings) { + logger.error(`Remapper: ${this._configKey}. Duplicate configuration. ${debugMsg}`); + continue; } + + logger.debug(`Remapper: ${this._configKey}. ${debugMsg}`); + remappings[keys] = remapping; } return remappings; } - private static _getLongestedRemappedKeySequence(remappings: IKeyRemapping[]): number { - if (remappings.length === 0) { - return 1; + protected static _findMatchingRemap( + userDefinedRemappings: { [key: string]: IKeyRemapping }, + inputtedKeys: string[], + currentMode: ModeName + ) { + let remapping: IKeyRemapping | undefined; + + let range = Remapper._getRemappedKeysLengthRange(userDefinedRemappings); + const startingSliceLength = Math.max(range[1], inputtedKeys.length); + for (let sliceLength = startingSliceLength; sliceLength >= range[0]; sliceLength--) { + const keySlice = inputtedKeys.slice(-sliceLength).join(''); + + if (keySlice in userDefinedRemappings) { + // In Insert mode, we allow users to precede remapped commands + // with extraneous keystrokes (eg. "hello world jj") + // In other modes, we have to precisely match the keysequence + // unless the preceding keys are numbers + if (currentMode !== ModeName.Insert) { + const precedingKeys = inputtedKeys + .slice(0, inputtedKeys.length - keySlice.length) + .join(''); + if (precedingKeys.length > 0 && !/^[0-9]+$/.test(precedingKeys)) { + break; + } + } + + remapping = userDefinedRemappings[keySlice]; + break; + } + } + + return remapping; + } + + /** + * Given list of remappings, returns the length of the shortest and longest remapped keys + * @param remappings + */ + protected static _getRemappedKeysLengthRange(remappings: { + [key: string]: IKeyRemapping; + }): [number, number] { + const keys = Object.keys(remappings); + if (keys.length === 0) { + return [0, 0]; } - return _.maxBy(remappings, map => map.before.length)!.before.length; + return [_.minBy(keys, m => m.length)!.length, _.maxBy(keys, m => m.length)!.length]; } } diff --git a/src/mode/modeHandler.ts b/src/mode/modeHandler.ts index f2fcba43ee79..61c8d4eb34f1 100644 --- a/src/mode/modeHandler.ts +++ b/src/mode/modeHandler.ts @@ -305,7 +305,6 @@ export class ModeHandler implements vscode.Disposable { try { // Take the count prefix out to perform the correct remapping. - const keys = this.vimState.recordedState.getCurrentCommandWithoutCountPrefix(); const withinTimeout = now - this.vimState.lastKeyPressedTimestamp < configuration.timeout; let handled = false; @@ -316,8 +315,15 @@ export class ModeHandler implements vscode.Disposable { * 1) We are not already performing a nonrecursive remapping. * 2) We haven't timed out of our previous remapping. */ - if (!this.vimState.isCurrentlyPerformingRemapping && (withinTimeout || keys.length === 1)) { - handled = await this._remappers.sendKey(keys, this, this.vimState); + if ( + !this.vimState.isCurrentlyPerformingRemapping && + (withinTimeout || this.vimState.recordedState.commandList.length === 1) + ) { + handled = await this._remappers.sendKey( + this.vimState.recordedState.commandList, + this, + this.vimState + ); } if (handled) { diff --git a/src/state/recordedState.ts b/src/state/recordedState.ts index 87a44520331e..ec9c8316078d 100644 --- a/src/state/recordedState.ts +++ b/src/state/recordedState.ts @@ -41,11 +41,6 @@ export class RecordedState { */ public commandList: string[] = []; - /** - * The number of keys the user has pressed that have been remapped. - */ - public numberOfRemappedKeys: number = 0; - /** * String representation of the exact keys that the user entered. Used for * showcmd. @@ -63,32 +58,16 @@ export class RecordedState { return result; } + /** - * get the current command without the prefixed count. - * For instance: if the current commandList is ['2', 'h'], returns only ['h']. + * Determines if the current command list is prefixed with a count */ - public getCurrentCommandWithoutCountPrefix(): string[] { - const commandList = this.commandList; - const result: string[] = []; - let previousWasCount = true; - - for (const commandKey of commandList) { - if (previousWasCount && commandKey.match(/[0-9]/)) { - continue; - } else { - previousWasCount = false; - result.push(commandKey); - } + public get isPendingCommandPrefixedWithCount() { + if (this.commandList.length === 0) { + return false; } - return result; - } - - /** - * lenth of the current command with remappings and the prefixed count excluded. - */ - public get numberOfKeysInCommandWithoutCountPrefix() { - return this.getCurrentCommandWithoutCountPrefix().length - this.numberOfRemappedKeys; + return this.commandList[0].match(/[0-9]/); } /** @@ -96,7 +75,6 @@ export class RecordedState { */ public resetCommandList() { this.commandList = []; - this.numberOfRemappedKeys = 0; } /** diff --git a/test/configuration/remapper.test.ts b/test/configuration/remapper.test.ts index dd43a2d43432..12b687157cc1 100644 --- a/test/configuration/remapper.test.ts +++ b/test/configuration/remapper.test.ts @@ -1,23 +1,26 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -import { getAndUpdateModeHandler } from '../../extension'; -import { Remappers } from '../../src/configuration/remapper'; +import { Remappers, Remapper } from '../../src/configuration/remapper'; import { ModeName } from '../../src/mode/mode'; import { ModeHandler } from '../../src/mode/modeHandler'; import { Configuration } from '../testConfiguration'; import { assertEqual, setupWorkspace, cleanUpWorkspace } from '../testUtils'; +import { IKeyRemapping } from '../../src/configuration/iconfiguration'; +import { getAndUpdateModeHandler } from '../../extension'; + +/* tslint:disable:no-string-literal */ suite('Remapper', () => { let modeHandler: ModeHandler; const leaderKey = '\\'; - const insertModeKeyBindings = [ + const insertModeKeyBindings: IKeyRemapping[] = [ { before: ['j', 'j'], after: [''], }, ]; - const normalModeKeyBindings = [ + const normalModeKeyBindings: IKeyRemapping[] = [ { before: ['leader', 'w'], commands: [ @@ -27,8 +30,17 @@ suite('Remapper', () => { }, ], }, + { + before: ['0'], + commands: [ + { + command: ':wq', + args: [], + }, + ], + }, ]; - const visualModeKeyBindings = [ + const visualModeKeyBindings: IKeyRemapping[] = [ { before: ['leader', 'c'], commands: [ @@ -40,6 +52,26 @@ suite('Remapper', () => { }, ]; + class TestRemapper extends Remapper { + constructor() { + super('configKey', [ModeName.Insert], false); + } + + public findMatchingRemap( + userDefinedRemappings: { [key: string]: IKeyRemapping }, + inputtedKeys: string[], + currentMode: ModeName + ) { + return TestRemapper._findMatchingRemap(userDefinedRemappings, inputtedKeys, currentMode); + } + + public getRemappedKeySequenceLengthRange(remappings: { + [key: string]: IKeyRemapping; + }): [number, number] { + return TestRemapper._getRemappedKeysLengthRange(remappings); + } + } + setup(async () => { let configuration = new Configuration(); configuration.leader = leaderKey; @@ -53,11 +85,121 @@ suite('Remapper', () => { teardown(cleanUpWorkspace); - test('jj -> ', async () => { + test('getLongestedRemappedKeySequence', async () => { // setup + let remappings: { [key: string]: IKeyRemapping } = { + abc: { before: ['a', 'b', 'c'] }, + de: { before: ['d', 'e'] }, + f: { before: ['f'] }, + }; + + // act + const testRemapper = new TestRemapper(); + const actual = testRemapper.getRemappedKeySequenceLengthRange(remappings); + + // assert + assert.equal(actual[0], 1); + assert.equal(actual[1], 3); + }); + + test('getMatchingRemap', async () => { + const testCases = [ + { + // able to match number in normal mode + before: '0', + after: ':wq', + input: '0', + mode: ModeName.Normal, + expectedAfter: ':wq', + }, + { + // able to match characters in normal mode + before: 'abc', + after: ':wq', + input: 'abc', + mode: ModeName.Normal, + expectedAfter: ':wq', + }, + { + // able to match with preceding count in normal mode + before: 'abc', + after: ':wq', + input: '0abc', + mode: ModeName.Normal, + expectedAfter: ':wq', + }, + { + // must match exactly in normal mode + before: 'abc', + after: ':wq', + input: 'defabc', + mode: ModeName.Normal, + }, + { + // able to match in insert mode + before: 'jj', + after: '', + input: 'jj', + mode: ModeName.Insert, + expectedAfter: '', + }, + { + // able to match with preceding keystrokes in insert mode + before: 'jj', + after: '', + input: 'hello world jj', + mode: ModeName.Insert, + expectedAfter: '', + }, + ]; + + for (const testCase of testCases) { + // setup + let remappings: { [key: string]: IKeyRemapping } = {}; + remappings[testCase.before] = { + before: testCase.before.split(''), + after: testCase.after.split(''), + }; + + // act + const testRemapper = new TestRemapper(); + const actual = testRemapper.findMatchingRemap( + remappings, + testCase.input.split(''), + testCase.mode + ); + + // assert + if (testCase.expectedAfter) { + assert( + actual, + `Expected remap for before=${testCase.before}. input=${testCase.input}. mode=${ + testCase.mode + }.` + ); + assert.deepEqual(actual!.after, testCase.expectedAfter.split('')); + } else { + assert.equal(actual, undefined); + } + } + }); + + test('jj -> through modehandler', async () => { + const expectedDocumentContent = 'lorem ipsum'; + + // setup + let remapper = new Remappers(); + + const edit = new vscode.WorkspaceEdit(); + edit.insert( + vscode.window.activeTextEditor!.document.uri, + new vscode.Position(0, 0), + expectedDocumentContent + ); + vscode.workspace.applyEdit(edit); + await modeHandler.handleKeyEvent('i'); assertEqual(modeHandler.currentMode.name, ModeName.Insert); - let remapper = new Remappers(); // act let actual = false; @@ -70,9 +212,28 @@ suite('Remapper', () => { // assert assert.equal(actual, true); assertEqual(modeHandler.currentMode.name, ModeName.Normal); + assert.equal(vscode.window.activeTextEditor!.document.getText(), expectedDocumentContent); }); - test('remapped command with leader on normal mode', async () => { + test('0 -> :wq through modehandler', async () => { + // setup + let remapper = new Remappers(); + assertEqual(modeHandler.currentMode.name, ModeName.Normal); + + // act + let actual = false; + try { + actual = await remapper.sendKey(['0'], modeHandler, modeHandler.vimState); + } catch (e) { + assert.fail(e); + } + + // assert + assert.equal(actual, true); + assert.equal(vscode.window.visibleTextEditors.length, 0); + }); + + test('leader, w -> closeActiveEditor in normal mode through modehandler', async () => { // setup let remapper = new Remappers(); assertEqual(modeHandler.currentMode.name, ModeName.Normal); @@ -90,7 +251,7 @@ suite('Remapper', () => { assert.equal(vscode.window.visibleTextEditors.length, 0); }); - test('remapped command with leader on visual mode', async () => { + test('leader, c -> closeActiveEditor in visual mode through modehandler', async () => { // setup let remapper = new Remappers(); assertEqual(modeHandler.currentMode.name, ModeName.Normal); @@ -111,3 +272,5 @@ suite('Remapper', () => { assert.equal(vscode.window.visibleTextEditors.length, 0); }); }); + +/* tslint:enable:no-string-literal */