From 91ca71f8607458c0558f9aff61e230c6917d4b51 Mon Sep 17 00:00:00 2001 From: berknam Date: Sun, 16 Aug 2020 18:22:51 +0000 Subject: [PATCH] Overhaul remapping logic (#4735) This is a pretty massive change; see pull request #4735 for full details Most notably: - Support for operator-pending mode, including remaps and a half-cursor decoration - Correct handling of ambiguous remaps with timeout - Correct handling of recursive special case when the RHS starts with the LHS - Correct handling of multi-key remaps in insert mode - Failed movements that occur partway through a remap stop & discard the rest of the remap - Implement `unmap` and `mapclear` in .vimrc Refs #463, refs #4908 Fixes #1261, fixes #1398, fixes #1579, fixes #1821, fixes #1835 Fixes #1870, fixes #1883, fixes #2041, fixes #2234, fixes #2466 Fixes #2897, fixes #2955, fixes #2975, fixes #3082, fixes #3086 Fixes #3171, fixes #3373, fixes #3413, fixes #3742, fixes #3768 Fixes #3988, fixes #4057, fixes #4118, fixes #4236, fixes #4353 Fixes #4464, fixes #4530, fixes #4532, fixes #4563, fixes #4674 Fixes #4756, fixes #4883, fixes #4928, fixes #4991, fixes #5016 Fixes #5057, fixes #5067, fixes #5084, fixes #5125 --- README.md | 88 +- extensionBase.ts | 22 +- package.json | 14 + src/actions/base.ts | 6 +- src/actions/commands/actions.ts | 13 +- src/cmd_line/commands/substitute.ts | 2 +- src/configuration/configuration.ts | 20 +- src/configuration/decoration.ts | 109 +- src/configuration/iconfiguration.ts | 17 +- src/configuration/remapper.ts | 507 ++++++- .../validators/remappingValidator.ts | 21 +- src/configuration/vimrc.ts | 282 +++- src/configuration/vimrcKeyRemappingBuilder.ts | 179 ++- src/error.ts | 16 + src/mode/mode.ts | 13 +- src/mode/modeHandler.ts | 292 +++- src/state/recordedState.ts | 134 +- src/state/vimState.ts | 117 +- src/util/specialKeys.ts | 5 + test/configuration/configuration.test.ts | 3 +- test/configuration/remapper.test.ts | 296 +++- test/configuration/remaps.test.ts | 1205 +++++++++++++++++ .../validators/remappingValidator.test.ts | 131 +- .../vimrcKeyRemappingBuilder.test.ts | 290 ++++ test/testConfiguration.ts | 8 +- test/testSimplifier.ts | 479 ++++++- test/testUtils.ts | 3 +- 27 files changed, 4033 insertions(+), 239 deletions(-) create mode 100644 src/util/specialKeys.ts create mode 100644 test/configuration/remaps.test.ts diff --git a/README.md b/README.md index 64ee24e9658..17fc43a15e1 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,9 @@ Here's some ideas on what you can do with neovim integration: Custom remappings are defined on a per-mode basis. -#### `"vim.insertModeKeyBindings"`/`"vim.normalModeKeyBindings"`/`"vim.visualModeKeyBindings"` +#### `"vim.insertModeKeyBindings"`/`"vim.normalModeKeyBindings"`/`"vim.visualModeKeyBindings"`/`"vim.operatorPendingModeKeyBindings"` -- Keybinding overrides to use for insert, normal, and visual modes. +- Keybinding overrides to use for insert, normal, operatorPending and visual modes. - Bind `jj` to `` in insert mode: ```json @@ -179,7 +179,7 @@ Custom remappings are defined on a per-mode basis. - Bind `:` to show the command palette: ```json - "vim.normalModeKeyBindingsNonRecursive": [ + "vim.normalModeKeyBindings": [ { "before": [":"], "commands": [ @@ -192,7 +192,7 @@ Custom remappings are defined on a per-mode basis. - Bind `m` to add a bookmark and `b` to open the list of all bookmarks (using the [Bookmarks](https://marketplace.visualstudio.com/items?itemName=alefragnani.Bookmarks) extension): ```json - "vim.normalModeKeyBindingsNonRecursive": [ + "vim.normalModeKeyBindings": [ { "before": ["", "m"], "commands": [ @@ -211,7 +211,7 @@ Custom remappings are defined on a per-mode basis. - Bind `ZZ` to the vim command `:wq` (save and close the current file): ```json - "vim.normalModeKeyBindingsNonRecursive": [ + "vim.normalModeKeyBindings": [ { "before": ["Z", "Z"], "commands": [ @@ -224,7 +224,7 @@ Custom remappings are defined on a per-mode basis. - Bind `ctrl+n` to turn off search highlighting and `w` to save the current file: ```json - "vim.normalModeKeyBindingsNonRecursive": [ + "vim.normalModeKeyBindings": [ { "before":[""], "commands": [ @@ -240,28 +240,36 @@ Custom remappings are defined on a per-mode basis. ] ``` -- Bind `p` in visual mode to paste without overriding the current register +- Bind `{` to `w` in operator pending mode makes `y{` and `d{` work like `yw` and `dw` respectively. ```json - "vim.visualModeKeyBindingsNonRecursive": [ + "vim.operatorPendingModeKeyBindings": [ { - "before": [ - "p", - ], - "after": [ - "p", - "g", - "v", - "y" - ] + "before": ["{"], + "after": ["w"] } - ], + ] +``` + +- Bind `L` to `$` and `H` to `^` in operator pending mode makes `yL` and `dH` work like `yL` and `d^` respectively. + +```json + "vim.operatorPendingModeKeyBindings": [ + { + "before": ["L"], + "after": ["$"] + }, + { + "before": ["H"], + "after": ["^"] + } + ] ``` - Bind `>` and `<` in visual mode to indent/outdent lines (repeatable) ```json - "vim.visualModeKeyBindingsNonRecursive": [ + "vim.visualModeKeyBindings": [ { "before": [ ">" @@ -284,7 +292,7 @@ Custom remappings are defined on a per-mode basis. - Bind `vim` to clone this repository to the selected location. ```json - "vim.visualModeKeyBindingsNonRecursive": [ + "vim.visualModeKeyBindings": [ { "before": [ "", "v", "i", "m" @@ -299,20 +307,53 @@ Custom remappings are defined on a per-mode basis. ] ``` -#### `"vim.insertModeKeyBindingsNonRecursive"`/`"normalModeKeyBindingsNonRecursive"`/`"visualModeKeyBindingsNonRecursive"` +#### `"vim.insertModeKeyBindingsNonRecursive"`/`"normalModeKeyBindingsNonRecursive"`/`"visualModeKeyBindingsNonRecursive"`/`"operatorPendingModeKeyBindingsNonRecursive"` - Non-recursive keybinding overrides to use for insert, normal, and visual modes -- _Example:_ Bind `j` to `gj`. Notice that if you attempted this binding normally, the j in gj would be expanded into gj, on and on forever. Stop this recursive expansion using insertModeKeyBindingsNonRecursive and/or normalModeKeyBindingNonRecursive. +- _Example:_ Exchange the meaning of two keys like `j` to `k` and `k` to `j` to exchange the cursor up and down commands. Notice that if you attempted this binding normally, the `j` would be replaced with `k` and the `k` would be replaced with `j`, on and on forever. When this happens 'maxmapdepth' times (default 1000) the error message 'E223 Recursive Mapping' will be thrown. Stop this recursive expansion using the NonRecursive variation of the keybindings. ```json "vim.normalModeKeyBindingsNonRecursive": [ { "before": ["j"], - "after": ["g", "j"] + "after": ["k"] + }, + { + "before": ["k"], + "after": ["j"] } ] ``` +- Bind `(` to 'i(' in operator pending mode makes 'y(' and 'c(' work like 'yi(' and 'ci(' respectively. + +```json + "vim.operatorPendingModeKeyBindingsNonRecursive": [ + { + "before": ["("], + "after": ["i("] + } + ] +``` + +- Bind `p` in visual mode to paste without overriding the current register + +```json + "vim.visualModeKeyBindingsNonRecursive": [ + { + "before": [ + "p", + ], + "after": [ + "p", + "g", + "v", + "y" + ] + } + ], +``` + #### Debugging Remappings 1. Are your configurations correct? @@ -361,6 +402,7 @@ Configuration settings that have been copied from vim. Vim settings are loaded i | vim.smartcase | Override the 'ignorecase' setting if search pattern contains uppercase characters | Boolean | true | | vim.textwidth | Width to word-wrap when using `gq` | Number | 80 | | vim.timeout | Timeout in milliseconds for remapped commands | Number | 1000 | +| vim.maxmapdepth | Maximum number of times a mapping is done without resulting in a character to be used. This normally catches endless mappings, like ":map x y" with ":map y x". It still does not catch ":map g wg", because the 'w' is used before the next mapping is done. | Number | 1000 | | vim.whichwrap | Controls wrapping at beginning and end of line. Comma-separated set of keys that should wrap to next/previous line. Arrow keys are represented by `[` and `]` in insert mode, `<` and `>` in normal and visual mode. To wrap "everything", set this to `h,l,<,>,[,]`. | String | `` | | vim.report | Threshold for reporting number of lines changed. | Number | 2 | diff --git a/extensionBase.ts b/extensionBase.ts index 19834f5f553..0577065179e 100644 --- a/extensionBase.ts +++ b/extensionBase.ts @@ -18,6 +18,7 @@ import { configuration } from './src/configuration/configuration'; import { globalState } from './src/state/globalState'; import { taskQueue } from './src/taskQueue'; import { Register } from './src/register/register'; +import { SpecialKeys } from './src/util/specialKeys'; let extensionContext: vscode.ExtensionContext; let previousActiveEditorId: EditorIdentity | undefined = undefined; @@ -434,7 +435,13 @@ export async function activate( }); for (const boundKey of configuration.boundKeyCombinations) { - registerCommand(context, boundKey.command, () => handleKeyEvent(`${boundKey.key}`)); + registerCommand(context, boundKey.command, () => { + if (['', ''].includes(boundKey.key)) { + checkIfRecursiveRemapping(`${boundKey.key}`); + } else { + handleKeyEvent(`${boundKey.key}`); + } + }); } // Initialize mode handler for current active Text Editor at startup. @@ -468,11 +475,11 @@ async function toggleExtension(isDisabled: boolean, compositionState: Compositio } let mh = await getAndUpdateModeHandler(); if (isDisabled) { - await mh.handleKeyEvent(''); + await mh.handleKeyEvent(SpecialKeys.ExtensionDisable); compositionState.reset(); ModeHandlerMap.clear(); } else { - await mh.handleKeyEvent(''); + await mh.handleKeyEvent(SpecialKeys.ExtensionEnable); } } @@ -546,6 +553,15 @@ async function handleKeyEvent(key: string): Promise { }); } +async function checkIfRecursiveRemapping(key: string): Promise { + const mh = await getAndUpdateModeHandler(); + if (mh.vimState.isCurrentlyPerformingRecursiveRemapping) { + mh.vimState.forceStopRecursiveRemapping = true; + } else { + handleKeyEvent(key); + } +} + function handleContentChangedFromDisk(document: vscode.TextDocument): void { ModeHandlerMap.getAll() .filter((modeHandler) => modeHandler.vimState.identity.fileName === document.fileName) diff --git a/package.json b/package.json index 5c467e735fe..d171f37dda0 100644 --- a/package.json +++ b/package.json @@ -416,6 +416,14 @@ "type": "array", "markdownDescription": "Non-recursive remapped keys in Normal mode. Allows mapping to Vim commands or VS Code actions. See [README](https://github.com/VSCodeVim/Vim/#key-remapping) for details." }, + "vim.operatorPendingModeKeyBindings": { + "type": "array", + "markdownDescription": "Remapped keys in OperatorPending mode. Allows mapping to Vim commands or VS Code actions. See [README](https://github.com/VSCodeVim/Vim/#key-remapping) for details." + }, + "vim.operatorPendingModeKeyBindingsNonRecursive": { + "type": "array", + "markdownDescription": "Non-recursive remapped keys in OperatorPending mode. Allows mapping to Vim commands or VS Code actions. See [README](https://github.com/VSCodeVim/Vim/#key-remapping) for details." + }, "vim.useCtrlKeys": { "type": "boolean", "markdownDescription": "Enable some Vim Ctrl key commands that override otherwise common operations, like `Ctrl+C`.", @@ -500,6 +508,12 @@ "default": 1000, "minimum": 0 }, + "vim.maxmapdepth": { + "type": "number", + "description": "Maximum number of times a mapping is done without resulting in a character to be used.", + "default": 1000, + "minimum": 0 + }, "vim.scroll": { "type": "number", "markdownDescription": "Number of lines to scroll with `Ctrl-U` and `Ctrl-D` commands. Set to 0 to use a half page scroll.", diff --git a/src/actions/base.ts b/src/actions/base.ts index 3ac083d56f6..a20d08beb38 100644 --- a/src/actions/base.ts +++ b/src/actions/base.ts @@ -53,7 +53,8 @@ export abstract class BaseAction { public doesActionApply(vimState: VimState, keysPressed: string[]): boolean { if ( this.mustBeFirstKey && - vimState.recordedState.commandWithoutCountPrefix.length > keysPressed.length + (vimState.recordedState.commandWithoutCountPrefix.length > keysPressed.length || + vimState.recordedState.operator) ) { return false; } @@ -80,7 +81,8 @@ export abstract class BaseAction { if ( this.mustBeFirstKey && - vimState.recordedState.commandWithoutCountPrefix.length > keysPressed.length + (vimState.recordedState.commandWithoutCountPrefix.length > keysPressed.length || + vimState.recordedState.operator) ) { return false; } diff --git a/src/actions/commands/actions.ts b/src/actions/commands/actions.ts index 6481ca0c4f4..e67a9a6aeca 100644 --- a/src/actions/commands/actions.ts +++ b/src/actions/commands/actions.ts @@ -32,6 +32,7 @@ import { StatusBar } from '../../statusBar'; import { reportFileInfo, reportSearch } from '../../util/statusBarTextUtils'; import { globalState } from '../../state/globalState'; import { VimError, ErrorCode } from '../../error'; +import { SpecialKeys } from '../../util/specialKeys'; import _ = require('lodash'); export class DocumentContentChangeAction extends BaseAction { @@ -184,7 +185,7 @@ class DisableExtension extends BaseCommand { Mode.EasyMotionInputMode, Mode.SurroundInputMode, ]; - keys = ['']; + keys = [SpecialKeys.ExtensionDisable]; public async exec(position: Position, vimState: VimState): Promise { await vimState.setCurrentMode(Mode.Disabled); @@ -195,7 +196,7 @@ class DisableExtension extends BaseCommand { @RegisterAction class EnableExtension extends BaseCommand { modes = [Mode.Disabled]; - keys = ['']; + keys = [SpecialKeys.ExtensionEnable]; public async exec(position: Position, vimState: VimState): Promise { await vimState.setCurrentMode(Mode.Normal); @@ -1176,7 +1177,7 @@ export class CommandShowSearchHistory extends BaseCommand { } public async exec(position: Position, vimState: VimState): Promise { - if (vimState.recordedState.commandList.includes('?')) { + if (this.keysPressed.includes('?')) { this.direction = SearchDirection.Backward; } vimState.recordedState.transformations.push({ @@ -2458,8 +2459,10 @@ export class ActionDeleteCharWithDeleteKey extends BaseCommand { // http://vimdoc.sourceforge.net/htmldoc/change.html# if (vimState.recordedState.count !== 0) { vimState.recordedState.count = Math.floor(vimState.recordedState.count / 10); - vimState.recordedState.actionKeys = vimState.recordedState.count.toString().split(''); - vimState.recordedState.commandList = vimState.recordedState.count.toString().split(''); + + // Change actionsRunPressedKeys so that showCmd updates correctly + vimState.recordedState.actionsRunPressedKeys = + vimState.recordedState.count > 0 ? vimState.recordedState.count.toString().split('') : []; this.isCompleteAction = false; return vimState; } diff --git a/src/cmd_line/commands/substitute.ts b/src/cmd_line/commands/substitute.ts index 4ef59ffa067..99932007451 100644 --- a/src/cmd_line/commands/substitute.ts +++ b/src/cmd_line/commands/substitute.ts @@ -235,7 +235,7 @@ export class SubstituteCommand extends node.CommandBase { ]; vimState.editor.revealRange(new vscode.Range(line, 0, line, 0)); - vimState.editor.setDecorations(decoration.SearchHighlight, searchRanges); + vimState.editor.setDecorations(decoration.searchHighlight, searchRanges); const prompt = `Replace with ${replacement} (${validSelections.join('/')})?`; await vscode.window.showInputBox( diff --git a/src/configuration/configuration.ts b/src/configuration/configuration.ts index 0fe09ef2386..71262e536d3 100644 --- a/src/configuration/configuration.ts +++ b/src/configuration/configuration.ts @@ -91,6 +91,8 @@ class Configuration implements IConfiguration { this.leader = Notation.NormalizeKey(this.leader, this.leaderDefault); + this.clearKeyBindingsMaps(); + const validatorResults = await configurationValidator.validate(configuration); // wrap keys @@ -160,6 +162,15 @@ class Configuration implements IConfiguration { return this.cursorTypeMap[cursorStyle]; } + clearKeyBindingsMaps() { + // Clear the KeyBindingsMaps so that the previous configuration maps don't leak to this one + this.normalModeKeyBindingsMap = new Map(); + this.insertModeKeyBindingsMap = new Map(); + this.visualModeKeyBindingsMap = new Map(); + this.commandLineModeKeyBindingsMap = new Map(); + this.operatorPendingModeKeyBindingsMap = new Map(); + } + handleKeys: IHandleKeys[] = []; useSystemClipboard = false; @@ -220,6 +231,8 @@ class Configuration implements IConfiguration { timeout = 1000; + maxmapdepth = 1000; + showcmd = true; showmodename = true; @@ -368,19 +381,18 @@ class Configuration implements IConfiguration { insertModeKeyBindingsNonRecursive: IKeyRemapping[] = []; normalModeKeyBindings: IKeyRemapping[] = []; normalModeKeyBindingsNonRecursive: IKeyRemapping[] = []; + operatorPendingModeKeyBindings: IKeyRemapping[] = []; + operatorPendingModeKeyBindingsNonRecursive: IKeyRemapping[] = []; visualModeKeyBindings: IKeyRemapping[] = []; visualModeKeyBindingsNonRecursive: IKeyRemapping[] = []; commandLineModeKeyBindings: IKeyRemapping[] = []; commandLineModeKeyBindingsNonRecursive: IKeyRemapping[] = []; insertModeKeyBindingsMap: Map; - insertModeKeyBindingsNonRecursiveMap: Map; normalModeKeyBindingsMap: Map; - normalModeKeyBindingsNonRecursiveMap: Map; + operatorPendingModeKeyBindingsMap: Map; visualModeKeyBindingsMap: Map; - visualModeKeyBindingsNonRecursiveMap: Map; commandLineModeKeyBindingsMap: Map; - commandLineModeKeyBindingsNonRecursiveMap: Map; private static unproxify(obj: Object): Object { let result = {}; diff --git a/src/configuration/decoration.ts b/src/configuration/decoration.ts index e930f8ea59c..97466cc7792 100644 --- a/src/configuration/decoration.ts +++ b/src/configuration/decoration.ts @@ -6,53 +6,89 @@ class DecorationImpl { private _searchHighlight: vscode.TextEditorDecorationType; private _easyMotionIncSearch: vscode.TextEditorDecorationType; private _easyMotionDimIncSearch: vscode.TextEditorDecorationType; + private _insertModeVirtualCharacter: vscode.TextEditorDecorationType; + private _operatorPendingModeCursor: vscode.TextEditorDecorationType; + private _operatorPendingModeCursorChar: vscode.TextEditorDecorationType; - public set Default(value: vscode.TextEditorDecorationType) { + public set default(value: vscode.TextEditorDecorationType) { if (this._default) { this._default.dispose(); } this._default = value; } - public get Default() { + public get default() { return this._default; } - public set SearchHighlight(value: vscode.TextEditorDecorationType) { + public set searchHighlight(value: vscode.TextEditorDecorationType) { if (this._searchHighlight) { this._searchHighlight.dispose(); } this._searchHighlight = value; } - public get SearchHighlight() { + public get searchHighlight() { return this._searchHighlight; } - public set EasyMotionIncSearch(value: vscode.TextEditorDecorationType) { + public set easyMotionIncSearch(value: vscode.TextEditorDecorationType) { if (this._easyMotionIncSearch) { this._easyMotionIncSearch.dispose(); } this._easyMotionIncSearch = value; } - public set EasyMotionDimIncSearch(value: vscode.TextEditorDecorationType) { + public set easyMotionDimIncSearch(value: vscode.TextEditorDecorationType) { if (this._easyMotionDimIncSearch) { this._easyMotionDimIncSearch.dispose(); } this._easyMotionDimIncSearch = value; } - public get EasyMotionIncSearch() { + public get easyMotionIncSearch() { return this._easyMotionIncSearch; } - public get EasyMotionDimIncSearch() { + public get easyMotionDimIncSearch() { return this._easyMotionDimIncSearch; } + public set insertModeVirtualCharacter(value: vscode.TextEditorDecorationType) { + if (this._insertModeVirtualCharacter) { + this._insertModeVirtualCharacter.dispose(); + } + this._insertModeVirtualCharacter = value; + } + + public get insertModeVirtualCharacter() { + return this._insertModeVirtualCharacter; + } + + public set operatorPendingModeCursor(value: vscode.TextEditorDecorationType) { + if (this._operatorPendingModeCursor) { + this._operatorPendingModeCursor.dispose(); + } + this._operatorPendingModeCursor = value; + } + + public get operatorPendingModeCursor() { + return this._operatorPendingModeCursor; + } + + public set operatorPendingModeCursorChar(value: vscode.TextEditorDecorationType) { + if (this._operatorPendingModeCursorChar) { + this._operatorPendingModeCursorChar.dispose(); + } + this._operatorPendingModeCursorChar = value; + } + + public get operatorPendingModeCursorChar() { + return this._operatorPendingModeCursorChar; + } + public load(configuration: IConfiguration) { - this.Default = vscode.window.createTextEditorDecorationType({ + this.default = vscode.window.createTextEditorDecorationType({ backgroundColor: new vscode.ThemeColor('editorCursor.foreground'), borderColor: new vscode.ThemeColor('editorCursor.foreground'), dark: { @@ -70,20 +106,69 @@ class DecorationImpl { ? configuration.searchHighlightColor : new vscode.ThemeColor('editor.findMatchHighlightBackground'); - this.SearchHighlight = vscode.window.createTextEditorDecorationType({ + this.searchHighlight = vscode.window.createTextEditorDecorationType({ backgroundColor: searchHighlightColor, color: configuration.searchHighlightTextColor, overviewRulerColor: new vscode.ThemeColor('editorOverviewRuler.findMatchForeground'), }); - this.EasyMotionIncSearch = vscode.window.createTextEditorDecorationType({ + this.easyMotionIncSearch = vscode.window.createTextEditorDecorationType({ color: configuration.easymotionIncSearchForegroundColor, fontWeight: configuration.easymotionMarkerFontWeight, }); - this.EasyMotionDimIncSearch = vscode.window.createTextEditorDecorationType({ + this.easyMotionDimIncSearch = vscode.window.createTextEditorDecorationType({ color: configuration.easymotionDimColor, }); + + this.insertModeVirtualCharacter = vscode.window.createTextEditorDecorationType({ + color: 'transparent', // no color to hide the existing character + before: { + color: 'currentColor', + backgroundColor: new vscode.ThemeColor('editor.background'), + borderColor: new vscode.ThemeColor('editor.background'), + margin: '0 -1ch 0 0', + height: '100%', + }, + }); + + // This creates the half block cursor when on operator pending mode + this.operatorPendingModeCursor = vscode.window.createTextEditorDecorationType({ + before: { + // no color to hide the existing character. We only need the character here to make + // the width be the same as the existing character. + color: 'transparent', + // The '-1ch' right margin is so that it displays on top of the existing character. The amount + // here doesn't really matter, it could be '-1px' it just needs to be negative so that the left + // of this 'before' element coincides with the left of the existing character. + margin: `0 -1ch 0 0; + position: absolute; + bottom: 0; + line-height: 0;`, + height: '50%', + backgroundColor: new vscode.ThemeColor('editorCursor.foreground'), + }, + }); + + // This puts a character on top of the half block cursor and on top of the existing character + // to create the mix-blend 'magic' + this.operatorPendingModeCursorChar = vscode.window.createTextEditorDecorationType({ + // We make the existing character 'black' -> rgb(0,0,0), because when using the mix-blend-mode + // with 'exclusion' it subtracts the darker color from the lightest color which means we will + // subtract zero from our 'currentcolor' leaving us with 'currentcolor' on the part above the + // background of the half cursor. + color: 'black', + before: { + color: 'currentcolor', + // The '-1ch' right margin is so that it displays on top of the existing character. The amount + // here doesn't really matter, it could be '-1px' it just needs to be negative so that the left + // of this 'before' element coincides with the left of the existing character. + margin: `0 -1ch 0 0; + position: absolute; + mix-blend-mode: exclusion;`, + height: '100%', + }, + }); } } diff --git a/src/configuration/iconfiguration.ts b/src/configuration/iconfiguration.ts index c78d3836333..d2033958efa 100644 --- a/src/configuration/iconfiguration.ts +++ b/src/configuration/iconfiguration.ts @@ -14,6 +14,8 @@ export interface IModeSpecificStrings { export interface IKeyRemapping { before: string[]; after?: string[]; + // 'recursive' is calculated when validating, according to the config that stored the remapping + recursive?: boolean; commands?: ({ command: string; args: any[] } | string)[]; source?: 'vscode' | 'vimrc'; } @@ -184,6 +186,14 @@ export interface IConfiguration { */ timeout: number; + /** + * Maximum number of times a mapping is done without resulting in a + * character to be used. This normally catches endless mappings, like + * ":map x y" with ":map y x". It still does not catch ":map g wg", + * because the 'w' is used before the next mapping is done. + */ + maxmapdepth: number; + /** * Display partial commands on status bar? */ @@ -333,6 +343,8 @@ export interface IConfiguration { insertModeKeyBindingsNonRecursive: IKeyRemapping[]; normalModeKeyBindings: IKeyRemapping[]; normalModeKeyBindingsNonRecursive: IKeyRemapping[]; + operatorPendingModeKeyBindings: IKeyRemapping[]; + operatorPendingModeKeyBindingsNonRecursive: IKeyRemapping[]; visualModeKeyBindings: IKeyRemapping[]; visualModeKeyBindingsNonRecursive: IKeyRemapping[]; commandLineModeKeyBindings: IKeyRemapping[]; @@ -342,13 +354,10 @@ export interface IConfiguration { * These are constructed by the RemappingValidator */ insertModeKeyBindingsMap: Map; - insertModeKeyBindingsNonRecursiveMap: Map; normalModeKeyBindingsMap: Map; - normalModeKeyBindingsNonRecursiveMap: Map; + operatorPendingModeKeyBindingsMap: Map; visualModeKeyBindingsMap: Map; - visualModeKeyBindingsNonRecursiveMap: Map; commandLineModeKeyBindingsMap: Map; - commandLineModeKeyBindingsNonRecursiveMap: Map; /** * Comma-separated list of motion keys that should wrap to next/previous line. diff --git a/src/configuration/remapper.ts b/src/configuration/remapper.ts index bee7988e46d..d61a3302097 100644 --- a/src/configuration/remapper.ts +++ b/src/configuration/remapper.ts @@ -7,6 +7,8 @@ import { VimState } from './../state/vimState'; import { commandLine } from '../cmd_line/commandLine'; import { configuration } from '../configuration/configuration'; import { StatusBar } from '../statusBar'; +import { VimError, ErrorCode, ForceStopRemappingError } from '../error'; +import { SpecialKeys } from '../util/specialKeys'; interface IRemapper { /** @@ -25,14 +27,11 @@ export class Remappers implements IRemapper { constructor() { this.remappers = [ - new InsertModeRemapper(true), - new NormalModeRemapper(true), - new VisualModeRemapper(true), - new CommandLineModeRemapper(true), - new InsertModeRemapper(false), - new NormalModeRemapper(false), - new VisualModeRemapper(false), - new CommandLineModeRemapper(false), + new InsertModeRemapper(), + new NormalModeRemapper(), + new VisualModeRemapper(), + new CommandLineModeRemapper(), + new OperatorPendingModeRemapper(), ]; } @@ -46,6 +45,7 @@ export class Remappers implements IRemapper { vimState: VimState ): Promise { let handled = false; + for (let remapper of this.remappers) { handled = handled || (await remapper.sendKey(keys, modeHandler, vimState)); } @@ -56,17 +56,49 @@ export class Remappers implements IRemapper { export class Remapper implements IRemapper { private readonly _configKey: string; private readonly _remappedModes: Mode[]; - private readonly _recursive: boolean; private readonly _logger = Logger.get('Remapper'); + /** + * Checks if the current commandList is a potential remap. + */ private _isPotentialRemap = false; + + /** + * If the commandList has a remap but there is still another potential remap we + * call it an Ambiguous Remap and we store it here. If later we need to handle it + * we don't need to go looking for it. + */ + private _hasAmbiguousRemap: IKeyRemapping | undefined; + + /** + * If the commandList is a potential remap but has no ambiguous remap + * yet, we say that it has a Potential Remap. + * + * This is to distinguish the commands with ambiguous remaps and the + * ones without. + * + * Example 1: if 'aaaa' is mapped and so is 'aa', when the user has pressed + * 'aaa' we say it has an Ambiguous Remap which is 'aa', because if the + * user presses other key than 'a' next or waits for the timeout to finish + * we need to now that there was a remap to run so we first run the 'aa' + * remap and then handle the remaining keys. + * + * Example 2: if only 'aaaa' is mapped, when the user has pressed 'aaa' + * we say it has a Potential Remap, because if the user presses other key + * than 'a' next or waits for the timeout to finish we need to now that + * there was a potential remap that never came or was broken, so we can + * resend the keys again without allowing for a potential remap on the first + * key, which means we won't get to the same state because the first key + * will be handled as an action (in this case a 'CommandInsertAfterCursor') + */ + private _hasPotentialRemap = false; + get isPotentialRemap(): boolean { return this._isPotentialRemap; } - constructor(configKey: string, remappedModes: Mode[], recursive: boolean) { + constructor(configKey: string, remappedModes: Mode[]) { this._configKey = configKey; - this._recursive = recursive; this._remappedModes = remappedModes; } @@ -76,85 +108,391 @@ export class Remapper implements IRemapper { vimState: VimState ): Promise { this._isPotentialRemap = false; + const allowPotentialRemapOnFirstKey = vimState.recordedState.allowPotentialRemapOnFirstKey; + let remainingKeys: string[] = []; + + /** + * Means that the timeout finished so we now can't allow the keys to be buffered again + * because the user already waited for timeout. + */ + let allowBufferingKeys = true; - if (!this._remappedModes.includes(vimState.currentMode)) { + if (!this._remappedModes.includes(vimState.currentModeIncludingPseudoModes)) { return false; } const userDefinedRemappings = configuration[this._configKey] as Map; + if (keys[keys.length - 1] === SpecialKeys.TimeoutFinished) { + // Timeout finished. Don't let an ambiguous or potential remap start another timeout again + keys = keys.slice(0, keys.length - 1); + allowBufferingKeys = false; + } + + if (keys.length === 0) { + return true; + } + this._logger.debug( `trying to find matching remap. keys=${keys}. mode=${ Mode[vimState.currentMode] }. keybindings=${this._configKey}.` ); + let remapping: IKeyRemapping | undefined = this.findMatchingRemap( userDefinedRemappings, keys, vimState.currentMode ); + // Check to see if a remapping could potentially be applied when more keys are received + let isPotentialRemap = Remapper.hasPotentialRemap(keys, userDefinedRemappings); + + this._isPotentialRemap = + isPotentialRemap && allowBufferingKeys && allowPotentialRemapOnFirstKey; + + /** + * Handle a broken potential or ambiguous remap + * 1. If this Remapper doesn't have a remapping AND + * 2. (It previously had an AmbiguousRemap OR a PotentialRemap) AND + * 3. (It doesn't have a potential remap anymore OR timeout finished) AND + * 4. keys length is more than 1 + * + * Points 1-3: If we no longer have a remapping but previously had one or a potential one + * and there is no longer potential remappings because of another pressed key or because the + * timeout has passed we need to handle those situations by resending the keys or handling the + * ambiguous remap and resending any remaining keys. + * Point 4: if there is only one key there is no point in resending it without allowing remaps + * on first key, we can let the remapper go to the end because since either there was no potential + * remap anymore or the timeout finished so this means that the next two checks (the 'Buffer keys + * and create timeout' and 'Handle remapping and remaining keys') will never be hit, so it reaches + * the end without doing anything which means that this key will be handled as an action as intended. + */ + if ( + !remapping && + (this._hasAmbiguousRemap || this._hasPotentialRemap) && + (!isPotentialRemap || !allowBufferingKeys) && + keys.length > 1 + ) { + if (this._hasAmbiguousRemap) { + remapping = this._hasAmbiguousRemap; + isPotentialRemap = false; + this._isPotentialRemap = false; + + // Use the commandList to get the remaining keys so that it includes any existing + // '' key + remainingKeys = vimState.recordedState.commandList.slice(remapping.before.length); + this._hasAmbiguousRemap = undefined; + } + if (!remapping) { + // if there is still no remapping, handle all the keys without allowing + // a potential remap on the first key so that we don't repeat everything + // again, but still allow for other ambiguous remaps after the first key. + // + // Example: if 'iiii' is mapped in normal and 'ii' is mapped in insert mode, + // and the user presses 'iiia' in normal mode or presses 'iii' and waits + // for the timeout to finish, we want the first 'i' to be handled without + // allowing potential remaps, which means it will go into insert mode, + // but then the next 'ii' should be remapped in insert mode and after the + // remap the 'a' should be handled. + if (!allowBufferingKeys) { + // Timeout finished and there is no remapping, so handle the buffered + // keys but resend the '' key as well so we don't wait + // for the timeout again but can still handle potential remaps. + // + // Example 1: if 'ccc' is mapped in normal mode and user presses 'cc' and + // waits for the timeout to finish, this will resend the 'cc' + // keys without allowing a potential remap on first key, which makes the + // first 'c' be handled as a 'ChangeOperator' and the second 'c' which has + // potential remaps (the 'ccc' remap) is buffered and the timeout started + // but then the '' key comes straight away that clears the + // timeout without waiting again, and makes the second 'c' be handled normally + // as another 'ChangeOperator'. + // + // Example 2: if 'iiii' is mapped in normal and 'ii' is mapped in insert + // mode, and the user presses 'iii' in normal mode and waits for the timeout + // to finish, this will resend the 'iii' keys without allowing + // a potential remap on first key, which makes the first 'i' be handled as + // an 'CommandInsertAtCursor' and goes to insert mode, next the second 'i' + // is buffered, then the third 'i' finds the insert mode remapping of 'ii' + // and handles that remap, after the remapping being handled the '' + // key comes that clears the timeout and since the commandList will be empty + // we return true as we finished handling this sequence of keys. + + keys.push(SpecialKeys.TimeoutFinished); // include the '' key + + this._logger.debug( + `${this._configKey}. timeout finished, handling timed out buffer keys without allowing a new timeout.` + ); + } + this._logger.debug( + `${this._configKey}. potential remap broken. resending keys without allowing a potential remap on first key. keys=${keys}` + ); + this._hasPotentialRemap = false; + vimState.recordedState.allowPotentialRemapOnFirstKey = false; + vimState.recordedState.resetCommandList(); + + if (vimState.wasPerformingRemapThatFinishedWaitingForTimeout) { + // Some keys that broke the possible remap were typed by the user so handle them seperatly + const lastRemapLength = vimState.wasPerformingRemapThatFinishedWaitingForTimeout.after! + .length; + const keysPressedByUser = keys.slice(lastRemapLength); + keys = keys.slice(0, lastRemapLength); + + try { + vimState.isCurrentlyPerformingRecursiveRemapping = true; + await modeHandler.handleMultipleKeyEvents(keys); + } catch (e) { + if (e instanceof ForceStopRemappingError) { + this._logger.debug( + `${this._configKey}. Stopped the remapping in the middle, ignoring the rest. Reason: ${e.message}` + ); + } + } finally { + vimState.isCurrentlyPerformingRecursiveRemapping = false; + vimState.wasPerformingRemapThatFinishedWaitingForTimeout = false; + await modeHandler.handleMultipleKeyEvents(keysPressedByUser); + } + } else { + await modeHandler.handleMultipleKeyEvents(keys); + } + return true; + } + } + + /** + * Buffer keys and create timeout + * 1. If the current keys have a potential remap AND + * 2. The timeout hasn't finished yet so we allow buffering keys AND + * 3. We allow potential remap on first key (check the note on RecordedState. TLDR: this will only + * be false for one key, the first one, when we resend keys that had a potential remap but no longer + * have it or the timeout finished) + * + * Points 1-3: If the current keys still have a potential remap and the timeout hasn't finished yet + * and we are not preventing a potential remap on the first key then we need to buffer this keys + * and wait for another key or the timeout to finish. + */ + if (isPotentialRemap && allowBufferingKeys && allowPotentialRemapOnFirstKey) { + if (remapping) { + // There are other potential remaps (ambiguous remaps), wait for other key or for the timeout + // to finish. Also store this current ambiguous remap on '_hasAmbiguousRemap' so that if later + // this ambiguous remap is broken or the user waits for timeout we don't need to go looking for + // it again. + this._hasAmbiguousRemap = remapping; + + this._logger.debug( + `${this._configKey}. ambiguous match found. before=${remapping.before}. after=${remapping.after}. command=${remapping.commands}. waiting for other key or timeout to finish.` + ); + } else { + this._hasPotentialRemap = true; + this._logger.debug( + `${this._configKey}. potential remap found. waiting for other key or timeout to finish.` + ); + } + + // Store BufferedKeys + vimState.recordedState.bufferedKeys = [...keys]; + + // Create Timeout + vimState.recordedState.bufferedKeysTimeoutObj = setTimeout(() => { + modeHandler.handleKeyEvent(SpecialKeys.TimeoutFinished); + }, configuration.timeout); + return true; + } + + /** + * Handle Remapping and any remaining keys + * If we get here with a remapping that means we need to handle it. + */ if (remapping) { + if (!allowBufferingKeys) { + // If the user already waited for the timeout to finish, prevent the + // remapping from waiting for the timeout again by making a clone of + // remapping and change 'after' to send the '' key at + // the end. + let newRemapping = { ...remapping }; + newRemapping.after = remapping.after?.slice(0); + newRemapping.after?.push(SpecialKeys.TimeoutFinished); + remapping = newRemapping; + } + + this._hasAmbiguousRemap = undefined; + this._hasPotentialRemap = false; + + let skipFirstCharacter = false; + + // If we were performing a remapping already, it means this remapping has a parent remapping + const hasParentRemapping = vimState.isCurrentlyPerformingRemapping; + if (!hasParentRemapping) { + vimState.mapDepth = 0; + } + + if (!remapping.recursive) { + vimState.isCurrentlyPerformingNonRecursiveRemapping = true; + } else { + vimState.isCurrentlyPerformingRecursiveRemapping = true; + + // As per the Vim documentation: (:help recursive) + // If the {rhs} starts with {lhs}, the first character is not mapped + // again (this is Vi compatible). + // For example: + // map ab abcd + // will execute the "a" command and insert "bcd" in the text. The "ab" + // in the {rhs} will not be mapped again. + if (remapping.after?.join('').startsWith(remapping.before.join(''))) { + skipFirstCharacter = true; + } + } + + // Increase mapDepth + vimState.mapDepth++; + this._logger.debug( - `${this._configKey}. match found. before=${remapping.before}. after=${remapping.after}. command=${remapping.commands}.` + `${this._configKey}. match found. before=${remapping.before}. after=${remapping.after}. command=${remapping.commands}. remainingKeys=${remainingKeys}. mapDepth=${vimState.mapDepth}.` ); - if (!this._recursive) { - vimState.isCurrentlyPerformingRemapping = true; - } + let remapFailed = false; try { - await this.handleRemapping(remapping, vimState, modeHandler); + // Check maxMapDepth + if (vimState.mapDepth >= configuration.maxmapdepth) { + const vimError = VimError.fromCode(ErrorCode.RecursiveMapping); + StatusBar.displayError(vimState, vimError); + throw ForceStopRemappingError.fromVimError(vimError); + } + + // Hacky code incoming!!! If someone has a better way to do this please change it + if (vimState.mapDepth % 10 === 0) { + // Allow the user to press or key when inside an infinite looping remap. + // When inside an infinite looping recursive mapping it would block the editor until it reached + // the maxmapdepth. This 0ms wait allows the extension to handle any key typed by the user which + // means it allows the user to press or to force stop the looping remap. + // This shouldn't impact the normal use case because we're only running this every 10 nested + // remaps. Also when the logs are set to Error only, a looping recursive remap takes around 1.5s + // to reach 1000 mapDepth and give back control to the user, but when logs are set to debug it + // can take as long as 7 seconds. + const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)); + await wait(0); + } + + vimState.remapUsedACharacter = false; + + await this.handleRemapping(remapping, vimState, modeHandler, skipFirstCharacter); + } catch (e) { + if (e instanceof ForceStopRemappingError) { + // If a motion fails or a VimError happens during any kind of remapping or if the user presses the + // force stop remapping key ( or ) during a recursive remapping it should stop handling + // the remap and all its parent remaps if we are on a chain of recursive remaps. + // (Vim documentation :help map-error) + remapFailed = true; + + // keep throwing until we reach the first parent + if (hasParentRemapping) { + throw e; + } + + this._logger.debug( + `${this._configKey}. Stopped the remapping in the middle, ignoring the rest. Reason: ${e.message}` + ); + } else { + // If some other error happens during the remapping handling it should stop the remap and rethrow + this._logger.debug( + `${this._configKey}. error found in the middle of remapping, ignoring the rest of the remap. error: ${e}` + ); + throw e; + } } finally { - vimState.isCurrentlyPerformingRemapping = false; - } + // Check if we are still inside a recursive remap + if (!hasParentRemapping && vimState.isCurrentlyPerformingRecursiveRemapping) { + // no more recursive remappings being handled + if (vimState.recordedState.bufferedKeysTimeoutObj !== undefined) { + // In order to be able to receive other keys and at the same time wait for timeout, we need + // to create a timeout and return from the remapper so that modeHandler can be free to receive + // more keys. This means that if we are inside a recursive remapping, when we return on the + // last key of that remapping it will think that it is finished and set the currently + // performing recursive remapping flag to false, which would result in the current bufferedKeys + // not knowing they had a parent remapping. So we store that remapping here. + vimState.wasPerformingRemapThatFinishedWaitingForTimeout = { ...remapping }; + } + vimState.isCurrentlyPerformingRecursiveRemapping = false; + } - return true; - } + if (!hasParentRemapping) { + // Last remapping finished handling. Set undo step. + vimState.historyTracker.finishCurrentStep(); + } - // Check to see if a remapping could potentially be applied when more keys are received - const keysAsString = keys.join(''); - for (let remap of userDefinedRemappings.keys()) { - if (remap.startsWith(keysAsString)) { - this._isPotentialRemap = true; - break; + // NonRecursive remappings can't have nested remaps so after a finished remap we always set this to + // false, because either we were performing a non recursive remap and now we finish or we weren't + // performing a non recursive remapping and this was false anyway. + vimState.isCurrentlyPerformingNonRecursiveRemapping = false; + + // if there were other remaining keys on the buffered keys that weren't part of the remapping + // handle them now, except if the remap failed and the remaining keys weren't typed by the user. + // (we know that if this remapping has a parent remapping then the remaining keys weren't typed + // by the user, but instead were sent by the parent remapping handler) + if (remainingKeys.length > 0 && !(remapFailed && hasParentRemapping)) { + if (vimState.wasPerformingRemapThatFinishedWaitingForTimeout) { + // If there was a performing remap that finished waiting for timeout then only the remaining keys + // that are not part of that remap were typed by the user. + let specialKey: string | undefined = ''; + if (remainingKeys[remainingKeys.length - 1] === SpecialKeys.TimeoutFinished) { + specialKey = remainingKeys.pop(); + } + const lastRemap = vimState.wasPerformingRemapThatFinishedWaitingForTimeout.after!; + const lastRemapWithoutAmbiguousRemap = lastRemap.slice(remapping.before.length); + const keysPressedByUser = remainingKeys.slice(lastRemapWithoutAmbiguousRemap.length); + remainingKeys = remainingKeys.slice(0, remainingKeys.length - keysPressedByUser.length); + if (specialKey) { + remainingKeys.push(specialKey); + if (keysPressedByUser.length !== 0) { + keysPressedByUser.push(specialKey); + } + } + try { + vimState.isCurrentlyPerformingRecursiveRemapping = true; + await modeHandler.handleMultipleKeyEvents(remainingKeys); + } catch (e) { + this._logger.debug( + `${this._configKey}. Stopped the remapping in the middle, ignoring the rest. Reason: ${e.message}` + ); + } finally { + vimState.isCurrentlyPerformingRecursiveRemapping = false; + vimState.wasPerformingRemapThatFinishedWaitingForTimeout = false; + if (keysPressedByUser.length > 0) { + await modeHandler.handleMultipleKeyEvents(keysPressedByUser); + } + } + } else { + await modeHandler.handleMultipleKeyEvents(remainingKeys); + } + } } + + return true; } + this._hasPotentialRemap = false; + this._hasAmbiguousRemap = undefined; return false; } private async handleRemapping( remapping: IKeyRemapping, vimState: VimState, - modeHandler: ModeHandler + modeHandler: ModeHandler, + skipFirstCharacter: boolean ) { - 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 === Mode.Insert) { - // Revert every single inserted character. - // We subtract 1 because we haven't actually applied the last key. - - // Make sure the resulting selection changed events are ignored - vimState.selectionsChanged.ignoreIntermediateSelections = true; - await vimState.historyTracker.undoAndRemoveChanges( - Math.max(0, numCharsToRemove * vimState.cursors.length) - ); - vimState.cursors = vimState.cursors.map((c) => - c.withNewStop(c.stop.getLeft(numCharsToRemove)) - ); - vimState.selectionsChanged.ignoreIntermediateSelections = false; - } - // We need to remove the keys that were remapped into different keys from the state. - vimState.recordedState.actionKeys = vimState.recordedState.actionKeys.slice( - 0, - -numCharsToRemove - ); - vimState.keyHistory = vimState.keyHistory.slice(0, -numCharsToRemove); - + vimState.recordedState.resetCommandList(); if (remapping.after) { - await modeHandler.handleMultipleKeyEvents(remapping.after); + if (skipFirstCharacter) { + vimState.isCurrentlyPerformingNonRecursiveRemapping = true; + await modeHandler.handleKeyEvent(remapping.after[0]); + vimState.isCurrentlyPerformingNonRecursiveRemapping = false; + await modeHandler.handleMultipleKeyEvents(remapping.after.slice(1)); + } else { + await modeHandler.handleMultipleKeyEvents(remapping.after); + } } if (remapping.commands) { @@ -201,7 +539,7 @@ export class Remapper implements IRemapper { } const range = Remapper.getRemappedKeysLengthRange(userDefinedRemappings); - const startingSliceLength = Math.max(range[1], inputtedKeys.length); + const startingSliceLength = inputtedKeys.length; for (let sliceLength = startingSliceLength; sliceLength >= range[0]; sliceLength--) { const keySlice = inputtedKeys.slice(-sliceLength).join(''); @@ -240,43 +578,66 @@ export class Remapper implements IRemapper { if (remappings.size === 0) { return [0, 0]; } - const keyLengths = Array.from(remappings.keys()).map((k) => k.length); + const keyLengths = Array.from(remappings.values()).map((remap) => remap.before.length); return [Math.min(...keyLengths), Math.max(...keyLengths)]; } + + /** + * Given list of keys and list of remappings, returns true if the keys are a potential remap + * @param keys the list of keys to be checked for potential remaps + * @param remappings The remappings Map + * @param countRemapAsPotential If the current keys are themselves a remap should they be considered a potential remap as well? + */ + protected static hasPotentialRemap( + keys: string[], + remappings: Map, + countRemapAsPotential: boolean = false + ): boolean { + const keysAsString = keys.join(''); + if (keysAsString !== '') { + for (let remap of remappings.keys()) { + if (remap.startsWith(keysAsString) && (remap !== keysAsString || countRemapAsPotential)) { + return true; + } + } + } + return false; + } } -function keyBindingsConfigKey(mode: string, recursive: boolean): string { - return `${mode}ModeKeyBindings${recursive ? '' : 'NonRecursive'}Map`; +function keyBindingsConfigKey(mode: string): string { + return `${mode}ModeKeyBindingsMap`; } class InsertModeRemapper extends Remapper { - constructor(recursive: boolean) { - super(keyBindingsConfigKey('insert', recursive), [Mode.Insert, Mode.Replace], recursive); + constructor() { + super(keyBindingsConfigKey('insert'), [Mode.Insert, Mode.Replace]); } } class NormalModeRemapper extends Remapper { - constructor(recursive: boolean) { - super(keyBindingsConfigKey('normal', recursive), [Mode.Normal], recursive); + constructor() { + super(keyBindingsConfigKey('normal'), [Mode.Normal]); + } +} + +class OperatorPendingModeRemapper extends Remapper { + constructor() { + super(keyBindingsConfigKey('operatorPending'), [Mode.OperatorPendingMode]); } } class VisualModeRemapper extends Remapper { - constructor(recursive: boolean) { - super( - keyBindingsConfigKey('visual', recursive), - [Mode.Visual, Mode.VisualLine, Mode.VisualBlock], - recursive - ); + constructor() { + super(keyBindingsConfigKey('visual'), [Mode.Visual, Mode.VisualLine, Mode.VisualBlock]); } } class CommandLineModeRemapper extends Remapper { - constructor(recursive: boolean) { - super( - keyBindingsConfigKey('commandLine', recursive), - [Mode.CommandlineInProgress, Mode.SearchInProgressMode], - recursive - ); + constructor() { + super(keyBindingsConfigKey('commandLine'), [ + Mode.CommandlineInProgress, + Mode.SearchInProgressMode, + ]); } } diff --git a/src/configuration/validators/remappingValidator.ts b/src/configuration/validators/remappingValidator.ts index fd273f4d3d5..a1cafbd472e 100644 --- a/src/configuration/validators/remappingValidator.ts +++ b/src/configuration/validators/remappingValidator.ts @@ -14,6 +14,8 @@ export class RemappingValidator implements IConfigurationValidator { 'insertModeKeyBindingsNonRecursive', 'normalModeKeyBindings', 'normalModeKeyBindingsNonRecursive', + 'operatorPendingModeKeyBindings', + 'operatorPendingModeKeyBindingsNonRecursive', 'visualModeKeyBindings', 'visualModeKeyBindingsNonRecursive', 'commandLineModeKeyBindings', @@ -21,11 +23,19 @@ export class RemappingValidator implements IConfigurationValidator { ]; for (const modeKeyBindingsKey of modeKeyBindingsKeys) { let keybindings = config[modeKeyBindingsKey]; + const isRecursive = modeKeyBindingsKey.indexOf('NonRecursive') === -1; - const modeKeyBindingsMap = new Map(); + const modeMapName = modeKeyBindingsKey.replace('NonRecursive', ''); + let modeKeyBindingsMap = config[modeMapName + 'Map'] as Map; + if (!modeKeyBindingsMap) { + modeKeyBindingsMap = new Map(); + } for (let i = keybindings.length - 1; i >= 0; i--) { let remapping = keybindings[i] as IKeyRemapping; + // set 'recursive' of the remapping according to where it was stored + remapping.recursive = isRecursive; + // validate let remappingError = await this.isRemappingValid(remapping); result.concat(remappingError); @@ -62,7 +72,7 @@ export class RemappingValidator implements IConfigurationValidator { modeKeyBindingsMap.set(beforeKeys, remapping); } - config[modeKeyBindingsKey + 'Map'] = modeKeyBindingsMap; + config[modeMapName + 'Map'] = modeKeyBindingsMap; } return result; @@ -89,6 +99,13 @@ export class RemappingValidator implements IConfigurationValidator { }); } + if (remapping.recursive === undefined) { + result.append({ + level: 'error', + message: `Remapping of '${remapping.before}' missing 'recursive' info.`, + }); + } + if (remapping.after && !(remapping.after instanceof Array)) { result.append({ level: 'error', diff --git a/src/configuration/vimrc.ts b/src/configuration/vimrc.ts index 24035954479..118edbdc333 100644 --- a/src/configuration/vimrc.ts +++ b/src/configuration/vimrc.ts @@ -8,7 +8,7 @@ import { vimrcKeyRemappingBuilder } from './vimrcKeyRemappingBuilder'; import { window } from 'vscode'; import { configuration } from './configuration'; -class VimrcImpl { +export class VimrcImpl { private _vimrcPath: string; /** @@ -56,6 +56,17 @@ class VimrcImpl { const remap = await vimrcKeyRemappingBuilder.build(line); if (remap) { VimrcImpl.addRemapToConfig(config, remap); + continue; + } + const unremap = await vimrcKeyRemappingBuilder.buildUnmapping(line); + if (unremap) { + VimrcImpl.removeRemapFromConfig(config, unremap); + continue; + } + const clearRemap = await vimrcKeyRemappingBuilder.buildClearMapping(line); + if (clearRemap) { + VimrcImpl.clearRemapsFromConfig(config, clearRemap); + continue; } } } catch (err) { @@ -67,32 +78,115 @@ class VimrcImpl { /** * Adds a remapping from .vimrc to the given configuration */ - private static addRemapToConfig(config: IConfiguration, remap: IVimrcKeyRemapping): void { + public static addRemapToConfig(config: IConfiguration, remap: IVimrcKeyRemapping): void { const mappings = (() => { switch (remap.keyRemappingType) { case 'map': - return [config.normalModeKeyBindings, config.visualModeKeyBindings]; + return [ + config.normalModeKeyBindings, + config.visualModeKeyBindings, + config.operatorPendingModeKeyBindings, + ]; case 'nmap': + case 'nma': + case 'nm': return [config.normalModeKeyBindings]; case 'vmap': + case 'vma': + case 'vm': + case 'xmap': + case 'xma': + case 'xm': return [config.visualModeKeyBindings]; case 'imap': + case 'ima': + case 'im': return [config.insertModeKeyBindings]; case 'cmap': + case 'cma': + case 'cm': return [config.commandLineModeKeyBindings]; + case 'omap': + case 'oma': + case 'om': + return [config.operatorPendingModeKeyBindings]; + case 'lmap': + case 'lma': + case 'lm': + case 'map!': + return [config.insertModeKeyBindings, config.commandLineModeKeyBindings]; case 'noremap': + case 'norema': + case 'norem': + case 'nore': + case 'nor': + case 'no': return [ config.normalModeKeyBindingsNonRecursive, config.visualModeKeyBindingsNonRecursive, + config.operatorPendingModeKeyBindingsNonRecursive, ]; case 'nnoremap': + case 'nnorema': + case 'nnorem': + case 'nnore': + case 'nnor': + case 'nno': + case 'nn': return [config.normalModeKeyBindingsNonRecursive]; case 'vnoremap': + case 'vnorema': + case 'vnorem': + case 'vnore': + case 'vnor': + case 'vno': + case 'vn': + case 'xnoremap': + case 'xnorema': + case 'xnorem': + case 'xnore': + case 'xnor': + case 'xno': + case 'xn': return [config.visualModeKeyBindingsNonRecursive]; case 'inoremap': + case 'inorema': + case 'inorem': + case 'inore': + case 'inor': + case 'ino': return [config.insertModeKeyBindingsNonRecursive]; case 'cnoremap': + case 'cnorema': + case 'cnorem': + case 'cnore': + case 'cnor': + case 'cno': return [config.commandLineModeKeyBindingsNonRecursive]; + case 'onoremap': + case 'onorema': + case 'onorem': + case 'onore': + case 'onor': + case 'ono': + return [config.operatorPendingModeKeyBindingsNonRecursive]; + case 'lnoremap': + case 'lnorema': + case 'lnorem': + case 'lnore': + case 'lnor': + case 'lno': + case 'ln': + case 'noremap!': + case 'norema!': + case 'norem!': + case 'nore!': + case 'nor!': + case 'no!': + return [ + config.insertModeKeyBindingsNonRecursive, + config.commandLineModeKeyBindingsNonRecursive, + ]; default: console.warn(`Encountered an unrecognized mapping type: '${remap.keyRemappingType}'`); return undefined; @@ -107,13 +201,193 @@ class VimrcImpl { }); } - private static removeAllRemapsFromConfig(config: IConfiguration): void { + /** + * Removes a remapping from .vimrc from the given configuration + */ + public static removeRemapFromConfig(config: IConfiguration, remap: IVimrcKeyRemapping): boolean { + const mappings = (() => { + switch (remap.keyRemappingType) { + case 'unmap': + case 'unma': + case 'unm': + return [ + config.normalModeKeyBindings, + config.normalModeKeyBindingsNonRecursive, + config.visualModeKeyBindings, + config.visualModeKeyBindingsNonRecursive, + config.operatorPendingModeKeyBindings, + config.operatorPendingModeKeyBindingsNonRecursive, + ]; + case 'nunmap': + case 'nunma': + case 'nunm': + case 'nun': + return [config.normalModeKeyBindings, config.normalModeKeyBindingsNonRecursive]; + case 'vunmap': + case 'vunma': + case 'vunm': + case 'vun': + case 'vu': + case 'xunmap': + case 'xunma': + case 'xunm': + case 'xun': + case 'xu': + return [config.visualModeKeyBindings, config.visualModeKeyBindingsNonRecursive]; + case 'iunmap': + case 'iunma': + case 'iunm': + case 'iun': + case 'iu': + return [config.insertModeKeyBindings, config.insertModeKeyBindingsNonRecursive]; + case 'cunmap': + case 'cunma': + case 'cunm': + case 'cun': + case 'cu': + return [config.commandLineModeKeyBindings, config.commandLineModeKeyBindingsNonRecursive]; + case 'ounmap': + case 'ounma': + case 'ounm': + case 'oun': + case 'ou': + return [ + config.operatorPendingModeKeyBindings, + config.operatorPendingModeKeyBindingsNonRecursive, + ]; + case 'lunmap': + case 'lunma': + case 'lunm': + case 'lun': + case 'lu': + case 'unmap!': + case 'unma!': + case 'unm!': + return [ + config.insertModeKeyBindings, + config.insertModeKeyBindingsNonRecursive, + config.commandLineModeKeyBindings, + config.commandLineModeKeyBindingsNonRecursive, + ]; + default: + console.warn(`Encountered an unrecognized unmapping type: '${remap.keyRemappingType}'`); + return undefined; + } + })(); + + if (mappings) { + mappings.forEach((remaps) => { + // Don't remove a mapping present in settings.json; those are more specific to VSCodeVim. + _.remove( + remaps, + (r) => r.source === 'vimrc' && _.isEqual(r.before, remap.keyRemapping.before) + ); + }); + return true; + } + return false; + } + + /** + * Clears all remappings from .vimrc from the given configuration for specific mode + */ + public static clearRemapsFromConfig(config: IConfiguration, remap: IVimrcKeyRemapping): boolean { + const mappings = (() => { + switch (remap.keyRemappingType) { + case 'mapclear': + case 'mapclea': + case 'mapcle': + case 'mapcl': + case 'mapc': + return [ + config.normalModeKeyBindings, + config.normalModeKeyBindingsNonRecursive, + config.visualModeKeyBindings, + config.visualModeKeyBindingsNonRecursive, + config.operatorPendingModeKeyBindings, + config.operatorPendingModeKeyBindingsNonRecursive, + ]; + case 'nmapclear': + case 'nmapclea': + case 'nmapcle': + case 'nmapcl': + case 'nmapc': + return [config.normalModeKeyBindings, config.normalModeKeyBindingsNonRecursive]; + case 'vmapclear': + case 'vmapclea': + case 'vmapcle': + case 'vmapcl': + case 'vmapc': + case 'xmapclear': + case 'xmapclea': + case 'xmapcle': + case 'xmapcl': + case 'xmapc': + return [config.visualModeKeyBindings, config.visualModeKeyBindingsNonRecursive]; + case 'imapclear': + case 'imapclea': + case 'imapcle': + case 'imapcl': + case 'imapc': + return [config.insertModeKeyBindings, config.insertModeKeyBindingsNonRecursive]; + case 'cmapclear': + case 'cmapclea': + case 'cmapcle': + case 'cmapcl': + case 'cmapc': + return [config.commandLineModeKeyBindings, config.commandLineModeKeyBindingsNonRecursive]; + case 'omapclear': + case 'omapclea': + case 'omapcle': + case 'omapcl': + case 'omapc': + return [ + config.operatorPendingModeKeyBindings, + config.operatorPendingModeKeyBindingsNonRecursive, + ]; + case 'lmapclear': + case 'lmapclea': + case 'lmapcle': + case 'lmapcl': + case 'lmapc': + case 'mapclear!': + case 'mapclea!': + case 'mapcle!': + case 'mapcl!': + case 'mapc!': + return [ + config.insertModeKeyBindings, + config.insertModeKeyBindingsNonRecursive, + config.commandLineModeKeyBindings, + config.commandLineModeKeyBindingsNonRecursive, + ]; + default: + console.warn( + `Encountered an unrecognized clearMapping type: '${remap.keyRemappingType}'` + ); + return undefined; + } + })(); + + if (mappings) { + mappings.forEach((remaps) => { + // Don't remove a mapping present in settings.json; those are more specific to VSCodeVim. + _.remove(remaps, (r) => r.source === 'vimrc'); + }); + return true; + } + return false; + } + + public static removeAllRemapsFromConfig(config: IConfiguration): void { const remapCollections = [ config.normalModeKeyBindings, + config.operatorPendingModeKeyBindings, config.visualModeKeyBindings, config.insertModeKeyBindings, config.commandLineModeKeyBindings, config.normalModeKeyBindingsNonRecursive, + config.operatorPendingModeKeyBindingsNonRecursive, config.visualModeKeyBindingsNonRecursive, config.insertModeKeyBindingsNonRecursive, config.commandLineModeKeyBindingsNonRecursive, diff --git a/src/configuration/vimrcKeyRemappingBuilder.ts b/src/configuration/vimrcKeyRemappingBuilder.ts index 2113e7e3f8d..eb153c827c9 100644 --- a/src/configuration/vimrcKeyRemappingBuilder.ts +++ b/src/configuration/vimrcKeyRemappingBuilder.ts @@ -2,9 +2,135 @@ import * as vscode from 'vscode'; import { IKeyRemapping, IVimrcKeyRemapping } from './iconfiguration'; class VimrcKeyRemappingBuilderImpl { - private static readonly KEY_REMAPPING_REG_EX = /(^.*map)\s([\S]+)\s+(?!)([\S]+)$/; + /** + * Regex for mapping lines + * + * * `^(` -> start of mapping type capture + * * `map!?`\ + * _matches:_ + * * :map + * * :map! + * + * * `|smap`\ + * _matches:_ + * * :smap + * + * * `|[nvxoilc]m(?:a(?:p)?)?`\ + * _matches:_ + * * :nm[ap] + * * :vm[ap] + * * :xm[ap] + * * :om[ap] + * * :im[ap] + * * :lm[ap] + * * :cm[ap] + * + * * `|(?:` + * * `[nvxl]no?r?|`\ + * _matches:_ + * * :nn[or] + * * :vn[or] + * * :xn[or] + * * :ln[or] + * + * * `[oic]nor?|`\ + * _matches:_ + * * :ono[r] + * * :ino[r] + * * :cno[r] + * + * * `snor`\ + * _matches:_ + * * :snor + * * `)(?:e(?:m(?:a(?:p)?)?)?)?`\ + * _matches the remaining optional [emap]_ + * + * * `|no(?:r(?:e(?:m(?:a(?:p)?)?)?)?)?!?`\ + * _matches:_ + * * :no[remap] + * * :no[remap]! + * * `)` -> end of mapping type capture + * + * * `(?!.*(?:|