diff --git a/README.md b/README.md index ca0c3ebc4..b0e873a67 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ VSCodeVim is a Vim emulator for [Visual Studio Code](https://code.visualstudio.c - [vim-commentary](#vim-commentary) - [vim-indent-object](#vim-indent-object) - [vim-sneak](#vim-sneak) + - [CamelCaseMotion](#camelcasemotion) - [Input Method](#input-method) - [VSCodeVim tricks](#-vscodevim-tricks) - [F.A.Q / Troubleshooting](#-faq) @@ -505,6 +506,25 @@ Once sneak is active, initiate motions using the following commands. For operato | `z` | Perform `` forward to the first occurence of `` | | `Z` | Perform `` backward to the first occurence of `` | +### CamelCaseMotion + +Based on [CamelCaseMotion](https://github.com/bkad/CamelCaseMotion), though not an exact emulation. This plugin provides an easier way to move through camelCase and snake_case words. + +| Setting | Description | Type | Default Value | +| -------------------------- | ------------------------------ | ------- | ------------- | +| vim.camelCaseMotion.enable | Enable/disable CamelCaseMotion | Boolean | false | + +Once CamelCaseMotion is enabled, the following motions are available: + +| Motion Command | Description | +| ---------------------- | -------------------------------------------------------------------------- | +| `w` | Move forward to the start of the next camelCase or snake_case word segment | +| `e` | Move forward to the next end of a camelCase or snake_case word segment | +| `b` | Move back to the prior beginning of a camelCase or snake_case word segment | +| `iw` | Select/change/delete/etc. the current camelCase or snake_case word segment | + +By default, `` is mapped to `\`, so for example, `d2i\w` would delete the current and next camelCase word segment. + ### Input Method Disable input method when exiting Insert Mode. diff --git a/build/Dockerfile b/build/Dockerfile index d9fbdcf9d..7d34e243a 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM node:8.15 +FROM node:10.15 ARG DEBIAN_FRONTEND=noninteractive diff --git a/package-lock.json b/package-lock.json index 864870edf..b24b64c90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,9 +87,9 @@ "dev": true }, "@types/node": { - "version": "9.6.42", - "resolved": "https://registry.npmjs.org/@types/node/-/node-9.6.42.tgz", - "integrity": "sha512-SpeVQJFekfnEaZZO1yl4je/36upII36L7gOT4DBx51B1GeAB45mmDb3a5OBQB+ZeFxVVOP37r8Owsl940G/fBg==", + "version": "10.12.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.25.tgz", + "integrity": "sha512-IcvnGLGSQFDvC07Bz2I8SX+QKErDZbUdiQq7S2u3XyzTyJfUmT0sWJMbeQkMzpTAkO7/N7sZpW/arUM2jfKsbQ==", "dev": true }, "@types/sinon": { diff --git a/package.json b/package.json index e22021d94..436c69008 100644 --- a/package.json +++ b/package.json @@ -449,6 +449,11 @@ "description": "Override the 'ignorecase' option if the search pattern contains upper case characters.", "default": true }, + "vim.camelCaseMotion.enable": { + "type": "boolean", + "description": "Enable the CamelCaseMotion plugin for Vim.", + "default": false + }, "vim.easymotion": { "type": "boolean", "description": "Enable the EasyMotion plugin for Vim.", @@ -711,7 +716,7 @@ "@types/diff-match-patch": "1.0.32", "@types/lodash": "4.14.121", "@types/mocha": "5.2.5", - "@types/node": "9.6.42", + "@types/node": "10.12.25", "@types/sinon": "7.0.5", "gulp": "4.0.0", "gulp-bump": "3.1.3", diff --git a/src/actions/include-all.ts b/src/actions/include-all.ts index 6324005c3..a6bf217a7 100644 --- a/src/actions/include-all.ts +++ b/src/actions/include-all.ts @@ -8,6 +8,7 @@ import './commands/insert'; import './commands/actions'; // plugin +import './plugins/camelCaseMotion'; import './plugins/easymotion/easymotion.cmd'; import './plugins/easymotion/registerMoveActions'; import './plugins/sneak'; diff --git a/src/actions/plugins/camelCaseMotion.ts b/src/actions/plugins/camelCaseMotion.ts new file mode 100644 index 000000000..e0ffce5e5 --- /dev/null +++ b/src/actions/plugins/camelCaseMotion.ts @@ -0,0 +1,117 @@ +import { TextObjectMovement } from '../textobject'; +import { RegisterAction } from '../base'; +import { ModeName } from '../../mode/mode'; +import { Position } from '../../common/motion/position'; +import { VimState } from '../../state/vimState'; +import { IMovement, BaseMovement } from '../motion'; +import { TextEditor } from '../../textEditor'; +import { configuration } from '../../configuration/configuration'; +import { ChangeOperator } from '../operator'; + +class CamelCaseBaseMovement extends BaseMovement { + public doesActionApply(vimState: VimState, keysPressed: string[]) { + return configuration.camelCaseMotion.enable && super.doesActionApply(vimState, keysPressed); + } + + public couldActionApply(vimState: VimState, keysPressed: string[]) { + return configuration.camelCaseMotion.enable && super.couldActionApply(vimState, keysPressed); + } +} + +class CamelCaseTextObjectMovement extends TextObjectMovement { + public doesActionApply(vimState: VimState, keysPressed: string[]) { + return configuration.camelCaseMotion.enable && super.doesActionApply(vimState, keysPressed); + } + + public couldActionApply(vimState: VimState, keysPressed: string[]) { + return configuration.camelCaseMotion.enable && super.couldActionApply(vimState, keysPressed); + } +} + +// based off of `MoveWordBegin` +@RegisterAction +class MoveCamelCaseWordBegin extends CamelCaseBaseMovement { + keys = ['', 'w']; + + public async execAction(position: Position, vimState: VimState): Promise { + if ( + !configuration.changeWordIncludesWhitespace && + vimState.recordedState.operator instanceof ChangeOperator + ) { + // TODO use execForOperator? Or maybe dont? + + // See note for w + return position.getCurrentCamelCaseWordEnd().getRight(); + } else { + return position.getCamelCaseWordRight(); + } + } +} + +// based off of `MoveWordEnd` +@RegisterAction +class MoveCamelCaseWordEnd extends CamelCaseBaseMovement { + keys = ['', 'e']; + + public async execAction(position: Position, vimState: VimState): Promise { + return position.getCurrentCamelCaseWordEnd(); + } + + public async execActionForOperator(position: Position, vimState: VimState): Promise { + let end = position.getCurrentCamelCaseWordEnd(); + + return new Position(end.line, end.character + 1); + } +} + +// based off of `MoveBeginningWord` +@RegisterAction +class MoveBeginningCamelCaseWord extends CamelCaseBaseMovement { + keys = ['', 'b']; + + public async execAction(position: Position, vimState: VimState): Promise { + return position.getCamelCaseWordLeft(); + } +} + +// based off of `SelectInnerWord` +@RegisterAction +export class SelectInnerCamelCaseWord extends CamelCaseTextObjectMovement { + modes = [ModeName.Normal, ModeName.Visual]; + keys = ['i', '', 'w']; + + public async execAction(position: Position, vimState: VimState): Promise { + let start: Position; + let stop: Position; + const currentChar = TextEditor.getLineAt(position).text[position.character]; + + if (/\s/.test(currentChar)) { + start = position.getLastCamelCaseWordEnd().getRight(); + stop = position.getCamelCaseWordRight().getLeftThroughLineBreaks(); + } else { + start = position.getCamelCaseWordLeft(true); + stop = position.getCurrentCamelCaseWordEnd(true); + } + + if ( + vimState.currentMode === ModeName.Visual && + !vimState.cursorStopPosition.isEqual(vimState.cursorStartPosition) + ) { + start = vimState.cursorStartPosition; + + if (vimState.cursorStopPosition.isBefore(vimState.cursorStartPosition)) { + // If current cursor postion is before cursor start position, we are selecting words in reverser order. + if (/\s/.test(currentChar)) { + stop = position.getLastCamelCaseWordEnd().getRight(); + } else { + stop = position.getCamelCaseWordLeft(true); + } + } + } + + return { + start: start, + stop: stop, + }; + } +} diff --git a/src/common/motion/position.ts b/src/common/motion/position.ts index e860d912b..d4e52bb8f 100644 --- a/src/common/motion/position.ts +++ b/src/common/motion/position.ts @@ -101,6 +101,7 @@ export class Position extends vscode.Position { private _nonWordCharRegex: RegExp; private _nonBigWordCharRegex: RegExp; + private _nonCamelCaseWordCharRegex: RegExp; private _sentenceEndRegex: RegExp; private _nonFileNameRegex: RegExp; @@ -109,6 +110,7 @@ export class Position extends vscode.Position { this._nonWordCharRegex = this.makeWordRegex(Position.NonWordCharacters); this._nonBigWordCharRegex = this.makeWordRegex(Position.NonBigWordCharacters); + this._nonCamelCaseWordCharRegex = this.makeCamelCaseWordRegex(Position.NonWordCharacters); this._sentenceEndRegex = /[\.!\?]{1}([ \n\t]+|$)/g; this._nonFileNameRegex = this.makeWordRegex(Position.NonFileCharacters); } @@ -516,6 +518,10 @@ export class Position extends vscode.Position { return this.getWordLeftWithRegex(this._nonBigWordCharRegex, inclusive); } + public getCamelCaseWordLeft(inclusive: boolean = false): Position { + return this.getWordLeftWithRegex(this._nonCamelCaseWordCharRegex, inclusive); + } + public getFilePathLeft(inclusive: boolean = false): Position { return this.getWordLeftWithRegex(this._nonFileNameRegex, inclusive); } @@ -531,6 +537,10 @@ export class Position extends vscode.Position { return this.getWordRightWithRegex(this._nonBigWordCharRegex); } + public getCamelCaseWordRight(inclusive: boolean = false): Position { + return this.getWordRightWithRegex(this._nonCamelCaseWordCharRegex); + } + public getFilePathRight(inclusive: boolean = false): Position { return this.getWordRightWithRegex(this._nonFileNameRegex, inclusive); } @@ -543,6 +553,10 @@ export class Position extends vscode.Position { return this.getLastWordEndWithRegex(this._nonBigWordCharRegex); } + public getLastCamelCaseWordEnd(): Position { + return this.getLastWordEndWithRegex(this._nonCamelCaseWordCharRegex); + } + /** * Inclusive is true if we consider the current position a valid result, false otherwise. */ @@ -557,6 +571,13 @@ export class Position extends vscode.Position { return this.getCurrentWordEndWithRegex(this._nonBigWordCharRegex, inclusive); } + /** + * Inclusive is true if we consider the current position a valid result, false otherwise. + */ + public getCurrentCamelCaseWordEnd(inclusive: boolean = false): Position { + return this.getCurrentWordEndWithRegex(this._nonCamelCaseWordCharRegex, inclusive); + } + /** * Get the boundary position of the section. */ @@ -831,6 +852,39 @@ export class Position extends vscode.Position { return result; } + private makeCamelCaseWordRegex(characterSet: string): RegExp { + const escaped = characterSet && _.escapeRegExp(characterSet).replace(/-/g, '\\-'); + const segments: string[] = []; + + // prettier-ignore + const firstSegment = + '(' + // OPEN: group for matching camel case words + `[^\\s${escaped}]` + // words can start with any word character + '(?:' + // OPEN: group for characters after initial char + `(?:(?<=[A-Z_])[A-Z](?=[\\sA-Z0-9${escaped}_]))+` + // If first char was a capital + // the word can continue with all caps + '|' + // OR + `(?:(?<=[0-9_])[0-9](?=[\\sA-Z0-9${escaped}_]))+` + // If first char was a digit + // the word can continue with all digits + '|' + // OR + `(?:(?<=[_])[_](?=[\\s${escaped}_]))+` + // Continue with all underscores + '|' + // OR + `[^\\sA-Z0-9${escaped}_]*` + // Continue with regular characters + ')' + // END: group for characters after initial char + ')' + // END: group for matching camel case words + ''; + + segments.push(firstSegment); + segments.push(`[${escaped}]+`); + segments.push(`$^`); + + // it can be difficult to grok the behavior of the above regex + // feel free to check out https://regex101.com/r/mkVeiH/1 as a live example + const result = new RegExp(segments.join('|'), 'g'); + + return result; + } + private getAllPositions(line: string, regex: RegExp): number[] { let positions: number[] = []; let result = regex.exec(line); @@ -987,7 +1041,7 @@ export class Position extends vscode.Position { .getRightThroughLineBreaks() .compareTo(this); - return (newPositionBeforeThis && (index < this.character || currentLine < this.line)); + return newPositionBeforeThis && (index < this.character || currentLine < this.line); }); if (newCharacter !== undefined) { diff --git a/src/configuration/configuration.ts b/src/configuration/configuration.ts index e99555cef..49dadbdb7 100644 --- a/src/configuration/configuration.ts +++ b/src/configuration/configuration.ts @@ -11,6 +11,7 @@ import { IAutoSwitchInputMethod, IDebugConfiguration, Digraph, + ICamelCaseMotionConfiguration, } from './iconfiguration'; const packagejson: { @@ -170,6 +171,10 @@ class Configuration implements IConfiguration { autoindent = true; + camelCaseMotion: ICamelCaseMotionConfiguration = { + enable: true, + }; + sneak = false; sneakUseIgnorecaseAndSmartcase = false; diff --git a/src/configuration/iconfiguration.ts b/src/configuration/iconfiguration.ts index 942feb2a8..c824d9225 100644 --- a/src/configuration/iconfiguration.ts +++ b/src/configuration/iconfiguration.ts @@ -42,6 +42,13 @@ export interface IDebugConfiguration { loggingLevelForConsole: 'error' | 'warn' | 'info' | 'verbose' | 'debug'; } +export interface ICamelCaseMotionConfiguration { + /** + * Enable CamelCaseMotion plugin or not + */ + enable: boolean; +} + export interface IConfiguration { /** * Use the system's clipboard when copying. @@ -84,6 +91,11 @@ export interface IConfiguration { */ autoindent: boolean; + /** + * CamelCaseMotion plugin options + */ + camelCaseMotion: ICamelCaseMotionConfiguration; + /** * Use EasyMotion plugin? */ diff --git a/test/plugins/camelCaseMotion.test.ts b/test/plugins/camelCaseMotion.test.ts new file mode 100644 index 000000000..ae2968e36 --- /dev/null +++ b/test/plugins/camelCaseMotion.test.ts @@ -0,0 +1,296 @@ +import { getTestingFunctions } from '../testSimplifier'; +import { cleanUpWorkspace, setupWorkspace, reloadConfiguration } from './../testUtils'; +import { Configuration } from '../testConfiguration'; + +const { newTest } = getTestingFunctions(); + +suite('camelCaseMotion plugin if not enabled', () => { + setup(async () => { + const configuration = new Configuration(); + configuration.camelCaseMotion.enable = false; + await setupWorkspace(configuration); + }); + + teardown(cleanUpWorkspace); + + newTest({ + title: "basic motion doesn't work", + start: ['|camelWord'], + keysPressed: 'w', + end: ['|camelWord'], + }); +}); + +suite('camelCaseMotion plugin', () => { + setup(async () => { + const configuration = new Configuration(); + configuration.camelCaseMotion.enable = true; + await setupWorkspace(configuration); + }); + + teardown(cleanUpWorkspace); + + suite('handles w for camelCaseText', () => { + newTest({ + title: 'step over whitespace', + start: ['|var testCamelVARWithNums555&&&Ops'], + keysPressed: 'w', + end: ['var |testCamelVARWithNums555&&&Ops'], + }); + + newTest({ + title: 'step to Camel word', + start: ['var |testCamelVARWithNums555&&&Ops'], + keysPressed: 'w', + end: ['var test|CamelVARWithNums555&&&Ops'], + }); + + newTest({ + title: 'step to CAP word', + start: ['var test|CamelVARWithNums555&&&Ops'], + keysPressed: 'w', + end: ['var testCamel|VARWithNums555&&&Ops'], + }); + + newTest({ + title: 'step after CAP word', + start: ['var testCamel|VARWithNums555&&&Ops'], + keysPressed: 'w', + end: ['var testCamelVAR|WithNums555&&&Ops'], + }); + + newTest({ + title: 'step from middle of word to Camel word', + start: ['var testCamelVARW|ithNums555&&&Ops'], + keysPressed: 'w', + end: ['var testCamelVARWith|Nums555&&&Ops'], + }); + + newTest({ + title: 'step to number word', + start: ['var testCamelVARWith|Nums555&&&Ops'], + keysPressed: 'w', + end: ['var testCamelVARWithNums|555&&&Ops'], + }); + + newTest({ + title: 'step to operator word', + start: ['var testCamelVARWithNums|555&&&Ops'], + keysPressed: 'w', + end: ['var testCamelVARWithNums555|&&&Ops'], + }); + + newTest({ + title: 'step from inside operator word', + start: ['var testCamelVARWithNums555&|&&Ops'], + keysPressed: 'w', + end: ['var testCamelVARWithNums555&&&|Ops'], + }); + + newTest({ + title: 'step to operator and then over', + start: ['|camel.camelWord'], + keysPressed: '2w', + end: ['camel.|camelWord'], + }); + }); + + suite('handles w for underscore_var', () => { + newTest({ + title: 'step to _word', + start: ['|some_var and_other23_var'], + keysPressed: 'w', + end: ['some|_var and_other23_var'], + }); + + newTest({ + title: 'step over whitespace to word', + start: ['some|_var and_other23_var'], + keysPressed: 'w', + end: ['some_var |and_other23_var'], + }); + + newTest({ + title: 'step from inside word to _word', + start: ['some_var a|nd_other23_var'], + keysPressed: 'w', + end: ['some_var and|_other23_var'], + }); + + newTest({ + title: 'step form _word to number', + start: ['some_var and|_other23_var'], + keysPressed: 'w', + end: ['some_var and_other|23_var'], + }); + + newTest({ + title: 'step from nubmer word to _word', + start: ['some_var and_other2|3_var'], + keysPressed: 'w', + end: ['some_var and_other23|_var'], + }); + + newTest({ + title: 'step from in whitespace to word', + start: ['variable | more_vars'], + keysPressed: 'w', + end: ['variable |more_vars'], + }); + + newTest({ + title: 'step in ALL_CAPS_WORD', + start: ['A|LL_CAPS_WORD'], + keysPressed: '2w', + end: ['ALL_CAPS|_WORD'], + }); + }); + + suite('handles dw', () => { + newTest({ + title: 'delete from start of camelWord', + start: ['|camelTwoWord'], + keysPressed: 'dw', + end: ['|TwoWord'], + }); + + newTest({ + title: 'delete from middle of camelWord', + start: ['ca|melTwoWord'], + keysPressed: 'dw', + end: ['ca|TwoWord'], + }); + + newTest({ + title: 'delete from start of CamelWord', + start: ['camel|TwoWord'], + keysPressed: 'dw', + end: ['camel|Word'], + }); + + newTest({ + title: 'delete two words from camelWord', + start: ['ca|melTwoWord'], + keysPressed: '2dw', + end: ['ca|Word'], + }); + + newTest({ + title: 'delete from start of underscore_word', + start: ['|camel_two_word'], + keysPressed: 'dw', + end: ['|_two_word'], + }); + + newTest({ + title: 'delete from middle of underscore_word', + start: ['ca|mel_two_word'], + keysPressed: 'dw', + end: ['ca|_two_word'], + }); + + newTest({ + title: 'delete two words from camel_word', + start: ['ca|mel_two_word'], + keysPressed: '2dw', + end: ['ca|_word'], + }); + }); + + suite('handles diw', () => { + newTest({ + title: 'delete from start of camelWord', + start: ['|camelTwoWord'], + keysPressed: 'diw', + end: ['|TwoWord'], + }); + + newTest({ + title: 'delete from middle of camelWord', + start: ['ca|melTwoWord'], + keysPressed: 'diw', + end: ['|TwoWord'], + }); + + newTest({ + title: 'delete from start of CamelWord', + start: ['camel|TwoWord'], + keysPressed: 'diw', + end: ['camel|Word'], + }); + + newTest({ + title: 'delete two words from camelWord', + start: ['ca|melTwoWord'], + keysPressed: '2diw', + end: ['|Word'], + }); + + newTest({ + title: 'delete from start of underscore_word', + start: ['|camel_two_word'], + keysPressed: 'diw', + end: ['|_two_word'], + }); + + newTest({ + title: 'delete from middle of underscore_word', + start: ['ca|mel_two_word'], + keysPressed: 'diw', + end: ['|_two_word'], + }); + + newTest({ + title: 'delete two words from camel_word', + start: ['ca|mel_two_word'], + keysPressed: '2diw', + end: ['|_word'], + }); + }); + + suite('handles b', () => { + newTest({ + title: 'back from middle of word', + start: ['camel.camelWord oth|er'], + keysPressed: 'b', + end: ['camel.camelWord |other'], + }); + + newTest({ + title: 'back over whitespace to camelWord', + start: ['camel.camelWord |other'], + keysPressed: 'b', + end: ['camel.camel|Word other'], + }); + + newTest({ + title: 'back twice over operator', + start: ['camel.camel|Word other'], + keysPressed: '2b', + end: ['camel|.camelWord other'], + }); + }); + + suite('handles e', () => { + newTest({ + title: 'from start to middle of underscore_word', + start: ['|foo_bar && camelCase'], + keysPressed: 'e', + end: ['fo|o_bar && camelCase'], + }); + + newTest({ + title: 'from middle to end of underscore_word', + start: ['fo|o_bar && camelCase'], + keysPressed: 'e', + end: ['foo_ba|r && camelCase'], + }); + + newTest({ + title: 'twice to end of word over operator', + start: ['foo_ba|r && camelCase'], + keysPressed: '2e', + end: ['foo_bar && came|lCase'], + }); + }); +}); diff --git a/test/testConfiguration.ts b/test/testConfiguration.ts index de5157efd..ce011581e 100644 --- a/test/testConfiguration.ts +++ b/test/testConfiguration.ts @@ -15,6 +15,9 @@ export class Configuration implements IConfiguration { ignorecase = true; smartcase = true; autoindent = true; + camelCaseMotion = { + enable: false, + }; sneak = false; sneakUseIgnorecaseAndSmartcase = false; surround = true;