Add CamelCaseMotion plugin (#3483)

* Add CamelCaseMotion Plugin

Fixes https://github.com/VSCodeVim/Vim/issues/1220

* update @node/types

* adjust tests to only load config once

* nest camelCaseMotion configuration

* fix merge error
This commit is contained in:
Jason Killian 2019-02-18 00:33:13 -05:00 committed by Jason Poon
parent fa0285dc8d
commit d8564f90a6
11 changed files with 519 additions and 6 deletions

View File

@ -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
| `<operator>z<char><char>` | Perform `<operator>` forward to the first occurence of `<char><char>` |
| `<operator>Z<char><char>` | Perform `<operator>` backward to the first occurence of `<char><char>` |
### 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 |
| ---------------------- | -------------------------------------------------------------------------- |
| `<leader>w` | Move forward to the start of the next camelCase or snake_case word segment |
| `<leader>e` | Move forward to the next end of a camelCase or snake_case word segment |
| `<leader>b` | Move back to the prior beginning of a camelCase or snake_case word segment |
| `<operator>i<leader>w` | Select/change/delete/etc. the current camelCase or snake_case word segment |
By default, `<leader>` 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.

View File

@ -1,4 +1,4 @@
FROM node:8.15
FROM node:10.15
ARG DEBIAN_FRONTEND=noninteractive

6
package-lock.json generated
View File

@ -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": {

View File

@ -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",

View File

@ -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';

View File

@ -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 = ['<leader>', 'w'];
public async execAction(position: Position, vimState: VimState): Promise<Position> {
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 = ['<leader>', 'e'];
public async execAction(position: Position, vimState: VimState): Promise<Position> {
return position.getCurrentCamelCaseWordEnd();
}
public async execActionForOperator(position: Position, vimState: VimState): Promise<Position> {
let end = position.getCurrentCamelCaseWordEnd();
return new Position(end.line, end.character + 1);
}
}
// based off of `MoveBeginningWord`
@RegisterAction
class MoveBeginningCamelCaseWord extends CamelCaseBaseMovement {
keys = ['<leader>', 'b'];
public async execAction(position: Position, vimState: VimState): Promise<Position> {
return position.getCamelCaseWordLeft();
}
}
// based off of `SelectInnerWord`
@RegisterAction
export class SelectInnerCamelCaseWord extends CamelCaseTextObjectMovement {
modes = [ModeName.Normal, ModeName.Visual];
keys = ['i', '<leader>', 'w'];
public async execAction(position: Position, vimState: VimState): Promise<IMovement> {
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,
};
}
}

View File

