fix(cursor): harden shell search pattern parsing (#2938)

Keep unquoted Windows backslashes so rg.exe paths still parse, skip pattern-file operands, and treat grep -r as recursive rather than a valued replace flag.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergo Magyar 2026-08-29 09:18:25 +00:00
parent 3f82e46ca6
commit beca63d402
2 changed files with 111 additions and 11 deletions

View file

@ -126,7 +126,13 @@ function tokenizeShellWords(command) {
}
if (char === '\\') {
escaped = true;
const next = command[index + 1];
if (next === undefined || /\s/.test(next) || next === "'" || next === '"' || next === '\\') {
escaped = true;
} else {
current += '\\' + next;
index += 1;
}
hasToken = true;
} else if (char === "'" || char === '"') {
quote = char;
@ -153,22 +159,55 @@ function parseRgGrepPattern(cmd) {
let skipNextAsPattern = false;
let endOfOptions = false;
let explicitPatternSeen = false;
let patternFileSeen = false;
const flagsWithValues = new Set([
'-e',
'-f',
'--file',
'-m',
'--max-count',
'-A',
'-B',
'-C',
'-g',
'--glob',
'--iglob',
'-t',
'--type',
'--include',
'--exclude',
'--encoding',
'--path',
]);
const rgValueFlags = new Set(['-r', '--replace']);
const patternFlags = new Set(['-e', '--regexp']);
const connectors = new Set(['&&', '||', ';', '|', '&']);
const wrappers = new Set([
'npx',
'bunx',
'pnpm',
'yarn',
'npm',
'sudo',
'env',
'command',
'time',
'nice',
'xargs',
'dlx',
'exec',
'run',
'git',
]);
const basename = (token) =>
token
.split(/[\\/]/)
.pop()
?.replace(/\.(exe|cmd|bat)$/i, '');
let previousToken;
let seenWrapper = false;
let searchCommand = null;
for (const token of tokens) {
if (skipNext) {
skipNext = false;
@ -176,18 +215,32 @@ function parseRgGrepPattern(cmd) {
skipNextAsPattern = false;
if (token.length >= 3) return token;
}
previousToken = token;
continue;
}
if (!foundCmd) {
const commandName = token
.split(/[\\/]/)
.pop()
?.replace(/\.exe$/i, '');
if (commandName === 'rg' || commandName === 'grep') foundCmd = true;
if (connectors.has(token)) {
seenWrapper = false;
previousToken = token;
continue;
}
const commandName = basename(token);
if (wrappers.has(commandName)) seenWrapper = true;
const atCommandPosition =
previousToken === undefined ||
connectors.has(previousToken) ||
wrappers.has(basename(previousToken)) ||
seenWrapper;
if (atCommandPosition && (commandName === 'rg' || commandName === 'grep')) {
foundCmd = true;
searchCommand = commandName;
}
previousToken = token;
continue;
}
previousToken = token;
if (endOfOptions) {
if (explicitPatternSeen) continue;
if (explicitPatternSeen || patternFileSeen) continue;
return token.length >= 3 ? token : null;
}
if (token === '--') {
@ -195,20 +248,39 @@ function parseRgGrepPattern(cmd) {
continue;
}
if (token.startsWith('-')) {
const attachedPattern = token.match(/^--regexp=(.+)$/) || token.match(/^-e(.+)$/);
if (token === '-f' || token === '--file') {
skipNext = true;
patternFileSeen = true;
continue;
}
if (token.startsWith('--file=')) {
patternFileSeen = true;
continue;
}
if (token.startsWith('--regexp=')) {
explicitPatternSeen = true;
const value = token.slice('--regexp='.length);
if (value.length >= 3) return value;
continue;
}
const attachedPattern = token.match(/^-e(.+)$/);
if (attachedPattern) {
explicitPatternSeen = true;
if (attachedPattern[1].length >= 3) return attachedPattern[1];
continue;
}
if (flagsWithValues.has(token) || patternFlags.has(token)) {
if (
flagsWithValues.has(token) ||
patternFlags.has(token) ||
(searchCommand === 'rg' && rgValueFlags.has(token))
) {
skipNext = true;
skipNextAsPattern = patternFlags.has(token);
if (skipNextAsPattern) explicitPatternSeen = true;
}
continue;
}
if (explicitPatternSeen) continue;
if (explicitPatternSeen || patternFileSeen) continue;
return token.length >= 3 ? token : null;
}
return null;

View file

@ -57,8 +57,9 @@ const CURSOR_HOOKS_JSON = path.resolve(
);
const require = createRequire(import.meta.url);
const { parseRgGrepPattern } = require(CURSOR_HOOK) as {
const { parseRgGrepPattern, tokenizeShellWords } = require(CURSOR_HOOK) as {
parseRgGrepPattern: (command: string) => string | null;
tokenizeShellWords: (command: string) => string[];
};
// ─── Cursor-specific output parser ──────────────────────────────────
@ -571,10 +572,31 @@ describe('Shell quoted-pattern parser', () => {
['rg --regexp=x --regexp=LongPattern src/', 'LongPattern'],
['/usr/bin/rg -- "User Service" src/', 'User Service'],
['rg -- -error src/', '-error'],
[String.raw`C:\Users\me\bin\rg.exe UserService src/`, 'UserService'],
['rg.exe "validateUser" src/', 'validateUser'],
['grep.cmd -e LongPattern src/', 'LongPattern'],
['cd grep && rg LongPattern src/', 'LongPattern'],
['npx rg "User Service" src/', 'User Service'],
['npx --yes rg UserService src/', 'UserService'],
['rg --max-count 100 UserService src/', 'UserService'],
['grep -r UserService src/', 'UserService'],
['rg --replace x UserService src/', 'UserService'],
['git grep UserService src/', 'UserService'],
])('extracts %j from %j', (command, expected) => {
expect(parseRgGrepPattern(command)).toBe(expected);
});
it.each([
['rg --regexp= src/'],
['rg --regexp="" src/'],
['rg -e x -- LongPattern src/'],
['rg -f patterns.txt src/'],
['rg --file=patterns.txt src/'],
['rg -eab src/'],
])('extracts no pattern from %j', (command) => {
expect(parseRgGrepPattern(command)).toBeNull();
});
it('does not treat a path after a short explicit pattern as the pattern', () => {
expect(parseRgGrepPattern('rg -e x src/')).toBeNull();
});
@ -582,6 +604,12 @@ describe('Shell quoted-pattern parser', () => {
it('keeps single-token quoted patterns intact', () => {
expect(parseRgGrepPattern('rg "validateUser"')).toBe('validateUser');
});
it('keeps backslashes in unquoted Windows paths but honours escaped spaces', () => {
expect(tokenizeShellWords(String.raw`C:\foo\bar`)).toEqual([String.raw`C:\foo\bar`]);
expect(tokenizeShellWords('User\\ Service')).toEqual(['User Service']);
expect(tokenizeShellWords('trailing\\')).toEqual(['trailing\\']);
});
});
// ─── Install docs ─────────────────────────────────────────────────────