Implement "smart quotes", part of the targets.vim plugin (#7025)

This can be toggled with `vim.smartQuotes.enable`. See the PR for usage details.
Refs #601
This commit is contained in:
Elazar Cohen 2022-03-13 03:53:26 +02:00 committed by GitHub
parent 941987238e
commit f3a9f6a317
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1695 additions and 32 deletions

View File

@ -739,6 +739,26 @@
"markdownDescription": "`#editor.lineNumbers#` is determined by the active Vim mode, absolute when in insert mode, relative otherwise.",
"default": false
},
"vim.targets.enable": {
"type": "boolean",
"markdownDescription": "Enable [targets.vim](https://github.com/wellle/targets.vim#quote-text-objects) plugin (not fully implmeneted yet).",
"default": false
},
"vim.targets.smartQuotes.enable": {
"type": "boolean",
"markdownDescription": "Enable the smart quotes movements from [targets.vim](https://github.com/wellle/targets.vim#quote-text-objects).",
"default": true
},
"vim.targets.smartQuotes.breakThroughLines": {
"type": "boolean",
"markdownDescription": "Whether to break through lines when using [n]ext/[l]ast motion, see [targets.vim#next-and-last-quote](https://github.com/wellle/targets.vim#next-and-last-quote).",
"default": true
},
"vim.targets.smartQuotes.aIncludesSurroundingSpaces": {
"type": "boolean",
"markdownDescription": "Whether to use default Vim behavior when using `a` (e.g. `da'`) which include surrounding spaces, or not, as for other text objects.",
"default": true
},
"vim.sneak": {
"type": "boolean",
"markdownDescription": "Enable the [Sneak](https://github.com/justinmk/vim-sneak) plugin for Vim.",

View File

@ -19,3 +19,4 @@ import './plugins/easymotion/registerMoveActions';
import './plugins/sneak';
import './plugins/replaceWithRegister';
import './plugins/surround';
import './plugins/targets/targets';

View File

@ -5,3 +5,4 @@ import './plugins/easymotion/registerMoveActions';
import './plugins/sneak';
import './plugins/replaceWithRegister';
import './plugins/surround';
import './plugins/targets/targets';

View File

@ -27,6 +27,8 @@ import { sorted } from '../common/motion/position';
import { WordType } from '../textobject/word';
import { CommandInsertAtCursor } from './commands/actions';
import { SearchDirection } from '../vimscript/pattern';
import { SmartQuoteMatcher, WhichQuotes } from './plugins/targets/smartQuotesMatcher';
import { useSmartQuotes } from './plugins/targets/targetsConfig';
/**
* A movement is something like 'h', 'k', 'w', 'b', 'gg', etc.
@ -1964,9 +1966,11 @@ export class MoveAroundSquareBracket extends MoveInsideCharacter {
// TODO: Shouldn't this be a TextObject? A clearer delineation between motions and objects should be made.
export abstract class MoveQuoteMatch extends BaseMovement {
override modes = [Mode.Normal, Mode.Visual, Mode.VisualBlock];
protected readonly anyQuote: boolean = false;
protected abstract readonly charToMatch: '"' | "'" | '`';
protected includeQuotes = false;
override isJump = true;
readonly which: WhichQuotes = 'current';
// HACK: surround uses these classes, but does not want trailing whitespace to be included
private adjustForTrailingWhitespace: boolean = true;
@ -1996,41 +2000,89 @@ export abstract class MoveQuoteMatch extends BaseMovement {
this.adjustForTrailingWhitespace = false;
}
const text = vimState.document.lineAt(position).text;
const quoteMatcher = new QuoteMatcher(this.charToMatch, text);
const quoteIndices = quoteMatcher.surroundingQuotes(position.character);
if (quoteIndices === undefined) {
return failedMovement(vimState);
}
if (useSmartQuotes()) {
const quoteMatcher = new SmartQuoteMatcher(
this.anyQuote ? 'any' : this.charToMatch,
vimState.document
);
const res = quoteMatcher.smartSurroundingQuotes(position, this.which);
let [start, end] = quoteIndices;
if (!this.includeQuotes) {
// Don't include the quotes
start++;
end--;
} else if (this.adjustForTrailingWhitespace) {
// Include trailing whitespace if there is any...
const trailingWhitespace = text.substring(end + 1).search(/\S|$/);
if (trailingWhitespace > 0) {
end += trailingWhitespace;
} else {
// ...otherwise include leading whitespace
start = text.substring(0, start).search(/\s*$/);
if (res === undefined) {
return failedMovement(vimState);
}
let { start, stop, lineText } = res;
if (!this.includeQuotes) {
// Don't include the quotes
start = start.translate({ characterDelta: 1 });
stop = stop.translate({ characterDelta: -1 });
} else if (
this.adjustForTrailingWhitespace &&
configuration.targets.smartQuotes.aIncludesSurroundingSpaces
) {
// Include trailing whitespace if there is any...
const trailingWhitespace = lineText.substring(stop.character + 1).search(/\S|$/);
if (trailingWhitespace > 0) {
stop = stop.translate({ characterDelta: trailingWhitespace });
} else {
// ...otherwise include leading whitespace
start = start.with({ character: lineText.substring(0, start.character).search(/\s*$/) });
}
}
if (!isVisualMode(vimState.currentMode) && position.isBefore(start)) {
vimState.recordedState.operatorPositionDiff = start.subtract(position);
} else if (!isVisualMode(vimState.currentMode) && position.isAfter(stop)) {
if (position.line === stop.line) {
vimState.recordedState.operatorPositionDiff = stop.getRight().subtract(position);
} else {
vimState.recordedState.operatorPositionDiff = start.subtract(position);
}
}
vimState.cursorStartPosition = start;
return {
start,
stop,
};
} else {
const text = vimState.document.lineAt(position).text;
const quoteMatcher = new QuoteMatcher(this.charToMatch, text);
const quoteIndices = quoteMatcher.surroundingQuotes(position.character);
if (quoteIndices === undefined) {
return failedMovement(vimState);
}
let [start, end] = quoteIndices;
if (!this.includeQuotes) {
// Don't include the quotes
start++;
end--;
} else if (this.adjustForTrailingWhitespace) {
// Include trailing whitespace if there is any...
const trailingWhitespace = text.substring(end + 1).search(/\S|$/);
if (trailingWhitespace > 0) {
end += trailingWhitespace;
} else {
// ...otherwise include leading whitespace
start = text.substring(0, start).search(/\s*$/);
}
}
const startPos = new Position(position.line, start);
const endPos = new Position(position.line, end);
if (!isVisualMode(vimState.currentMode) && position.isBefore(startPos)) {
vimState.recordedState.operatorPositionDiff = startPos.subtract(position);
}
return {
start: startPos,
stop: endPos,
};
}
const startPos = new Position(position.line, start);
const endPos = new Position(position.line, end);
if (!isVisualMode(vimState.currentMode) && position.isBefore(startPos)) {
vimState.recordedState.operatorPositionDiff = startPos.subtract(position);
}
return {
start: startPos,
stop: endPos,
};
}
public override async execActionForOperator(

View File

@ -0,0 +1,155 @@
import { RegisterAction } from '../../base';
import { Mode } from '../../../mode/mode';
import { MoveQuoteMatch } from '../../motion';
abstract class SmartQuotes extends MoveQuoteMatch {
override modes = [Mode.Normal, Mode.Visual, Mode.VisualBlock];
}
@RegisterAction
export class MoveAroundNextSingleQuotes extends SmartQuotes {
keys = ['a', 'n', "'"];
readonly charToMatch = "'";
override readonly which = 'next';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideNextSingleQuotes extends SmartQuotes {
keys = ['i', 'n', "'"];
readonly charToMatch = "'";
override readonly which = 'next';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundLastSingleQuotes extends SmartQuotes {
keys = ['a', 'l', "'"];
readonly charToMatch = "'";
override readonly which = 'last';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideLastSingleQuotes extends SmartQuotes {
keys = ['i', 'l', "'"];
readonly charToMatch = "'";
override readonly which = 'last';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundNextDoubleQuotes extends SmartQuotes {
keys = ['a', 'n', '"'];
readonly charToMatch = '"';
override readonly which = 'next';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideNextDoubleQuotes extends SmartQuotes {
keys = ['i', 'n', '"'];
readonly charToMatch = '"';
override readonly which = 'next';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundLastDoubleQuotes extends SmartQuotes {
keys = ['a', 'l', '"'];
readonly charToMatch = '"';
override readonly which = 'last';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideLastDoubleQuotes extends SmartQuotes {
keys = ['i', 'l', '"'];
readonly charToMatch = '"';
override readonly which = 'last';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundNextBacktick extends SmartQuotes {
keys = ['a', 'n', '`'];
readonly charToMatch = '`';
override readonly which = 'next';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideNextBacktick extends SmartQuotes {
keys = ['i', 'n', '`'];
readonly charToMatch = '`';
override readonly which = 'next';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundLastBacktick extends SmartQuotes {
keys = ['a', 'l', '`'];
readonly charToMatch = '`';
override readonly which = 'last';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideLastBacktick extends SmartQuotes {
keys = ['i', 'l', '`'];
readonly charToMatch = '`';
override readonly which = 'last';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundQuote extends SmartQuotes {
keys = ['a', 'q'];
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideQuote extends SmartQuotes {
keys = ['i', 'q'];
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundNextQuote extends SmartQuotes {
keys = ['a', 'n', 'q'];
override readonly which = 'next';
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideNextQuote extends SmartQuotes {
keys = ['i', 'n', 'q'];
override readonly which = 'next';
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundLastQuote extends SmartQuotes {
keys = ['a', 'l', 'q'];
override readonly which = 'last';
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideLastQuote extends SmartQuotes {
keys = ['i', 'l', 'q'];
override readonly which = 'last';
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = false;
}

View File

@ -0,0 +1,367 @@
import { Position } from 'vscode';
import { TextDocument } from 'vscode';
import { configuration } from '../../../configuration/configuration';
type Quote = '"' | "'" | '`';
enum QuoteMatch {
Opening,
Closing,
}
export type WhichQuotes = 'current' | 'next' | 'last';
type Dir = '>' | '<';
type SearchAction = {
first: Dir;
second: Dir;
includeCurrent: boolean;
};
type QuotesAction = {
search: SearchAction | undefined;
skipToLeft: number; // for last quotes, how many quotes need to skip while searching
skipToRight: number; // for next quotes, how many quotes need to skip while searching
};
/**
* This mapping is used to give a way to identify which action we need to take when operating on a line.
* The keys here are, in some sense, the number of quotes in the line, in the format of `lcr`, where:
* `l` means left of the cursor, `c` whether the cursor is on a quote, and `r` is right of the cursor.
*
* It is based on the ideas used in `targets.vim`. For each line & cursor position, we count the number of quotes
* left (#L) and right (#R) of the cursor. Using those numbers and whether the cursor it on a quote, we know
* what action to make.
*
* For each entry we have an example of a line & position.
*/
const quoteDirs: Record<string, QuotesAction> = {
'002': {
// | "a" "b" "c"
search: { first: '>', second: '>', includeCurrent: false },
skipToLeft: 0,
skipToRight: 1,
},
'012': {
// |"a" "b" "c" "
search: { first: '>', second: '>', includeCurrent: true },
skipToLeft: 0,
skipToRight: 2,
},
'102': {
// "a" "|b" "c" "
search: { first: '<', second: '>', includeCurrent: false },
skipToLeft: 2,
skipToRight: 2,
},
'112': {
// "a" "b|" "c"
search: { first: '<', second: '<', includeCurrent: true },
skipToLeft: 2,
skipToRight: 1,
},
'202': {
// "a"| "b" "c"
search: { first: '>', second: '>', includeCurrent: false },
skipToLeft: 1,
skipToRight: 1,
},
'211': {
// "a" |"b" "c"
search: { first: '>', second: '>', includeCurrent: true },
skipToLeft: 1,
skipToRight: 2,
},
'101': {
// "a" "|b" "c"
search: { first: '<', second: '>', includeCurrent: false },
skipToLeft: 2,
skipToRight: 2,
},
'011': {
// |"a" "b" "c"
search: { first: '>', second: '>', includeCurrent: true },
skipToLeft: 0,
skipToRight: 2,
},
'110': {
// "a" "b" "c|"
search: { first: '<', second: '<', includeCurrent: true },
skipToLeft: 2,
skipToRight: 0,
},
'212': {
// "a" |"b" "c" "
search: { first: '>', second: '>', includeCurrent: true },
skipToLeft: 1,
skipToRight: 2,
},
'111': {
// "a" "b|" "c" "
search: { first: '<', second: '<', includeCurrent: true },
skipToLeft: 2,
skipToRight: 1,
},
'200': {
// "a" "b" "c"|
search: { first: '<', second: '<', includeCurrent: false },
skipToLeft: 1,
skipToRight: 0,
},
'201': {
// "a" "b" "c"| "
// "a"| "b" "c" "
search: { first: '>', second: '>', includeCurrent: false },
skipToLeft: 1,
skipToRight: 1,
},
'210': {
// "a" "b" "c" |"
search: undefined,
skipToLeft: 1,
skipToRight: 0,
},
'001': {
// | "a" "b" "c" "
search: undefined,
skipToLeft: 0,
skipToRight: 1,
},
'010': {
// a|"b
search: undefined,
skipToLeft: 0,
skipToRight: 0,
},
'100': {
// "a" "b" "c" "|
search: undefined,
skipToLeft: 2,
skipToRight: 0,
},
'000': {
// |ab
search: undefined,
skipToLeft: 0,
skipToRight: 0,
},
};
export class SmartQuoteMatcher {
static readonly escapeChar = '\\';
private document: TextDocument;
private quote: Quote | 'any';
constructor(quote: Quote | 'any', document: TextDocument) {
this.quote = quote;
this.document = document;
}
private buildQuoteMap(text: string) {
const quoteMap: QuoteMatch[] = [];
let openingQuote = true;
// Loop over text, marking quotes and respecting escape characters.
for (let i = 0; i < text.length; i++) {
if (text[i] === SmartQuoteMatcher.escapeChar) {
i += 1;
continue;
}
if (
(this.quote === 'any' && (text[i] === '"' || text[i] === "'" || text[i] === '`')) ||
text[i] === this.quote
) {
quoteMap[i] = openingQuote ? QuoteMatch.Opening : QuoteMatch.Closing;
openingQuote = !openingQuote;
}
}
return quoteMap;
}
private static lineSearchAction(cursorIndex: number, quoteMap: QuoteMatch[]) {
// base on ideas from targets.vim
// cut line in left of, on and right of cursor
const left = Array.from(quoteMap.entries()).slice(undefined, cursorIndex);
const cursor = quoteMap[cursorIndex];
const right = Array.from(quoteMap.entries()).slice(cursorIndex + 1, undefined);
// how many delimiters left, on and right of cursor
const lc = left.filter(([_, v]) => v !== undefined).length;
const cc = cursor !== undefined ? 1 : 0;
const rc = right.filter(([_, v]) => v !== undefined).length;
// truncate counts
const lct = lc === 0 ? 0 : lc % 2 === 0 ? 2 : 1;
const rct = rc === 0 ? 0 : rc >= 2 ? 2 : 1;
const key = `${lct}${cc}${rct}`;
const act = quoteDirs[key];
return act;
}
public smartSurroundingQuotes(
position: Position,
which: WhichQuotes
): { start: Position; stop: Position; lineText: string } | undefined {
position = this.document.validatePosition(position);
const cursorIndex = position.character;
const lineText = this.document.lineAt(position).text;
const quoteMap = this.buildQuoteMap(lineText);
const act = SmartQuoteMatcher.lineSearchAction(cursorIndex, quoteMap);
if (which === 'current') {
if (act.search) {
const searchRes = this.smartSearch(cursorIndex, act.search, quoteMap);
return searchRes
? {
start: position.with({ character: searchRes[0] }),
stop: position.with({ character: searchRes[1] }),
lineText,
}
: undefined;
} else {
return undefined;
}
} else if (which === 'next') {
// search quote in current line
const right = Array.from(quoteMap.entries()).slice(cursorIndex + 1, undefined);
const [index, found] = right.filter(([i, v]) => v !== undefined)[act.skipToRight] ?? [
+Infinity,
undefined,
];
// find next position for surrounding quotes, possibly breaking through lines
let nextPos;
position = position.with({ character: index });
if (found === undefined && configuration.targets.smartQuotes.breakThroughLines) {
// nextPos = State.evalGenerator(this.getNextQuoteThroughLineBreaks(), position);
nextPos = this.getNextQuoteThroughLineBreaks(position);
} else {
nextPos = found !== undefined ? position : undefined;
}
// find surrounding with new position
if (nextPos) {
return this.smartSurroundingQuotes(nextPos, 'current');
} else {
return undefined;
}
} else if (which === 'last') {
// search quote in current line
const left = Array.from(quoteMap.entries()).slice(undefined, cursorIndex);
const [index, found] = left.reverse().filter(([i, v]) => v !== undefined)[act.skipToLeft] ?? [
0,
undefined,
];
// find last position for surrounding quotes, possibly breaking through lines
let lastPos;
position = position.with({ character: index });
if (found === undefined && configuration.targets.smartQuotes.breakThroughLines) {
position = position.getLeftThroughLineBreaks();
lastPos = this.getLastQuoteThroughLineBreaks(position);
} else {
lastPos = found !== undefined ? position : undefined;
}
// find surrounding with new position
if (lastPos) {
return this.smartSurroundingQuotes(lastPos, 'current');
} else {
return undefined;
}
} else {
return undefined;
}
}
private smartSearch(
start: number,
action: SearchAction,
quoteMap: QuoteMatch[]
): [number, number] | undefined {
const offset = action.includeCurrent ? 1 : 0;
let cursorPos: number | undefined = start;
let fst: number | undefined;
let snd: number | undefined;
if (action.first === '>') {
cursorPos = fst = this.getNextQuote(cursorPos - offset, quoteMap);
} else {
// dir === '<'
cursorPos = fst = this.getPrevQuote(cursorPos + offset, quoteMap);
}
if (cursorPos === undefined) return undefined;
if (action.second === '>') {
snd = this.getNextQuote(cursorPos, quoteMap);
} else {
// dir === '<'
snd = this.getPrevQuote(cursorPos, quoteMap);
}
if (fst === undefined || snd === undefined) return undefined;
if (fst < snd) return [fst, snd];
else return [snd, fst];
}
private getNextQuoteThroughLineBreaks(position: Position): Position | undefined {
for (let line = position.line; line < this.document.lineCount; line++) {
position = this.document.validatePosition(position.with({ line }));
const text = this.document.lineAt(position).text;
if (this.quote === 'any') {
for (let i = position.character; i < text.length; i++) {
if (text[i] === '"' || text[i] === "'" || text[i] === '`') {
return position.with({ character: i });
}
}
} else {
const index = text.indexOf(this.quote, position.character);
if (index >= 0) {
return position.with({ character: index });
}
}
position = position.with({ character: 0 }); // set at line begin for next iteration
}
return undefined;
}
private getLastQuoteThroughLineBreaks(position: Position): Position | undefined {
for (let line = position.line; line >= 0; line--) {
position = this.document.validatePosition(position.with({ line }));
const text = this.document.lineAt(position).text;
if (this.quote === 'any') {
for (let i = position.character; i >= 0; i--) {
if (text[i] === '"' || text[i] === "'" || text[i] === '`') {
return position.with({ character: i });
}
}
} else {
const index = text.lastIndexOf(this.quote, position.character);
if (index >= 0) {
return position.with({ character: index });
}
}
position = position.with({ character: +Infinity }); // set at line end for next iteration
}
return undefined;
}
private getNextQuote(start: number, quoteMap: QuoteMatch[]): number | undefined {
for (let i = start + 1; i < quoteMap.length; i++) {
if (quoteMap[i] !== undefined) {
return i;
}
}
return undefined;
}
private getPrevQuote(start: number, quoteMap: QuoteMatch[]): number | undefined {
for (let i = start - 1; i >= 0; i--) {
if (quoteMap[i] !== undefined) {
return i;
}
}
return undefined;
}
}

View File

@ -0,0 +1,2 @@
// targets sub-plugins
import './smartQuotes';

View File

@ -0,0 +1,9 @@
import { configuration } from '../../../configuration/configuration';
export function useSmartQuotes(): boolean {
return (
(configuration.targets.enable === true && configuration.targets.smartQuotes.enable !== false) ||
(configuration.targets.enable === undefined &&
configuration.targets.smartQuotes.enable === true)
);
}

View File

@ -15,6 +15,7 @@ import {
IDebugConfiguration,
IHighlightedYankConfiguration,
ICamelCaseMotionConfiguration,
ITargetsConfiguration,
} from './iconfiguration';
import * as packagejson from '../../package.json';
@ -267,6 +268,16 @@ class Configuration implements IConfiguration {
easymotionKeys = 'hklyuiopnm,qwertzxcvbasdgjf;';
easymotionJumpToAnywhereRegex = '\\b[A-Za-z0-9]|[A-Za-z0-9]\\b|_.|#.|[a-z][A-Z]';
targets: ITargetsConfiguration = {
enable: false,
smartQuotes: {
enable: false,
breakThroughLines: false,
aIncludesSurroundingSpaces: true,
},
};
autoSwitchInputMethod: IAutoSwitchInputMethod = {
enable: false,
defaultIM: '',

View File

@ -80,6 +80,29 @@ export interface ICamelCaseMotionConfiguration {
enable: boolean;
}
export interface ISmartQuotesConfiguration {
/**
* Enable SmartQuotes plugin or not
*/
enable: boolean;
/**
* Whether to break through lines when using [n]ext/[l]ast motion
*/
breakThroughLines: boolean;
/**
* Whether to use default vim behaviour when using `a` (e.g. da') which include surrounding spaces, or not, as for other text objects.
*/
aIncludesSurroundingSpaces: boolean;
}
export interface ITargetsConfiguration {
/**
* Enable Targets plugin or not
*/
enable: boolean;
smartQuotes: ISmartQuotesConfiguration;
}
export interface IConfiguration {
/**
* Use the system's clipboard when copying.

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import {
IHighlightedYankConfiguration,
IKeyRemapping,
IModeSpecificStrings,
ITargetsConfiguration,
} from '../src/configuration/iconfiguration';
export class Configuration implements IConfiguration {
@ -45,6 +46,14 @@ export class Configuration implements IConfiguration {
easymotionMarkerFontWeight = 'bold';
easymotionMarkerMargin = 0; // Deprecated! No longer needed!
easymotionKeys = 'hklyuiopnm,qwertzxcvbasdgjf;';
targets: ITargetsConfiguration = {
enable: false,
smartQuotes: {
enable: false,
breakThroughLines: true,
aIncludesSurroundingSpaces: true,
},
};
autoSwitchInputMethod = {
enable: false,
defaultIM: '',