@ -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) {

View File

@ -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;

View File

@ -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?
*/

View File

@ -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: '<leader>w',
end: ['|camelWord'],
});
});
suite('camelCaseMotion plugin', () => {
setup(async () => {
const configuration = new Configuration();
configuration.camelCaseMotion.enable = true;
await setupWorkspace(configuration);
});
teardown(cleanUpWorkspace);
suite('handles <leader>w for camelCaseText', () => {
newTest({
title: 'step over whitespace',
start: ['|var testCamelVARWithNums555&&&Ops'],
keysPressed: '<leader>w',
end: ['var |testCamelVARWithNums555&&&Ops'],
});
newTest({
title: 'step to Camel word',
start: ['var |testCamelVARWithNums555&&&Ops'],
keysPressed: '<leader>w',
end: ['var test|CamelVARWithNums555&&&Ops'],
});
newTest({
title: 'step to CAP word',
start: ['var test|CamelVARWithNums555&&&Ops'],
keysPressed: '<leader>w',
end: ['var testCamel|VARWithNums555&&&Ops'],
});
newTest({
title: 'step after CAP word',
start: ['var testCamel|VARWithNums555&&&Ops'],
keysPressed: '<leader>w',
end: ['var testCamelVAR|WithNums555&&&Ops'],
});
newTest({
title: 'step from middle of word to Camel word',
start: ['var testCamelVARW|ithNums555&&&Ops'],
keysPressed: '<leader>w',
end: ['var testCamelVARWith|Nums555&&&Ops'],
});
newTest({
title: 'step to number word',
start: ['var testCamelVARWith|Nums555&&&Ops'],
keysPressed: '<leader>w',
end: ['var testCamelVARWithNums|555&&&Ops'],
});
newTest({
title: 'step to operator word',
start: ['var testCamelVARWithNums|555&&&Ops'],
keysPressed: '<leader>w',
end: ['var testCamelVARWithNums555|&&&Ops'],
});
newTest({
title: 'step from inside operator word',
start: ['var testCamelVARWithNums555&|&&Ops'],
keysPressed: '<leader>w',
end: ['var testCamelVARWithNums555&&&|Ops'],
});
newTest({
title: 'step to operator and then over',
start: ['|camel.camelWord'],
keysPressed: '2<leader>w',
end: ['camel.|camelWord'],
});
});
suite('handles <leader>w for underscore_var', () => {
newTest({
title: 'step to _word',
start: ['|some_var and_other23_var'],
keysPressed: '<leader>w',
end: ['some|_var and_other23_var'],
});
newTest({
title: 'step over whitespace to word',
start: ['some|_var and_other23_var'],
keysPressed: '<leader>w',
end: ['some_var |and_other23_var'],
});
newTest({
title: 'step from inside word to _word',
start: ['some_var a|nd_other23_var'],
keysPressed: '<leader>w',
end: ['some_var and|_other23_var'],
});
newTest({
title: 'step form _word to number',
start: ['some_var and|_other23_var'],
keysPressed: '<leader>w',
end: ['some_var and_other|23_var'],
});
newTest({
title: 'step from nubmer word to _word',
start: ['some_var and_other2|3_var'],
keysPressed: '<leader>w',
end: ['some_var and_other23|_var'],
});
newTest({
title: 'step from in whitespace to word',
start: ['variable | more_vars'],
keysPressed: '<leader>w',
end: ['variable |more_vars'],
});
newTest({
title: 'step in ALL_CAPS_WORD',
start: ['A|LL_CAPS_WORD'],
keysPressed: '2<leader>w',
end: ['ALL_CAPS|_WORD'],
});
});
suite('handles d<leader>w', () => {
newTest({
title: 'delete from start of camelWord',
start: ['|camelTwoWord'],
keysPressed: 'd<leader>w',
end: ['|TwoWord'],
});
newTest({
title: 'delete from middle of camelWord',
start: ['ca|melTwoWord'],
keysPressed: 'd<leader>w',
end: ['ca|TwoWord'],
});
newTest({
title: 'delete from start of CamelWord',
start: ['camel|TwoWord'],
keysPressed: 'd<leader>w',
end: ['camel|Word'],
});
newTest({
title: 'delete two words from camelWord',
start: ['ca|melTwoWord'],
keysPressed: '2d<leader>w',
end: ['ca|Word'],
});
newTest({
title: 'delete from start of underscore_word',
start: ['|camel_two_word'],
keysPressed: 'd<leader>w',
end: ['|_two_word'],
});
newTest({
title: 'delete from middle of underscore_word',
start: ['ca|mel_two_word'],
keysPressed: 'd<leader>w',
end: ['ca|_two_word'],
});
newTest({
title: 'delete two words from camel_word',
start: ['ca|mel_two_word'],
keysPressed: '2d<leader>w',
end: ['ca|_word'],
});
});
suite('handles di<leader>w', () => {
newTest({
title: 'delete from start of camelWord',
start: ['|camelTwoWord'],
keysPressed: 'di<leader>w',
end: ['|TwoWord'],
});
newTest({
title: 'delete from middle of camelWord',
start: ['ca|melTwoWord'],
keysPressed: 'di<leader>w',
end: ['|TwoWord'],
});
newTest({
title: 'delete from start of CamelWord',
start: ['camel|TwoWord'],
keysPressed: 'di<leader>w',
end: ['camel|Word'],
});
newTest({
title: 'delete two words from camelWord',
start: ['ca|melTwoWord'],
keysPressed: '2di<leader>w',
end: ['|Word'],
});
newTest({
title: 'delete from start of underscore_word',
start: ['|camel_two_word'],
keysPressed: 'di<leader>w',
end: ['|_two_word'],
});
newTest({
title: 'delete from middle of underscore_word',
start: ['ca|mel_two_word'],
keysPressed: 'di<leader>w',
end: ['|_two_word'],
});
newTest({
title: 'delete two words from camel_word',
start: ['ca|mel_two_word'],
keysPressed: '2di<leader>w',
end: ['|_word'],
});
});
suite('handles <leader>b', () => {
newTest({
title: 'back from middle of word',
start: ['camel.camelWord oth|er'],
keysPressed: '<leader>b',
end: ['camel.camelWord |other'],
});
newTest({
title: 'back over whitespace to camelWord',
start: ['camel.camelWord |other'],
keysPressed: '<leader>b',
end: ['camel.camel|Word other'],
});
newTest({
title: 'back twice over operator',
start: ['camel.camel|Word other'],
keysPressed: '2<leader>b',
end: ['camel|.camelWord other'],
});
});
suite('handles <leader>e', () => {
newTest({
title: 'from start to middle of underscore_word',
start: ['|foo_bar && camelCase'],
keysPressed: '<leader>e',
end: ['fo|o_bar && camelCase'],
});
newTest({
title: 'from middle to end of underscore_word',
start: ['fo|o_bar && camelCase'],
keysPressed: '<leader>e',
end: ['foo_ba|r && camelCase'],
});
newTest({
title: 'twice to end of word over operator',
start: ['foo_ba|r && camelCase'],
keysPressed: '2<leader>e',
end: ['foo_bar && came|lCase'],
});
});
});

View File

@ -15,6 +15,9 @@ export class Configuration implements IConfiguration {
ignorecase = true;
smartcase = true;
autoindent = true;
camelCaseMotion = {
enable: false,
};
sneak = false;
sneakUseIgnorecaseAndSmartcase = false;
surround = true;