diff --git a/ROADMAP.md b/ROADMAP.md index f90a49a52ebb..d73d08525e3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -272,12 +272,12 @@ Status | Command | Description :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" - | :1234: a` | Select "a backward quoted string" - | :1234: i` | Select "inner backward 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" +:warning: | :1234: a` | Select "a backward quoted string" +:warning: | :1234: i` | Select "inner backward quoted string" ## Repeating commands diff --git a/src/actions/actions.ts b/src/actions/actions.ts index 27497a9b82ac..f79020ed4484 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -4,6 +4,7 @@ import { TextEditor } from './../textEditor'; import { Register, RegisterMode } from './../register/register'; import { Position } from './../motion/position'; import { PairMatcher } from './../matching/matcher'; +import { QuoteMatcher } from './../matching/quoteMatcher'; import { Tab, TabCommand } from './../cmd_line/commands/tab'; import * as vscode from 'vscode'; @@ -2521,6 +2522,89 @@ class MoveAClosingSquareBracket extends MoveInsideCharacter { includeSurrounding = true; } +abstract class MoveQuoteMatch extends BaseMovement { + modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine]; + protected charToMatch: string; + protected includeSurrounding = false; + + public async execAction(position: Position, vimState: VimState): Promise { + const text = TextEditor.getLineAt(position).text; + const quoteMatcher = new QuoteMatcher(this.charToMatch, text); + const start = quoteMatcher.findOpening(position.character); + const end = quoteMatcher.findClosing(start + 1); + + if (start === -1 || end === -1 || end === start) { + return { + start: position, + stop: position, + failed: true + }; + } + + let startPos = new Position(position.line, start); + let endPos = new Position(position.line, end); + if (!this.includeSurrounding) { + startPos = startPos.getRight(); + endPos = endPos.getLeft(); + } + + return { + start: startPos, + stop: endPos + }; + } + + public async execActionForOperator(position: Position, vimState: VimState): Promise { + const res = await this.execAction(position, vimState); + + res.stop = res.stop.getRight(); + + return res; + } +} + +@RegisterAction +class MoveInsideSingleQuotes extends MoveQuoteMatch { + keys = ["i", "'"]; + charToMatch = "'"; + includeSurrounding = false; +} + +@RegisterAction +class MoveASingleQuotes extends MoveQuoteMatch { + keys = ["a", "'"]; + charToMatch = "'"; + includeSurrounding = true; +} + +@RegisterAction +class MoveInsideDoubleQuotes extends MoveQuoteMatch { + keys = ["i", "\""]; + charToMatch = "\""; + includeSurrounding = false; +} + +@RegisterAction +class MoveADoubleQuotes extends MoveQuoteMatch { + keys = ["a", "\""]; + charToMatch = "\""; + includeSurrounding = true; +} + +@RegisterAction +class MoveInsideBacktick extends MoveQuoteMatch { + keys = ["i", "`"]; + charToMatch = "`"; + includeSurrounding = false; +} + +@RegisterAction +class MoveABacktick extends MoveQuoteMatch { + keys = ["a", "`"]; + charToMatch = "`"; + includeSurrounding = true; +} + @RegisterAction class MoveToUnclosedRoundBracketBackward extends MoveToMatchingBracket { keys = ["[", "("]; diff --git a/src/matching/matcher.ts b/src/matching/matcher.ts index 11edbc6d190f..82dd1aa27584 100644 --- a/src/matching/matcher.ts +++ b/src/matching/matcher.ts @@ -14,8 +14,6 @@ export class PairMatcher { "]" : { match: "[", nextMatchIsForward: false, matchesWithPercentageMotion: true }, // These characters can't be used for "%"-based matching, but are still // useful for text objects. - // "'" : { match: "'", nextMatchIsForward: true }, - // "\"": { match: "\"", nextMatchIsForward: true }, "<" : { match: ">", nextMatchIsForward: true }, ">" : { match: "<", nextMatchIsForward: false }, }; diff --git a/src/matching/quoteMatcher.ts b/src/matching/quoteMatcher.ts new file mode 100644 index 000000000000..df44df32cdd1 --- /dev/null +++ b/src/matching/quoteMatcher.ts @@ -0,0 +1,37 @@ +/** + * QuoteMatcher matches quoted strings, respecting escaped quotes (\") and friends + */ +export class QuoteMatcher { + static escapeChar = "\\"; + + private quoteMap: boolean[] = []; + + constructor(char: string, corpus: string) { + // Loop over corpus, marking quotes and respecting escape characters. + for (let i = 0; i < corpus.length; i++) { + if (corpus[i] === QuoteMatcher.escapeChar) { + i += 1; + continue; + } + this.quoteMap[i] = corpus[i] === char; + } + } + + findOpening(start: number): number { + // First, search backwards to see if we could be inside a quote + for (let i = start - 1; i >= 0; i--) { + if (this.quoteMap[i]) { + return i; + } + } + + // Didn't find one behind us, the string may start ahead of us. This happens + // to be the same logic we use to search forwards. + return this.findClosing(start); + } + + findClosing(start: number): number { + // Search forwards from start, looking for a non-escaped char + return this.quoteMap.indexOf(true, start); + } +} \ No newline at end of file diff --git a/test/mode/modeNormal.test.ts b/test/mode/modeNormal.test.ts index 1402507d306e..d908c00260ea 100644 --- a/test/mode/modeNormal.test.ts +++ b/test/mode/modeNormal.test.ts @@ -328,6 +328,150 @@ suite("Mode Normal", () => { endMode: ModeName.Insert }); + newTest({ + title: "Can handle 'ci\'' on first quote", + start: ["|'one'"], + keysPressed: "ci'", + end: ["'|'"], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\'' inside quoted string", + start: ["'o|ne'"], + keysPressed: "ci'", + end: ["'|'"], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\'' on closing quote", + start: ["'one|'"], + keysPressed: "ci'", + end: ["'|'"], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\'' when string is ahead", + start: ["on|e 'two'"], + keysPressed: "ci'", + end: ["one '|'"], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' on opening quote", + start: ['|"one"'], + keysPressed: 'ci"', + end: ['"|"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' starting behind the quoted word", + start: ['|one "two"'], + keysPressed: 'ci"', + end: ['one "|"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ca\"' starting behind the quoted word", + start: ['|one "two"'], + keysPressed: 'ca"', + end: ['one |'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ca\"' starting on the opening quote", + start: ['one |"two"'], + keysPressed: 'ca"', + end: ['one |'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' with escaped quotes", + start: ['"one \\"tw|o\\""'], + keysPressed: 'ci"', + end: ['"|"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' with a single escaped quote", + start: ['|"one \\" two"'], + keysPressed: 'ci"', + end: ['"|"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' with a single escaped quote behind", + start: ['one "two \\" |three"'], + keysPressed: 'ci"', + end: ['one "|"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' with an escaped backslash", + start: ['one "tw|o \\\\three"'], + keysPressed: 'ci"', + end: ['one "|"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' with an escaped backslash on closing quote", + start: ['"\\\\|"'], + keysPressed: 'ci"', + end: ['"|"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ca\"' starting on the closing quote", + start: ['one "two|"'], + keysPressed: 'ca"', + end: ['one |'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Can handle 'ci\"' with complex escape sequences", + start: ['"two|\\\\\\""'], + keysPressed: 'ci"', + end: ['"|"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "Emulates vim's behavior sandwiched between double quoted strings for 'ci\"'", + start: ['"one" |"two"'], + keysPressed: 'ci"', + end: ['"one"|"two"'], + endMode: ModeName.Insert + }); + + newTest({ + title: "will fail when ca\" ahead of quoted string", + start: ['"one" |two'], + keysPressed: 'ca"', + end: ['"one" |two'], + endMode: ModeName.Normal + }); + + newTest({ + title: "Can handle 'ca`' inside word", + start: ['one `t|wo`'], + keysPressed: 'ca`', + end: ['one |'], + endMode: ModeName.Insert + }); + newTest({ title: "Can handle 'daw' on word with cursor inside spaces", start: ['one two | three, four '],