diff --git a/ROADMAP.md b/ROADMAP.md index 0b39e0481b61..7699763b3747 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -269,13 +269,13 @@ Status | Command | Description | :1234: aB | Select "a Block" (from "[{" to "]}") | :1234: iB | Select "inner Block" (from "[{" to "]}") | :1234: a> | Select "a <> block" - | :1234: i> | Select "inner <> block" +:warning: | :1234: i> | Select "inner <> block" | :1234: at | Select "a tag block" (from to ) | :1234: it | Select "inner tag block" (from to ) - | :1234: a' | Select "a single quoted string" - | :1234: i' | Select "inner single quoted string" - | :1234: a" | Select "a double quoted string" - | :1234: i" | Select "inner double quoted string" +:warning: | :1234: a' | Select "a single quoted string" +:warning: | :1234: i' | Select "inner single quoted string" +:warning: | :1234: a" | Select "a double quoted string" +:warning: | :1234: i" | Select "inner double quoted string" | :1234: a` | Select "a backward quoted string" | :1234: i` | Select "inner backward quoted string" diff --git a/src/actions/actions.ts b/src/actions/actions.ts index 6573ab6de28b..ac1d4c5ae86e 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -3,6 +3,7 @@ import { ModeName } from './../mode/mode'; import { TextEditor } from './../textEditor'; import { Register, RegisterMode } from './../register/register'; import { Position } from './../motion/position'; +import { PairMatcher } from './../matching/matcher'; import * as vscode from 'vscode'; const controlKeys: string[] = [ @@ -2154,68 +2155,19 @@ class MoveToMatchingBracket extends BaseMovement { modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine]; keys = ["%"]; - pairings: { [key: string]: { match: string, nextMatchIsForward: boolean }} = { - "(" : { match: ")", nextMatchIsForward: true }, - "{" : { match: "}", nextMatchIsForward: true }, - "[" : { match: "]", nextMatchIsForward: true }, - ")" : { match: "(", nextMatchIsForward: false }, - "}" : { match: "{", nextMatchIsForward: false }, - "]" : { match: "[", nextMatchIsForward: false }, - // "'" : { match: "'", direction: 0 }, - // "\"": { match: "\"", direction: 0 }, - }; - - nextBracket(position: Position, charToMatch: string, toFind: { match: string, nextMatchIsForward: boolean }, closed: boolean = true) { - /** - * We do a fairly basic implementation that only tracks the state of the type of - * character you're over and its pair (e.g. "[" and "]"). This is similar to - * what Vim does. - * - * It can't handle strings very well - something like "|( ')' )" where | is the - * cursor will cause it to go to the ) in the quotes, even though it should skip over it. - * - * PRs welcomed! (TODO) - * Though ideally VSC implements https://github.com/Microsoft/vscode/issues/7177 - */ - - let stackHeight = closed ? 0 : 1; - let matchedPosition: Position | undefined = undefined; - - for (const { char, pos } of Position.IterateDocument(position, toFind.nextMatchIsForward)) { - if (char === charToMatch) { - stackHeight++; - } - - if (char === this.pairings[charToMatch].match) { - stackHeight--; - } - - if (stackHeight === 0) { - matchedPosition = pos; - - break; - } - } - - if (matchedPosition) { - return matchedPosition; - } - - // TODO(bell) - return position; - } + matcher = new PairMatcher(); public async execAction(position: Position, vimState: VimState): Promise { const text = TextEditor.getLineAt(position).text; const charToMatch = text[position.character]; - const toFind = this.pairings[charToMatch]; + const toFind = PairMatcher.pairings[charToMatch]; - if (!toFind) { + if (!toFind || toFind.limited) { // If we're not on a match, go right until we find a // pairable character or hit the end of line. for (let i = position.character; i < text.length; i++) { - if (this.pairings[text[i]]) { + if (PairMatcher.pairings[text[i]]) { return new Position(position.line, i); } } @@ -2224,8 +2176,8 @@ class MoveToMatchingBracket extends BaseMovement { return position; } - return this.nextBracket(position, charToMatch, toFind, true); -} + return this.matcher.nextPairedChar(position, charToMatch, true); + } public async execActionForOperator(position: Position, vimState: VimState): Promise { const result = await this.execAction(position, vimState); @@ -2238,13 +2190,121 @@ class MoveToMatchingBracket extends BaseMovement { } } +abstract class MoveInsideCharacter extends BaseMovement { + modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine]; + protected charToMatch: string; + protected includeSurrounding = false; + protected matcher = new PairMatcher(); + + public async execAction(position: Position, vimState: VimState): Promise { + const text = TextEditor.getLineAt(position).text; + + // First, search backwards for the opening character of the sequence + let startPos = this.matcher.previousChar(position, this.charToMatch); + const startPlusOne = new Position(startPos.line, startPos.character + 1); + + let endPos = this.matcher.nextPairedChar(startPlusOne, this.charToMatch, false); + if (startPos.isAfter(position) || endPos.isBefore(position)) { + return position; + } + + if (this.includeSurrounding) { + endPos = new Position(endPos.line, endPos.character + 1); + } else { + startPos = startPlusOne; + } + return { + start : startPos, + stop : endPos, + }; + } +} + +@RegisterAction +class MoveIParentheses extends MoveInsideCharacter { + keys = ["i", "("]; + charToMatch = "("; +} + +@RegisterAction +class MoveIClosingParentheses extends MoveInsideCharacter { + keys = ["i", ")"]; + charToMatch = "("; +} + +@RegisterAction +class MoveAParentheses extends MoveInsideCharacter { + keys = ["a", "("]; + charToMatch = "("; + includeSurrounding = true; +} + +@RegisterAction +class MoveAClosingParentheses extends MoveInsideCharacter { + keys = ["a", ")"]; + charToMatch = "("; + includeSurrounding = true; +} + +@RegisterAction +class MoveICaret extends MoveInsideCharacter { + keys = ["i", "<"]; + charToMatch = "<"; +} + +@RegisterAction +class MoveIClosingCaret extends MoveInsideCharacter { + keys = ["i", ">"]; + charToMatch = "<"; +} + +@RegisterAction +class MoveACaret extends MoveInsideCharacter { + keys = ["a", "<"]; + charToMatch = "<"; + includeSurrounding = true; +} + +@RegisterAction +class MoveAClosingCaret extends MoveInsideCharacter { + keys = ["a", ">"]; + charToMatch = "<"; + includeSurrounding = true; +} + +@RegisterAction +class MoveIDoubleQuote extends MoveInsideCharacter { + keys = ["i", "\""]; + charToMatch = "\""; +} + +@RegisterAction +class MoveISingleQuote extends MoveInsideCharacter { + keys = ["i", "'"]; + charToMatch = "'"; +} + +@RegisterAction +class MoveADoubleQuote extends MoveInsideCharacter { + keys = ["a", "\""]; + charToMatch = "\""; + includeSurrounding = true; +} + +@RegisterAction +class MoveASingleQuote extends MoveInsideCharacter { + keys = ["a", "'"]; + charToMatch = "'"; + includeSurrounding = true; +} + @RegisterAction class MoveToUnclosedRoundBracketBackward extends MoveToMatchingBracket { keys = ["[", "("]; public async execAction(position: Position, vimState: VimState): Promise { const charToMatch = ")"; - return this.nextBracket(position.getLeftThroughLineBreaks(), charToMatch, this.pairings[charToMatch], false); + return this.matcher.nextPairedChar(position.getLeftThroughLineBreaks(), charToMatch, false); } } @@ -2254,7 +2314,7 @@ class MoveToUnclosedRoundBracketForward extends MoveToMatchingBracket { public async execAction(position: Position, vimState: VimState): Promise { const charToMatch = "("; - return this.nextBracket(position.getRightThroughLineBreaks(), charToMatch, this.pairings[charToMatch], false); + return this.matcher.nextPairedChar(position.getRightThroughLineBreaks(), charToMatch, false); } } @@ -2264,7 +2324,7 @@ class MoveToUnclosedCurlyBracketBackward extends MoveToMatchingBracket { public async execAction(position: Position, vimState: VimState): Promise { const charToMatch = "}"; - return this.nextBracket(position.getLeftThroughLineBreaks(), charToMatch, this.pairings[charToMatch], false); + return this.matcher.nextPairedChar(position.getLeftThroughLineBreaks(), charToMatch, false); } } @@ -2274,7 +2334,7 @@ class MoveToUnclosedCurlyBracketForward extends MoveToMatchingBracket { public async execAction(position: Position, vimState: VimState): Promise { const charToMatch = "{"; - return this.nextBracket(position.getRightThroughLineBreaks(), charToMatch, this.pairings[charToMatch], false); + return this.matcher.nextPairedChar(position.getRightThroughLineBreaks(), charToMatch, false); } } diff --git a/src/matching/matcher.ts b/src/matching/matcher.ts new file mode 100644 index 000000000000..9d61ba2e84c3 --- /dev/null +++ b/src/matching/matcher.ts @@ -0,0 +1,79 @@ +import { Position } from './../motion/position'; + +/** + * PairMatcher finds the position matching the given character, respecting nested + * instances of the pair. + */ +export class PairMatcher { + static pairings: { [key: string]: { match: string, nextMatchIsForward: boolean, limited?: boolean }} = { + "(" : { match: ")", nextMatchIsForward: true }, + "{" : { match: "}", nextMatchIsForward: true }, + "[" : { match: "]", nextMatchIsForward: true }, + ")" : { match: "(", nextMatchIsForward: false }, + "}" : { match: "{", nextMatchIsForward: false }, + "]" : { match: "[", nextMatchIsForward: false }, + // These characters can't be used for "%"-based matching, but are still + // useful for text objects. TODO limited is a terrible name. + "'" : { match: "'", nextMatchIsForward: true, limited: true }, + "\"": { match: "\"", nextMatchIsForward: true, limited: true }, + "<" : { match: ">", nextMatchIsForward: true, limited: true }, + }; + + previousChar(position: Position, charToMatch: string, currentLineOnly: boolean = false): Position { + // TODO handle currentLineOnly + for (const { char, pos } of Position.IterateDocument(position, false)) { + if (currentLineOnly && position.line != pos.line) { + // We've advanced past the current line and the caller doesn't want us to + return position; + } + + if (char === charToMatch) { + return pos; + } + } + + return position; + } + + nextPairedChar(position: Position, charToMatch: string, closed: boolean = true, currentLineOnly: boolean = false): Position { + /** + * We do a fairly basic implementation that only tracks the state of the type of + * character you're over and its pair (e.g. "[" and "]"). This is similar to + * what Vim does. + * + * It can't handle strings very well - something like "|( ')' )" where | is the + * cursor will cause it to go to the ) in the quotes, even though it should skip over it. + * + * PRs welcomed! (TODO) + * Though ideally VSC implements https://github.com/Microsoft/vscode/issues/7177 + */ + const toFind = PairMatcher.pairings[charToMatch]; + + let stackHeight = closed ? 0 : 1; + let matchedPosition: Position | undefined = undefined; + // TODO respect currentLineOnly + + for (const { char, pos } of Position.IterateDocument(position, toFind.nextMatchIsForward)) { + if (char === charToMatch && charToMatch !== toFind.match) { + stackHeight++; + } + + if (char === toFind.match) { + stackHeight--; + } + + if (stackHeight === 0) { + matchedPosition = pos; + + break; + } + } + + if (matchedPosition) { + return matchedPosition; + } + + // TODO(bell) + return position; + } +} \ No newline at end of file diff --git a/test/mode/modeNormal.test.ts b/test/mode/modeNormal.test.ts index e9c9280e03e9..3751957d3553 100644 --- a/test/mode/modeNormal.test.ts +++ b/test/mode/modeNormal.test.ts @@ -219,6 +219,41 @@ suite("Mode Normal", () => { endMode: ModeName.Insert }); + newTest({ + title: "Can handle 'ci(' on first parentheses", + start: ['print(|"hello")'], + keysPressed: 'ci(', + end: ['print(|)'] + }); + + newTest({ + title: "Can handle 'ci(' with nested parentheses", + start: ['call|(() => 5)'], + keysPressed: 'ci(', + end: ['call(|)'] + }); + + newTest({ + title: "Can handle 'ci\"' inside a string", + start: ['"hel|lo" world'], + keysPressed: 'ci"', + end: ['"|" world'] + }); + + newTest({ + title: "Can handle \"ci'\" inside a string", + start: ['\'on|e\' two'], + keysPressed: 'ci\'', + end: ['\'|\' two'] + }); + + newTest({ + title: "Can handle 'ca(' spanning multiple lines", + start: ['call(', ' |arg1)'], + keysPressed: 'ca(', + end: ['call|'] + }); + newTest({ title: "Can handle 'df'", start: ['aext tex|t'],