Skip to content

Commit

Permalink
Fix escaping in :s substitutions (#6891)
Browse files Browse the repository at this point in the history
* `parsePattern` generally leaves backslashes alone, passing them on
  to the regular expression parser.
* New `parseReplace` interprets backslashes.  New definitions for `\b`
  (backspace), `\&` (same as `$&`), and `\0` through `\9` (same as
  `$1` through `$9`).
* Rewrite these parsers in imperative style to avoid any stack overflow.

Fixes #6890
  • Loading branch information
edemaine authored Jul 20, 2021
1 parent 9483523 commit 10111ef
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 32 deletions.
91 changes: 59 additions & 32 deletions src/cmd_line/subparsers/substitute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,49 +8,76 @@ function isValidDelimiter(char: string): boolean {
return !!/^[^\w\s\\|"]{1}$/g.exec(char);
}

function parsePattern(pattern: string, scanner: Scanner, delimiter: string): [string, boolean] {
if (scanner.isAtEof) {
return [pattern, false];
} else {
function parsePattern(scanner: Scanner, delimiter: string): [string, boolean] {
let pattern = '';
while (!scanner.isAtEof) {
let currentChar = scanner.next();

if (currentChar === delimiter) {
// TODO skip delimiter
return [pattern, true];
return [pattern, true]; // found second delimiter
} else if (currentChar === '\\') {
if (!scanner.isAtEof) {
currentChar = scanner.next();

if (currentChar !== delimiter) {
switch (currentChar) {
case 'r':
pattern += '\r';
break;
case 'n':
pattern += '\n';
break;
case 't':
pattern += '\t';
break;
case '\\':
pattern += '\\';
break;
default:
pattern += '\\';
pattern += currentChar;
break;
}
if (currentChar === delimiter) {
pattern += delimiter;
} else {
pattern += currentChar;
pattern += '\\' + currentChar;
}
} else {
pattern += '\\\\'; // :s/\ is treated like :s/\\
}

return parsePattern(pattern, scanner, delimiter);
} else {
pattern += currentChar;
return parsePattern(pattern, scanner, delimiter);
}
}
return [pattern, false];
}

// See Vim's sub-replace-special documentation
// TODO: \u, \U, \l, \L, \e, \E
const replaceEscapes = {
b: '\b',
r: '\r',
n: '\n',
t: '\t',
'&': '$&',
'0': '$0',
'1': '$1',
'2': '$2',
'3': '$3',
'4': '$4',
'5': '$5',
'6': '$6',
'7': '$7',
'8': '$8',
'9': '$9',
};

function parseReplace(scanner: Scanner, delimiter: string): string {
let replace = '';
while (!scanner.isAtEof) {
let currentChar = scanner.next();

if (currentChar === delimiter) {
return replace; // found second delimiter
} else if (currentChar === '\\') {
if (!scanner.isAtEof) {
currentChar = scanner.next();
if (currentChar === delimiter) {
replace += delimiter;
} else if (replaceEscapes.hasOwnProperty(currentChar)) {
replace += replaceEscapes[currentChar];
} else {
replace += currentChar;
}
} else {
replace += '\\'; // :s/.../\ is treated like :s/.../\\
}
} else {
replace += currentChar;
}
}
return replace;
}

function parseSubstituteFlags(scanner: Scanner): number {
Expand Down Expand Up @@ -165,7 +192,7 @@ export function parseSubstituteCommandArgs(args: string): node.SubstituteCommand
let secondDelimiterFound: boolean;

scanner = new Scanner(args.substr(1, args.length - 1));
[searchPattern, secondDelimiterFound] = parsePattern('', scanner, delimiter);
[searchPattern, secondDelimiterFound] = parsePattern(scanner, delimiter);

if (!secondDelimiterFound) {
// special case for :s/search
Expand All @@ -175,7 +202,7 @@ export function parseSubstituteCommandArgs(args: string): node.SubstituteCommand
flags: node.SubstituteFlags.None,
});
}
replaceString = parsePattern('', scanner, delimiter)[0];
replaceString = parseReplace(scanner, delimiter);
} else {
// if it's not a valid delimiter, it must be flags, so start parsing from here
searchPattern = undefined;
Expand Down
12 changes: 12 additions & 0 deletions test/cmd_line/subparser.substitute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ suite(':substitute args parser', () => {
assert.strictEqual(args.arguments.replace, 'b');
});

test('can use pattern escapes', () => {
const args = commandParsers.substitute.parser('/\\ba/b/');
assert.strictEqual(args.arguments.pattern, '\\ba');
assert.strictEqual(args.arguments.replace, 'b');
});

test('can escape replacement', () => {
const args = commandParsers.substitute.parser('/a/\\b/');
assert.strictEqual(args.arguments.pattern, 'a');
assert.strictEqual(args.arguments.replace, '\b');
});

test('can parse flag KeepPreviousFlags', () => {
const args = commandParsers.substitute.parser('/a/b/&');
assert.strictEqual(args.arguments.flags, 1);
Expand Down
21 changes: 21 additions & 0 deletions test/cmd_line/substitute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ suite('Basic substitute', () => {
end: ['blah blah', '|blah', 'blah blah', 'yay blah'],
});

newTest({
title: 'Preserve \\b in regular expression',
start: ['one |two three thirteen'],
keysPressed: sub('\\bt', 'x', { flags: 'g' }),
end: ['|one xwo xhree xhirteen'],
});

newTest({
title: 'Preserve \\\\ in regular expression',
start: ['one |\\two \\three thirteen'],
keysPressed: sub('\\\\t', 'x', { flags: 'g' }),
end: ['|one xwo xhree thirteen'],
});

newTest({
title: 'Replace with \\n',
start: ['one |two three'],
Expand All @@ -174,6 +188,13 @@ suite('Basic substitute', () => {
end: ['|one \\wo \\hree'],
});

newTest({
title: 'Replace trailing \\ with \\',
start: ['one |two three'],
keysPressed: sub('t', '\\'),
end: ['|one \\wo three'],
});

newTest({
title: 'Replace specific single equal lines',
start: ['|aba', 'ab'],
Expand Down

0 comments on commit 10111ef

Please sign in to comment.