From 8bacbcd1d2da3c046a2e26e1dfb492da38105369 Mon Sep 17 00:00:00 2001 From: Aiden Scandella Date: Thu, 14 Jul 2016 22:25:58 -0700 Subject: [PATCH] Implement additional text object commands This allows for e.g. 'ci(', 'ca"', 'di<' commands. --- ROADMAP.md | 12 +-- src/actions/actions.ts | 190 ++++++++++++++++++++++++----------- src/matching/matcher.ts | 64 ++++++++++++ src/motion/position.ts | 15 +++ test/mode/modeNormal.test.ts | 40 ++++++++ 5 files changed, 255 insertions(+), 66 deletions(-) create mode 100644 src/matching/matcher.ts diff --git a/ROADMAP.md b/ROADMAP.md index 0b39e0481b61..3e45f6bd363b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -268,14 +268,14 @@ Status | Command | Description | :1234: ib | Select "inner block" (from "[(" to "])") | :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: a> | Select "a <> 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..c4d193b169cb 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,17 @@ 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; - } - 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.matchesWithPercentageMotion) { // 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 +2174,8 @@ class MoveToMatchingBracket extends BaseMovement { return position; } - return this.nextBracket(position, charToMatch, toFind, true); -} + return PairMatcher.nextPairedChar(position, charToMatch, true); + } public async execActionForOperator(position: Position, vimState: VimState): Promise { const result = await this.execAction(position, vimState); @@ -2238,13 +2188,133 @@ class MoveToMatchingBracket extends BaseMovement { } } +abstract class MoveInsideCharacter extends BaseMovement { + modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine]; + protected charToMatch: string; + protected includeSurrounding = false; + protected lineMatchOnly = false; + + 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 = position.lastIndexOf(this.charToMatch, this.lineMatchOnly); + const startPlusOne = new Position(startPos.line, startPos.character + 1); + + let endPos = PairMatcher.nextPairedChar(startPlusOne, this.charToMatch, false, this.lineMatchOnly); + + if (startPos === position && text[position.character] !== this.charToMatch) { + return position; + } + if (startPos.isAfter(position) || endPos.isBefore(position)) { + return position; + } + + if (this.includeSurrounding) { + endPos = new Position(endPos.line, endPos.character + 1); + } else { + startPos = startPlusOne; + } + // If the closing character is the first on the line, don't swallow it. + if (endPos.character === 0) { + endPos = endPos.getLeftThroughLineBreaks(); + } + 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 = "\""; + lineMatchOnly = true; +} + +@RegisterAction +class MoveISingleQuote extends MoveInsideCharacter { + keys = ["i", "'"]; + charToMatch = "'"; + lineMatchOnly = true; +} + +@RegisterAction +class MoveADoubleQuote extends MoveInsideCharacter { + keys = ["a", "\""]; + charToMatch = "\""; + includeSurrounding = true; + lineMatchOnly = true; +} + +@RegisterAction +class MoveASingleQuote extends MoveInsideCharacter { + keys = ["a", "'"]; + charToMatch = "'"; + includeSurrounding = true; + lineMatchOnly = 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 PairMatcher.nextPairedChar(position.getLeftThroughLineBreaks(), charToMatch, false); } } @@ -2254,7 +2324,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 PairMatcher.nextPairedChar(position.getRightThroughLineBreaks(), charToMatch, false); } } @@ -2264,7 +2334,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 PairMatcher.nextPairedChar(position.getLeftThroughLineBreaks(), charToMatch, false); } } @@ -2274,7 +2344,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 PairMatcher.nextPairedChar(position.getRightThroughLineBreaks(), charToMatch, false); } } diff --git a/src/matching/matcher.ts b/src/matching/matcher.ts new file mode 100644 index 000000000000..047530c4c452 --- /dev/null +++ b/src/matching/matcher.ts @@ -0,0 +1,64 @@ +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, 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 }, + // These characters can't be used for "%"-based matching, but are still + "'" : { match: "'", nextMatchIsForward: true }, + "\"": { match: "\"", nextMatchIsForward: true }, + "<" : { match: ">", nextMatchIsForward: true }, + }; + + static 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 = this.pairings[charToMatch]; + + let stackHeight = closed ? 0 : 1; + let matchedPosition: Position | undefined = undefined; + + for (const { char, pos } of Position.IterateDocument(position, toFind.nextMatchIsForward)) { + if (currentLineOnly && position.line < pos.line) { + break; + } + 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/src/motion/position.ts b/src/motion/position.ts index ed551bd1849e..ee40e4f410c7 100644 --- a/src/motion/position.ts +++ b/src/motion/position.ts @@ -621,4 +621,19 @@ export class Position extends vscode.Position { return position; } + + public lastIndexOf(char: string, currentLineOnly: boolean = false): Position { + for (const current of Position.IterateDocument(this, false)) { + if (currentLineOnly && this.line !== current.pos.line) { + // We've advanced past the current line and the caller doesn't want us to + return this; + } + + if (current.char === char) { + return current.pos; + } + } + + return this; + } } \ No newline at end of file diff --git a/test/mode/modeNormal.test.ts b/test/mode/modeNormal.test.ts index e9c9280e03e9..20e3e23b0b34 100644 --- a/test/mode/modeNormal.test.ts +++ b/test/mode/modeNormal.test.ts @@ -219,6 +219,46 @@ suite("Mode Normal", () => { endMode: ModeName.Insert }); + newTest({ + title: "Can handle 'ci(' on first parentheses", + start: ['print(|"hello")'], + keysPressed: 'ci(', + end: ['print(|)'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci(' with nested parentheses", + start: ['call|(() => 5)'], + keysPressed: 'ci(', + end: ['call(|)'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' inside a string", + start: ['"hel|lo" world'], + keysPressed: 'ci"', + end: ['"|" world'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle \"ci'\" inside a string", + start: ['\'on|e\' two'], + keysPressed: 'ci\'', + end: ['\'|\' two'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ca(' spanning multiple lines", + start: ['call(', ' |arg1)'], + keysPressed: 'ca(', + end: ['call|'], + endMode: ModeName.Insert + }); + newTest({ title: "Can handle 'df'", start: ['aext tex|t'],