diff --git a/src/actions/commands/actions.ts b/src/actions/commands/actions.ts index b1c4e84eccd..7ecd047adaa 100644 --- a/src/actions/commands/actions.ts +++ b/src/actions/commands/actions.ts @@ -3817,7 +3817,7 @@ class ActionChangeChar extends BaseCommand { public couldActionApply(vimState: VimState, keysPressed: string[]): boolean { return ( - super.doesActionApply(vimState, keysPressed) && + super.couldActionApply(vimState, keysPressed) && !configuration.sneak && !vimState.recordedState.operator ); diff --git a/src/actions/motion.ts b/src/actions/motion.ts index 2bce940933a..909810c2723 100644 --- a/src/actions/motion.ts +++ b/src/actions/motion.ts @@ -14,6 +14,7 @@ import { BaseAction } from './base'; import { RegisterAction } from './base'; import { ChangeOperator, DeleteOperator, YankOperator } from './operator'; import { shouldWrapKey } from './wrapping'; +import { RecordedState } from '../state/recordedState'; export function isIMovement(o: IMovement | Position): o is IMovement { return (o as IMovement).start !== undefined && (o as IMovement).stop !== undefined; @@ -39,6 +40,11 @@ export interface IMovement { registerMode?: RegisterMode; } +enum SelectionType { + Concatenating, // selections that concatenate repeated movements + Expanding, // selections that expand the start and end of the previous selection +} + /** * A movement is something like 'h', 'k', 'w', 'b', 'gg', etc. */ @@ -64,6 +70,10 @@ export abstract class BaseMovement extends BaseAction { */ public setsDesiredColumnToEOL = false; + protected minCount = 1; + protected maxCount = 99999; + protected selectionType = SelectionType.Concatenating; + constructor(keysPressed?: string[], isRepeat?: boolean) { super(); @@ -111,51 +121,74 @@ export abstract class BaseMovement extends BaseAction { ): Promise { let recordedState = vimState.recordedState; let result: Position | IMovement = new Position(0, 0); // bogus init to satisfy typechecker + let prevResult: IMovement | undefined = undefined; + let firstMovementStart: Position = new Position(position.line, position.character); - if (count < 1) { - count = 1; - } else if (count > 99999) { - count = 99999; - } + count = this.clampCount(count); for (let i = 0; i < count; i++) { const firstIteration = i === 0; const lastIteration = i === count - 1; - const temporaryResult = - recordedState.operator && lastIteration - ? await this.execActionForOperator(position, vimState) - : await this.execAction(position, vimState); - - if (temporaryResult instanceof Position) { - result = temporaryResult; - position = temporaryResult; - } else if (isIMovement(temporaryResult)) { - if (result instanceof Position) { - result = { - start: new Position(0, 0), - stop: new Position(0, 0), - failed: false, - }; - } + result = await this.createMovementResult(position, vimState, recordedState, lastIteration); - result.failed = result.failed || temporaryResult.failed; - - if (firstIteration) { - (result as IMovement).start = temporaryResult.start; + if (result instanceof Position) { + position = result; + } else if (isIMovement(result)) { + if (prevResult && result.failed) { + return prevResult; } - if (lastIteration) { - (result as IMovement).stop = temporaryResult.stop; - } else { - position = temporaryResult.stop.getRightThroughLineBreaks(); + if (firstIteration) { + firstMovementStart = new Position(result.start.line, result.start.character); } - result.registerMode = temporaryResult.registerMode; + position = this.adjustPosition(position, result, lastIteration); + prevResult = result; } } + if (this.selectionType === SelectionType.Concatenating && isIMovement(result)) { + result.start = firstMovementStart; + } + + return result; + } + + protected clampCount(count: number) { + count = Math.max(count, this.minCount); + count = Math.min(count, this.maxCount); + return count; + } + + protected async createMovementResult( + position: Position, + vimState: VimState, + recordedState: RecordedState, + lastIteration: boolean + ): Promise { + const result = + recordedState.operator && lastIteration + ? await this.execActionForOperator(position, vimState) + : await this.execAction(position, vimState); return result; } + protected adjustPosition(position: Position, result: IMovement, lastIteration: boolean) { + if (!lastIteration) { + position = result.stop.getRightThroughLineBreaks(); + } + return position; + } +} + +export abstract class ExpandingSelection extends BaseMovement { + protected selectionType = SelectionType.Expanding; + + protected adjustPosition(position: Position, result: IMovement, lastIteration: boolean) { + if (!lastIteration) { + position = result.stop; + } + return position; + } } abstract class MoveByScreenLine extends BaseMovement { @@ -1338,14 +1371,14 @@ class MoveToMatchingBracket extends BaseMovement { if (PairMatcher.pairings[text[i]]) { // We found an opening char, now move to the matching closing char const openPosition = new Position(position.line, i); - return PairMatcher.nextPairedChar(openPosition, text[i], true) || failure; + return PairMatcher.nextPairedChar(openPosition, text[i]) || failure; } } return failure; } - return PairMatcher.nextPairedChar(position, charToMatch, true) || failure; + return PairMatcher.nextPairedChar(position, charToMatch) || failure; } public async execActionForOperator( @@ -1401,19 +1434,39 @@ class MoveToMatchingBracket extends BaseMovement { } } -export abstract class MoveInsideCharacter extends BaseMovement { +export abstract class MoveInsideCharacter extends ExpandingSelection { modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine, ModeName.VisualBlock]; protected charToMatch: string; protected includeSurrounding = false; public async execAction(position: Position, vimState: VimState): Promise { - const failure = { start: position, stop: position, failed: true }; - const text = TextEditor.getLineAt(position).text; const closingChar = PairMatcher.pairings[this.charToMatch].match; - const closedMatch = text[position.character] === closingChar; + let cursorStartPos = new Position( + vimState.cursorStartPosition.line, + vimState.cursorStartPosition.character + ); + // maintain current selection on failure + const failure = { start: cursorStartPos, stop: position, failed: true }; + // when matching inside content of a pair, search for the next pair if + // the inner content is already selected in full + if (!this.includeSurrounding) { + const adjacentPosLeft = cursorStartPos.getLeftThroughLineBreaks(); + let adjacentPosRight = position.getRightThroughLineBreaks(); + if (vimState.recordedState.operator) { + adjacentPosRight = adjacentPosRight.getLeftThroughLineBreaks(); + } + const adjacentCharLeft = TextEditor.getCharAt(adjacentPosLeft); + const adjacentCharRight = TextEditor.getCharAt(adjacentPosRight); + if (adjacentCharLeft === this.charToMatch && adjacentCharRight === closingChar) { + cursorStartPos = adjacentPosLeft; + vimState.cursorStartPosition = adjacentPosLeft; + position = adjacentPosRight; + vimState.cursorPosition = adjacentPosRight; + } + } // First, search backwards for the opening character of the sequence - let startPos = PairMatcher.nextPairedChar(position, closingChar, closedMatch); + let startPos = PairMatcher.nextPairedChar(cursorStartPos, closingChar, vimState); if (startPos === undefined) { return failure; } @@ -1426,7 +1479,8 @@ export abstract class MoveInsideCharacter extends BaseMovement { startPlusOne = new Position(startPos.line, startPos.character + 1); } - let endPos = PairMatcher.nextPairedChar(startPlusOne, this.charToMatch, false); + let endPos = PairMatcher.nextPairedChar(position, this.charToMatch, vimState); + if (endPos === undefined) { return failure; } @@ -1452,26 +1506,13 @@ export abstract class MoveInsideCharacter extends BaseMovement { vimState.recordedState.operatorPositionDiff = startPos.subtract(position); } + vimState.cursorStartPosition = startPos; return { start: startPos, stop: endPos, diff: new PositionDiff(0, startPos === position ? 1 : 0), }; } - - public async execActionForOperator( - position: Position, - vimState: VimState - ): Promise { - const result = await this.execAction(position, vimState); - if (isIMovement(result)) { - if (result.failed) { - vimState.recordedState.hasRunOperator = false; - vimState.recordedState.actionsRun = []; - } - } - return result; - } } @RegisterAction @@ -1707,11 +1748,7 @@ class MoveToUnclosedRoundBracketBackward extends MoveToMatchingBracket { public async execAction(position: Position, vimState: VimState): Promise { const failure = { start: position, stop: position, failed: true }; const charToMatch = ')'; - const result = PairMatcher.nextPairedChar( - position.getLeftThroughLineBreaks(), - charToMatch, - false - ); + const result = PairMatcher.nextPairedChar(position, charToMatch); if (!result) { return failure; @@ -1727,11 +1764,7 @@ class MoveToUnclosedRoundBracketForward extends MoveToMatchingBracket { public async execAction(position: Position, vimState: VimState): Promise { const failure = { start: position, stop: position, failed: true }; const charToMatch = '('; - const result = PairMatcher.nextPairedChar( - position.getRightThroughLineBreaks(), - charToMatch, - false - ); + const result = PairMatcher.nextPairedChar(position, charToMatch); if (!result) { return failure; @@ -1756,11 +1789,7 @@ class MoveToUnclosedCurlyBracketBackward extends MoveToMatchingBracket { public async execAction(position: Position, vimState: VimState): Promise { const failure = { start: position, stop: position, failed: true }; const charToMatch = '}'; - const result = PairMatcher.nextPairedChar( - position.getLeftThroughLineBreaks(), - charToMatch, - false - ); + const result = PairMatcher.nextPairedChar(position, charToMatch); if (!result) { return failure; @@ -1776,11 +1805,7 @@ class MoveToUnclosedCurlyBracketForward extends MoveToMatchingBracket { public async execAction(position: Position, vimState: VimState): Promise { const failure = { start: position, stop: position, failed: true }; const charToMatch = '{'; - const result = PairMatcher.nextPairedChar( - position.getRightThroughLineBreaks(), - charToMatch, - false - ); + const result = PairMatcher.nextPairedChar(position, charToMatch); if (!result) { return failure; @@ -1798,27 +1823,37 @@ class MoveToUnclosedCurlyBracketForward extends MoveToMatchingBracket { } } -abstract class MoveTagMatch extends BaseMovement { +abstract class MoveTagMatch extends ExpandingSelection { modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualBlock]; protected includeTag = false; public async execAction(position: Position, vimState: VimState): Promise { const editorText = TextEditor.getText(); const offset = TextEditor.getOffsetAt(position); - const tagMatcher = new TagMatcher(editorText, offset); + const tagMatcher = new TagMatcher(editorText, offset, vimState); + const cursorStartPos = new Position( + vimState.cursorStartPosition.line, + vimState.cursorStartPosition.character + ); const start = tagMatcher.findOpening(this.includeTag); const end = tagMatcher.findClosing(this.includeTag); if (start === undefined || end === undefined) { return { - start: position, + start: cursorStartPos, stop: position, failed: true, }; } - let startPosition = start ? TextEditor.getPositionAt(start) : position; - let endPosition = end ? TextEditor.getPositionAt(end) : position; + let startPosition = start >= 0 ? TextEditor.getPositionAt(start) : cursorStartPos; + let endPosition = end >= 0 ? TextEditor.getPositionAt(end) : position; + if ( + vimState.currentMode === ModeName.Visual || + vimState.currentMode === ModeName.SurroundInputMode + ) { + endPosition = endPosition.getLeftThroughLineBreaks(); + } if (position.isAfter(endPosition)) { vimState.recordedState.transformations.push({ @@ -1831,37 +1866,22 @@ abstract class MoveTagMatch extends BaseMovement { diff: startPosition.subtract(position), }); } - if (start === end) { - if (vimState.recordedState.operator instanceof ChangeOperator) { - vimState.currentMode = ModeName.Insert; - } - return { - start: startPosition, - stop: startPosition, - failed: true, - }; - } + // if (start === end) { + // if (vimState.recordedState.operator instanceof ChangeOperator) { + // vimState.currentMode = ModeName.Insert; + // } + // return { + // start: startPosition, + // stop: startPosition, + // failed: true, + // }; + // } + vimState.cursorStartPosition = startPosition; return { start: startPosition, - stop: endPosition.getLeftThroughLineBreaks(true), + stop: endPosition, }; } - - public async execActionForOperator( - position: Position, - vimState: VimState - ): Promise { - const result = await this.execAction(position, vimState); - if (isIMovement(result)) { - if (result.failed) { - vimState.recordedState.hasRunOperator = false; - vimState.recordedState.actionsRun = []; - } else { - result.stop = result.stop.getRight(); - } - } - return result; - } } @RegisterAction diff --git a/src/actions/plugins/surround.ts b/src/actions/plugins/surround.ts index 79475b18846..e448bdb0921 100644 --- a/src/actions/plugins/surround.ts +++ b/src/actions/plugins/surround.ts @@ -420,7 +420,7 @@ export class CommandSurroundAddToReplacement extends BaseCommand { if (startReplace.length === 1 && startReplace in PairMatcher.pairings) { endReplace = PairMatcher.pairings[startReplace].match; - if (!PairMatcher.pairings[startReplace].nextMatchIsForward) { + if (!PairMatcher.pairings[startReplace].isNextMatchForward) { [startReplace, endReplace] = [endReplace, startReplace]; } else { startReplace = startReplace + ' '; @@ -544,25 +544,27 @@ export class CommandSurroundAddToReplacement extends BaseCommand { } if (target === 't') { + // `MoveInsideTag` must be run first as otherwise the search will + // look for the next enclosing tag after having selected the first + let innerTagContent = await new MoveInsideTag().execAction(position, vimState); let { start, stop, failed } = await new MoveAroundTag().execAction(position, vimState); - let tagEnd = await new MoveInsideTag().execAction(position, vimState); - if (failed || tagEnd.failed) { + if (failed || innerTagContent.failed) { return CommandSurroundAddToReplacement.Finish(vimState); } stop = stop.getRight(); - tagEnd.stop = tagEnd.stop.getRight(); + innerTagContent.stop = innerTagContent.stop.getRight(); if (failed) { return CommandSurroundAddToReplacement.Finish(vimState); } startReplaceRange = new Range(start, start.getRight()); - endReplaceRange = new Range(tagEnd.stop, tagEnd.stop.getRight()); + endReplaceRange = new Range(innerTagContent.stop, innerTagContent.stop.getRight()); - startDeleteRange = new Range(start.getRight(), tagEnd.start); - endDeleteRange = new Range(tagEnd.stop.getRight(), stop); + startDeleteRange = new Range(start.getRight(), innerTagContent.start); + endDeleteRange = new Range(innerTagContent.stop.getRight(), stop); } if (operator === 'change') { diff --git a/src/actions/textobject.ts b/src/actions/textobject.ts index 48b21f992ac..891bcc5ead0 100644 --- a/src/actions/textobject.ts +++ b/src/actions/textobject.ts @@ -13,6 +13,9 @@ import { MoveAParentheses, MoveASingleQuotes, MoveASquareBracket, + MoveABacktick, + MoveAroundTag, + ExpandingSelection, } from './motion'; import { ChangeOperator } from './operator'; @@ -37,7 +40,7 @@ export class SelectWord extends TextObjectMovement { public async execAction(position: Position, vimState: VimState): Promise { let start: Position; let stop: Position; - const currentChar = TextEditor.getLineAt(position).text[position.character]; + const currentChar = TextEditor.getCharAt(position); if (/\s/.test(currentChar)) { start = position.getLastWordEnd().getRight(); @@ -152,26 +155,45 @@ export class SelectABigWord extends TextObjectMovement { /** * This is a custom action that I (johnfn) added. It selects procedurally * larger blocks. e.g. if you had "blah (foo [bar 'ba|z'])" then it would - * select 'baz' first. If you pressed az again, it'd then select [bar 'baz'], + * select 'baz' first. If you pressed af again, it'd then select [bar 'baz'], * and if you did it a third time it would select "(foo [bar 'baz'])". */ @RegisterAction -export class SelectAnExpandingBlock extends TextObjectMovement { +export class SelectAnExpandingBlock extends ExpandingSelection { keys = ['a', 'f']; modes = [ModeName.Visual, ModeName.VisualLine]; public async execAction(position: Position, vimState: VimState): Promise { - const ranges = [ - await new MoveASingleQuotes().execAction(position, vimState), - await new MoveADoubleQuotes().execAction(position, vimState), - await new MoveAClosingCurlyBrace().execAction(position, vimState), - await new MoveAParentheses().execAction(position, vimState), - await new MoveASquareBracket().execAction(position, vimState), + const blocks = [ + new MoveADoubleQuotes(), + new MoveASingleQuotes(), + new MoveABacktick(), + new MoveAClosingCurlyBrace(), + new MoveAParentheses(), + new MoveASquareBracket(), + new MoveAroundTag(), ]; + // ideally no state would change as we test each of the possible expansions + // a deep copy of vimState could work here but may be expensive + let ranges: IMovement[] = []; + for (const block of blocks) { + const cursorPos = new Position(position.line, position.character); + const cursorStartPos = new Position( + vimState.cursorStartPosition.line, + vimState.cursorStartPosition.character + ); + ranges.push(await block.execAction(cursorPos, vimState)); + vimState.cursorStartPosition = cursorStartPos; + } + + ranges = ranges.filter(range => { + return !range.failed; + }); let smallestRange: Range | undefined = undefined; for (const iMotion of ranges) { + const currentSelectedRange = new Range(vimState.cursorStartPosition, vimState.cursorPosition); if (iMotion.failed) { continue; } @@ -179,11 +201,16 @@ export class SelectAnExpandingBlock extends TextObjectMovement { const range = Range.FromIMovement(iMotion); let contender: Range | undefined = undefined; - if (!smallestRange) { - contender = range; - } else { - if (range.start.isAfter(smallestRange.start) && range.stop.isBefore(smallestRange.stop)) { + if ( + range.start.isBefore(currentSelectedRange.start) && + range.stop.isAfter(currentSelectedRange.stop) + ) { + if (!smallestRange) { contender = range; + } else { + if (range.start.isAfter(smallestRange.start) && range.stop.isBefore(smallestRange.stop)) { + contender = range; + } } } @@ -199,13 +226,19 @@ export class SelectAnExpandingBlock extends TextObjectMovement { } } } - if (!smallestRange) { return { start: vimState.cursorStartPosition, stop: vimState.cursorPosition, }; } else { + // revert relevant state changes + vimState.cursorStartPosition = new Position( + smallestRange.start.line, + smallestRange.start.character + ); + vimState.cursorPosition = new Position(smallestRange.stop.line, smallestRange.stop.character); + vimState.recordedState.operatorPositionDiff = undefined; return { start: smallestRange.start, stop: smallestRange.stop, diff --git a/src/common/matching/matcher.ts b/src/common/matching/matcher.ts index f7f67fe7ccb..ce2389253af 100644 --- a/src/common/matching/matcher.ts +++ b/src/common/matching/matcher.ts @@ -3,30 +3,7 @@ import * as vscode from 'vscode'; import { TextEditor } from './../../textEditor'; import { Position, PositionDiff } from './../motion/position'; import { configuration } from '../../configuration/configuration'; - -function escapeRegExpCharacters(value: string): string { - return value.replace(/[\-\\\{\}\*\+\?\|\^\$\.\,\[\]\(\)\#\s]/g, '\\$&'); -} - -let toReversedString = (function() { - function reverse(str: string): string { - let reversedStr = ''; - for (let i = str.length - 1; i >= 0; i--) { - reversedStr += str.charAt(i); - } - return reversedStr; - } - - let lastInput: string = ''; - let lastOutput: string = ''; - return function(str: string): string { - if (lastInput !== str) { - lastInput = str; - lastOutput = reverse(lastInput); - } - return lastOutput; - }; -})(); +import { VimState } from '../../state/vimState'; /** * PairMatcher finds the position matching the given character, respecting nested @@ -36,32 +13,112 @@ export class PairMatcher { static pairings: { [key: string]: { match: string; - nextMatchIsForward: boolean; - directionLess?: boolean; + isNextMatchForward: boolean; + directionless?: boolean; matchesWithPercentageMotion?: boolean; }; } = { - '(': { match: ')', nextMatchIsForward: true, matchesWithPercentageMotion: true }, - '{': { match: '}', nextMatchIsForward: true, matchesWithPercentageMotion: true }, - '[': { match: ']', nextMatchIsForward: true, matchesWithPercentageMotion: true }, - ')': { match: '(', nextMatchIsForward: false, matchesWithPercentageMotion: true }, - '}': { match: '{', nextMatchIsForward: false, matchesWithPercentageMotion: true }, - ']': { match: '[', nextMatchIsForward: false, matchesWithPercentageMotion: true }, + '(': { match: ')', isNextMatchForward: true, matchesWithPercentageMotion: true }, + '{': { match: '}', isNextMatchForward: true, matchesWithPercentageMotion: true }, + '[': { match: ']', isNextMatchForward: true, matchesWithPercentageMotion: true }, + ')': { match: '(', isNextMatchForward: false, matchesWithPercentageMotion: true }, + '}': { match: '{', isNextMatchForward: false, matchesWithPercentageMotion: true }, + ']': { match: '[', isNextMatchForward: false, matchesWithPercentageMotion: true }, + // These characters can't be used for "%"-based matching, but are still // useful for text objects. - '<': { match: '>', nextMatchIsForward: true }, - '>': { match: '<', nextMatchIsForward: false }, + '<': { match: '>', isNextMatchForward: true }, + '>': { match: '<', isNextMatchForward: false }, // These are useful for deleting closing and opening quotes, but don't seem to negatively // affect how text objects such as `ci"` work, which was my worry. - '"': { match: '"', nextMatchIsForward: false, directionLess: true }, - "'": { match: "'", nextMatchIsForward: false, directionLess: true }, - '`': { match: '`', nextMatchIsForward: false, directionLess: true }, + '"': { match: '"', isNextMatchForward: false, directionless: true }, + "'": { match: "'", isNextMatchForward: false, directionless: true }, + '`': { match: '`', isNextMatchForward: false, directionless: true }, }; + private static findPairedChar( + position: Position, + charToFind: string, + charToStack: string, + stackHeight, + isNextMatchForward: boolean, + vimState?: VimState + ): Position | undefined { + let lineNumber = position.line; + let linePosition = position.character; + let lineCount = TextEditor.getLineCount(); + let cursorChar = TextEditor.getCharAt(position); + if (vimState) { + let startPos = vimState.cursorStartPosition; + let endPos = vimState.cursorPosition; + if (startPos.isEqual(endPos) && cursorChar === charToFind) { + return position; + } + } + + while (PairMatcher.keepSearching(lineNumber, lineCount, isNextMatchForward)) { + let lineText = TextEditor.getLineAt(new Position(lineNumber, 0)).text.split(''); + const originalLineLength = lineText.length; + if (lineNumber === position.line) { + if (isNextMatchForward) { + lineText = lineText.slice(linePosition + 1, originalLineLength); + } else { + lineText = lineText.slice(0, linePosition); + } + } + + while (true) { + if (lineText.length <= 0 || stackHeight <= -1) { + break; + } + + let nextChar: string | undefined; + if (isNextMatchForward) { + nextChar = lineText.shift(); + } else { + nextChar = lineText.pop(); + } + + if (nextChar === charToStack) { + stackHeight++; + } else if (nextChar === charToFind) { + stackHeight--; + } else { + continue; + } + } + + if (stackHeight <= -1) { + let pairMemberChar: number; + if (isNextMatchForward) { + pairMemberChar = Math.max(0, originalLineLength - lineText.length - 1); + } else { + pairMemberChar = lineText.length; + } + return new Position(lineNumber, pairMemberChar); + } + + if (isNextMatchForward) { + lineNumber++; + } else { + lineNumber--; + } + } + return undefined; + } + + private static keepSearching(lineNumber, lineCount, isNextMatchForward) { + if (isNextMatchForward) { + return lineNumber <= lineCount - 1; + } else { + return lineNumber >= 0; + } + } + static nextPairedChar( position: Position, charToMatch: string, - closed: boolean = true + vimState?: VimState ): Position | undefined { /** * We do a fairly basic implementation that only tracks the state of the type of @@ -74,103 +131,29 @@ export class PairMatcher { * PRs welcomed! (TODO) * Though ideally VSC implements https://github.com/Microsoft/vscode/issues/7177 */ - const toFind = this.pairings[charToMatch]; + const pairing = this.pairings[charToMatch]; - if (toFind === undefined || toFind.directionLess) { + if (pairing === undefined || pairing.directionless) { return undefined; } - let regex = new RegExp( - '(' + escapeRegExpCharacters(charToMatch) + '|' + escapeRegExpCharacters(toFind.match) + ')', - 'i' + const stackHeight = 0; + let matchedPos: Position | undefined; + const charToFind = pairing.match; + const charToStack = charToMatch; + + matchedPos = PairMatcher.findPairedChar( + position, + charToFind, + charToStack, + stackHeight, + pairing.isNextMatchForward, + vimState ); - let stackHeight = closed ? 0 : 1; - let matchedPosition: Position | undefined = undefined; - - // find matched bracket up - if (!toFind.nextMatchIsForward) { - for (let lineNumber = position.line; lineNumber >= 0; lineNumber--) { - let lineText = TextEditor.getLineAt(new Position(lineNumber, 0)).text; - let startOffset = - lineNumber === position.line ? lineText.length - position.character - 1 : 0; - - while (true) { - let queryText = toReversedString(lineText).substr(startOffset); - if (queryText === '') { - break; - } - - let m = queryText.match(regex); - - if (!m) { - break; - } - - let matchedChar = m[0]; - if (matchedChar === charToMatch) { - stackHeight++; - } - - if (matchedChar === toFind.match) { - stackHeight--; - } - - if (stackHeight === 0) { - matchedPosition = new Position( - lineNumber, - lineText.length - startOffset - m.index! - 1 - ); - return matchedPosition; - } - - startOffset = startOffset + m.index! + 1; - } - } - } else { - for ( - let lineNumber = position.line, lineCount = TextEditor.getLineCount(); - lineNumber < lineCount; - lineNumber++ - ) { - let lineText = TextEditor.getLineAt(new Position(lineNumber, 0)).text; - let startOffset = lineNumber === position.line ? position.character : 0; - - while (true) { - let queryText = lineText.substr(startOffset); - if (queryText === '') { - break; - } - - let m = queryText.match(regex); - - if (!m) { - break; - } - - let matchedChar = m[0]; - if (matchedChar === charToMatch) { - stackHeight++; - } - - if (matchedChar === toFind.match) { - stackHeight--; - } - - if (stackHeight === 0) { - matchedPosition = new Position(lineNumber, startOffset + m.index!); - return matchedPosition; - } - - startOffset = startOffset + m.index! + 1; - } - } + if (matchedPos) { + return matchedPos; } - - if (matchedPosition) { - return matchedPosition; - } - // TODO(bell) return undefined; } diff --git a/src/common/matching/tagMatcher.ts b/src/common/matching/tagMatcher.ts index 470ca584412..10ffd468435 100644 --- a/src/common/matching/tagMatcher.ts +++ b/src/common/matching/tagMatcher.ts @@ -1,3 +1,7 @@ +import { TextEditor } from '../../textEditor'; +import { VimState } from '../../state/vimState'; +import { Position } from 'vscode'; + type Tag = { name: string; type: 'close' | 'open'; startPos: number; endPos: number }; type MatchedTag = { tag: string; @@ -8,7 +12,8 @@ type MatchedTag = { }; export class TagMatcher { - static TAG_REGEX = /\<(\/)?([^\>\<\s]+)[^\>\<]*?(\/?)\>/g; + // see regexr.com/3t585 + static TAG_REGEX = /\<(\/)?([^\>\<\s\/]+)(?:[^\>\<]*?)(\/)?\>/g; static OPEN_FORWARD_SLASH = 1; static TAG_NAME = 2; static CLOSE_FORWARD_SLASH = 3; @@ -18,7 +23,7 @@ export class TagMatcher { closeStart: number | undefined; closeEnd: number | undefined; - constructor(corpus: string, position: number) { + constructor(corpus: string, position: number, vimState: VimState) { let match = TagMatcher.TAG_REGEX.exec(corpus); const tags: Tag[] = []; @@ -72,8 +77,10 @@ export class TagMatcher { } } + const startPos = TextEditor.getOffsetAt(vimState.cursorStartPosition); + const endPos = position; const tagsSurrounding = matchedTags.filter(n => { - return position >= n.openingTagStart && position <= n.closingTagEnd; + return startPos > n.openingTagStart && endPos < n.closingTagEnd; }); if (!tagsSurrounding.length) { @@ -84,9 +91,18 @@ export class TagMatcher { const nodeSurrounding = tagsSurrounding[0]; this.openStart = nodeSurrounding.openingTagStart; - this.openEnd = nodeSurrounding.openingTagEnd; - this.closeStart = nodeSurrounding.closingTagStart; this.closeEnd = nodeSurrounding.closingTagEnd; + // if the inner tag content is already selected, expand to enclose tags with 'it' as in vim + if ( + startPos === nodeSurrounding.openingTagEnd && + endPos + 1 === nodeSurrounding.closingTagStart + ) { + this.openEnd = this.openStart; + this.closeStart = this.closeEnd; + } else { + this.openEnd = nodeSurrounding.openingTagEnd; + this.closeStart = nodeSurrounding.closingTagStart; + } } findOpening(inclusive: boolean): number | undefined { diff --git a/src/mode/modeHandler.ts b/src/mode/modeHandler.ts index 92fed3f756e..303191bfc53 100644 --- a/src/mode/modeHandler.ts +++ b/src/mode/modeHandler.ts @@ -521,11 +521,12 @@ export class ModeHandler implements vscode.Disposable { } if (recordedState.operatorReadyToExecute(vimState.currentMode)) { - vimState = await this.executeOperator(vimState); - - vimState.recordedState.hasRunOperator = true; - ranRepeatableAction = vimState.recordedState.operator.canBeRepeatedWithDot; - ranAction = true; + if (vimState.recordedState.operator) { + vimState = await this.executeOperator(vimState); + vimState.recordedState.hasRunOperator = true; + ranRepeatableAction = vimState.recordedState.operator.canBeRepeatedWithDot; + ranAction = true; + } } if (vimState.currentMode === ModeName.Visual) { @@ -757,7 +758,8 @@ export class ModeHandler implements vscode.Disposable { let recordedState = vimState.recordedState; if (!recordedState.operator) { - throw new Error("what in god's name"); + console.error('recordedState.operator: ' + recordedState.operator); + throw new Error("what in god's name. recordedState.operator is falsy."); } let resultVimState = vimState; @@ -1471,14 +1473,14 @@ export class ModeHandler implements vscode.Disposable { if (vimState.currentMode === ModeName.Insert) { // Check if the keypress is a closing bracket to a corresponding opening bracket right next to it - let result = PairMatcher.nextPairedChar(vimState.cursorPosition, key, false); + let result = PairMatcher.nextPairedChar(vimState.cursorPosition, key); if (result !== undefined) { if (vimState.cursorPosition.compareTo(result) === 0) { return true; } } - result = PairMatcher.nextPairedChar(vimState.cursorPosition.getLeft(), key, true); + result = PairMatcher.nextPairedChar(vimState.cursorPosition.getLeft(), key); if (result !== undefined) { if (vimState.cursorPosition.getLeftByCount(2).compareTo(result) === 0) { return true; diff --git a/test/mode/modeNormal.test.ts b/test/mode/modeNormal.test.ts index e9efac33c3e..fe6bb942582 100644 --- a/test/mode/modeNormal.test.ts +++ b/test/mode/modeNormal.test.ts @@ -364,6 +364,22 @@ suite('Mode Normal', () => { endMode: ModeName.Insert, }); + newTest({ + title: "Can handle count prefixed 'ci)'", + start: [' b(l(baz(f|oo)baz)a)h '], + keysPressed: 'c3i)', + end: [' b(|)h '], + endMode: ModeName.Insert, + }); + + newTest({ + title: "Can handle count prefixed 'ca)'", + start: [' b(l(baz(f|oo)baz)a)h '], + keysPressed: 'c3a)', + end: [' b|h '], + endMode: ModeName.Insert, + }); + newTest({ title: "Can handle 'ca(' spanning multiple lines", start: ['call(', ' |arg1)'], diff --git a/test/mode/modeVisual.test.ts b/test/mode/modeVisual.test.ts index 3feeeb6a7fb..9ac86c4d5d4 100644 --- a/test/mode/modeVisual.test.ts +++ b/test/mode/modeVisual.test.ts @@ -523,6 +523,15 @@ suite('Mode Visual', () => { endMode: ModeName.Normal, }); + newTest({ + title: + 'Count-prefixed vit alternates expanding selection between inner and outer tag brackets', + start: ['
one

t|wo

three
'], + keysPressed: 'v3itd', + end: ['
|
'], + endMode: ModeName.Normal, + }); + newTest({ title: 'Can do vat on a matching tag', start: ['one he|llo two'], @@ -532,6 +541,46 @@ suite('Mode Visual', () => { }); }); + newTest({ + title: 'Can do vat on multiple matching tags', + start: ['one two he|llo three four'], + keysPressed: 'vatatd', + end: ['one | four'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'Can maintain selection on failure with vat on multiple matching tags', + start: ['one two he|llo three four'], + keysPressed: 'vatatatatd', + end: ['one | four'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'Can maintain selection on failure with repeat-prefixed vat on multiple matching tags', + start: ['one two he|llo three four'], + keysPressed: 'v4atd', + end: ['one | four'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'Repeat-prefixed vat does not bleed below', + start: ['

', '\t

', '\t|test', '\t

', '

', '', 'do not delete'], + keysPressed: 'v8atd', + end: ['|', '', 'do not delete'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'Failed vat does not expand or move selection, remains in visual mode', + start: ['one | two'], + keysPressed: 'v4atd', + end: ['one |two'], + endMode: ModeName.Normal, + }); + newTest({ title: 'Can do vi) on a matching parenthesis', start: ['test(te|st)'], @@ -540,6 +589,14 @@ suite('Mode Visual', () => { endMode: ModeName.Normal, }); + newTest({ + title: 'Can do vi) on multiple matching parens', + start: ['test(te(te|st)st)'], + keysPressed: 'vi)i)d', + end: ['test(|)'], + endMode: ModeName.Normal, + }); + newTest({ title: 'Can do va) on a matching parenthesis', start: ['test(te|st);'], @@ -548,6 +605,30 @@ suite('Mode Visual', () => { endMode: ModeName.Normal, }); + newTest({ + title: 'Can do va) on multiple matching parens', + start: ['test(te(te|st)st);'], + keysPressed: 'va)a)d', + end: ['test|;'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'Failed va) does not expand or move selection, remains in visual mode', + start: ['one | two'], + keysPressed: 'v4a)d', + end: ['one |two'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'Repeat-prefixed va) does not bleed below', + start: ['(', '\t(', '\t|', '\t)', ')', '', 'do not delete'], + keysPressed: 'v8a)d', + end: ['|', '', 'do not delete'], + endMode: ModeName.Normal, + }); + newTest({ title: 'Can do va} on a matching bracket as first character', start: ['1|{', 'test', '}1'], @@ -556,6 +637,14 @@ suite('Mode Visual', () => { endMode: ModeName.Normal, }); + newTest({ + title: 'Can do va} on multiple matching brackets', + start: ['test{te{te|st}st};'], + keysPressed: 'va}a}d', + end: ['test|;'], + endMode: ModeName.Normal, + }); + newTest({ title: 'Can do vi( on a matching bracket near first character', start: ['test(()=>{', '|', '});'], @@ -580,6 +669,37 @@ suite('Mode Visual', () => { endMode: ModeName.Normal, }); + newTest({ + title: 'Can do va] on multiple matching brackets', + start: ['test[te[te|st]st];'], + keysPressed: 'va]a]d', + end: ['test|;'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'Can do repeat-prefixed vaf on multiple matching pairs of different types', + start: ['test

[[{{((|))}}]]

test;'], + keysPressed: 'v8afd', + end: ['test | test;'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'Repeat-prefixed vaf does not bleed below', + start: ['

', '\t(', '\t|', '\t)', '

', '', 'do not delete'], + keysPressed: 'v8afd', + end: ['|', '', 'do not delete'], + endMode: ModeName.Normal, + }); + + newTest({ + title: 'vaf only expands to enclosing pairs', + start: ['test (f|oo) "hi" test;'], + keysPressed: 'vafd', + end: ['test | "hi" test;'], + endMode: ModeName.Normal, + }); suite('handles replace in visual mode', () => { newTest({ title: 'Can do a single line replace',