diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index 233bfe05a..5f4fbd9bf 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -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; diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts index 741be922b..a16c66584 100644 --- a/gitnexus/test/unit/cursor-hook.test.ts +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -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 ─────────────────────────────────────────────────────