diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/ui/hooks/atCommandProcessor.test.ts | 394 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/atCommandProcessor.ts | 15 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useSlashCompletion.test.ts | 258 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useSlashCompletion.tsx | 13 |
4 files changed, 672 insertions, 8 deletions
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 2b4c81a3..583c0b2e 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -689,5 +689,397 @@ describe('handleAtCommand', () => { `Ignored 1 files:\nGemini-ignored: ${geminiIgnoredFile}`, ); }); - // }); + + describe('punctuation termination in @ commands', () => { + const punctuationTestCases = [ + { + name: 'comma', + fileName: 'test.txt', + fileContent: 'File content here', + queryTemplate: (filePath: string) => + `Look at @${filePath}, then explain it.`, + messageId: 400, + }, + { + name: 'period', + fileName: 'readme.md', + fileContent: 'File content here', + queryTemplate: (filePath: string) => + `Check @${filePath}. What does it say?`, + messageId: 401, + }, + { + name: 'semicolon', + fileName: 'example.js', + fileContent: 'Code example', + queryTemplate: (filePath: string) => + `Review @${filePath}; check for bugs.`, + messageId: 402, + }, + { + name: 'exclamation mark', + fileName: 'important.txt', + fileContent: 'Important content', + queryTemplate: (filePath: string) => + `Look at @${filePath}! This is critical.`, + messageId: 403, + }, + { + name: 'question mark', + fileName: 'config.json', + fileContent: 'Config settings', + queryTemplate: (filePath: string) => + `What is in @${filePath}? Please explain.`, + messageId: 404, + }, + { + name: 'opening parenthesis', + fileName: 'func.ts', + fileContent: 'Function definition', + queryTemplate: (filePath: string) => + `Analyze @${filePath}(the main function).`, + messageId: 405, + }, + { + name: 'closing parenthesis', + fileName: 'data.json', + fileContent: 'Test data', + queryTemplate: (filePath: string) => + `Use data from @${filePath}) for testing.`, + messageId: 406, + }, + { + name: 'opening square bracket', + fileName: 'array.js', + fileContent: 'Array data', + queryTemplate: (filePath: string) => + `Check @${filePath}[0] for the first element.`, + messageId: 407, + }, + { + name: 'closing square bracket', + fileName: 'list.md', + fileContent: 'List content', + queryTemplate: (filePath: string) => + `Review item @${filePath}] from the list.`, + messageId: 408, + }, + { + name: 'opening curly brace', + fileName: 'object.ts', + fileContent: 'Object definition', + queryTemplate: (filePath: string) => + `Parse @${filePath}{prop1: value1}.`, + messageId: 409, + }, + { + name: 'closing curly brace', + fileName: 'config.yaml', + fileContent: 'Configuration', + queryTemplate: (filePath: string) => + `Use settings from @${filePath}} for deployment.`, + messageId: 410, + }, + ]; + + it.each(punctuationTestCases)( + 'should terminate @path at $name', + async ({ fileName, fileContent, queryTemplate, messageId }) => { + const filePath = await createTestFile( + path.join(testRootDir, fileName), + fileContent, + ); + const query = queryTemplate(filePath); + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: query }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }, + ); + + it('should handle multiple @paths terminated by different punctuation', async () => { + const content1 = 'First file'; + const file1Path = await createTestFile( + path.join(testRootDir, 'first.txt'), + content1, + ); + const content2 = 'Second file'; + const file2Path = await createTestFile( + path.join(testRootDir, 'second.txt'), + content2, + ); + const query = `Compare @${file1Path}, @${file2Path}; what's different?`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 411, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Compare @${file1Path}, @${file2Path}; what's different?` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${file1Path}:\n` }, + { text: content1 }, + { text: `\nContent from @${file2Path}:\n` }, + { text: content2 }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should still handle escaped spaces in paths before punctuation', async () => { + const fileContent = 'Spaced file content'; + const filePath = await createTestFile( + path.join(testRootDir, 'spaced file.txt'), + fileContent, + ); + const escapedPath = path.join(testRootDir, 'spaced\\ file.txt'); + const query = `Check @${escapedPath}, it has spaces.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 412, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath}, it has spaces.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should not break file paths with periods in extensions', async () => { + const fileContent = 'TypeScript content'; + const filePath = await createTestFile( + path.join(testRootDir, 'example.d.ts'), + fileContent, + ); + const query = `Analyze @${filePath} for type definitions.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 413, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Analyze @${filePath} for type definitions.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle file paths ending with period followed by space', async () => { + const fileContent = 'Config content'; + const filePath = await createTestFile( + path.join(testRootDir, 'config.json'), + fileContent, + ); + const query = `Check @${filePath}. This file contains settings.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 414, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath}. This file contains settings.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle comma termination with complex file paths', async () => { + const fileContent = 'Package info'; + const filePath = await createTestFile( + path.join(testRootDir, 'package.json'), + fileContent, + ); + const query = `Review @${filePath}, then check dependencies.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 415, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Review @${filePath}, then check dependencies.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should not terminate at period within file name', async () => { + const fileContent = 'Version info'; + const filePath = await createTestFile( + path.join(testRootDir, 'version.1.2.3.txt'), + fileContent, + ); + const query = `Check @${filePath} contains version information.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 416, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath} contains version information.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle end of string termination for period and comma', async () => { + const fileContent = 'End file content'; + const filePath = await createTestFile( + path.join(testRootDir, 'end.txt'), + fileContent, + ); + const query = `Show me @${filePath}.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 417, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Show me @${filePath}.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle files with special characters in names', async () => { + const fileContent = 'File with special chars content'; + const filePath = await createTestFile( + path.join(testRootDir, 'file$with&special#chars.txt'), + fileContent, + ); + const query = `Check @${filePath} for content.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 418, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath} for content.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle basic file names without special characters', async () => { + const fileContent = 'Basic file content'; + const filePath = await createTestFile( + path.join(testRootDir, 'basicfile.txt'), + fileContent, + ); + const query = `Check @${filePath} please.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 421, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath} please.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 7b9005fa..165b7b30 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -87,9 +87,17 @@ function parseAllAtCommands(query: string): AtCommandPart[] { inEscape = false; } else if (char === '\\') { inEscape = true; - } else if (/\s/.test(char)) { - // Path ends at first whitespace not escaped + } else if (/[,\s;!?()[\]{}]/.test(char)) { + // Path ends at first whitespace or punctuation not escaped break; + } else if (char === '.') { + // For . we need to be more careful - only terminate if followed by whitespace or end of string + // This allows file extensions like .txt, .js but terminates at sentence endings like "file.txt. Next sentence" + const nextChar = + pathEndIndex + 1 < query.length ? query[pathEndIndex + 1] : ''; + if (nextChar === '' || /\s/.test(nextChar)) { + break; + } } pathEndIndex++; } @@ -320,8 +328,7 @@ export async function handleAtCommand({ if ( i > 0 && initialQueryText.length > 0 && - !initialQueryText.endsWith(' ') && - resolvedSpec + !initialQueryText.endsWith(' ') ) { // Add space if previous part was text and didn't end with space, or if previous was @path const prevPart = commandParts[i - 1]; diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 13f8c240..206c4dc9 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -1350,4 +1350,262 @@ describe('useSlashCompletion', () => { expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt'); }); }); + + describe('File Path Escaping', () => { + it('should escape special characters in file names', async () => { + await createTestFile('', 'my file.txt'); + await createTestFile('', 'file(1).txt'); + await createTestFile('', 'backup[old].txt'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'my file.txt', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('my\\ file.txt'); + }); + + it('should escape parentheses in file names', async () => { + await createTestFile('', 'document(final).docx'); + await createTestFile('', 'script(v2).sh'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@doc'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'document(final).docx', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('document\\(final\\).docx'); + }); + + it('should escape square brackets in file names', async () => { + await createTestFile('', 'backup[2024-01-01].zip'); + await createTestFile('', 'config[dev].json'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@backup'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'backup[2024-01-01].zip', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('backup\\[2024-01-01\\].zip'); + }); + + it('should escape multiple special characters in file names', async () => { + await createTestFile('', 'my file (backup) [v1.2].txt'); + await createTestFile('', 'data & config {prod}.json'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'my file (backup) [v1.2].txt', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe( + 'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt', + ); + }); + + it('should preserve path separators while escaping special characters', async () => { + await createTestFile( + '', + 'projects', + 'my project (2024)', + 'file with spaces.txt', + ); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@projects/my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find((s) => + s.label.includes('my project'), + ); + expect(suggestion).toBeDefined(); + // Should escape spaces and parentheses but preserve forward slashes + expect(suggestion!.value).toMatch(/my\\ project\\ \\\(2024\\\)/); + expect(suggestion!.value).toContain('/'); // Should contain forward slash for path separator + }); + + it('should normalize Windows path separators to forward slashes while preserving escaping', async () => { + // Create test with complex nested structure + await createTestFile( + '', + 'deep', + 'nested', + 'special folder', + 'file with (parentheses).txt', + ); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@deep/nested/special'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find((s) => + s.label.includes('special folder'), + ); + expect(suggestion).toBeDefined(); + // Should use forward slashes for path separators and escape spaces + expect(suggestion!.value).toContain('special\\ folder/'); + expect(suggestion!.value).not.toContain('\\\\'); // Should not contain double backslashes for path separators + }); + + it('should handle directory names with special characters', async () => { + await createEmptyDir('my documents (personal)'); + await createEmptyDir('config [production]'); + await createEmptyDir('data & logs'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestions = result.current.suggestions; + + const docSuggestion = suggestions.find( + (s) => s.label === 'my documents (personal)/', + ); + expect(docSuggestion).toBeDefined(); + expect(docSuggestion!.value).toBe('my\\ documents\\ \\(personal\\)/'); + + const configSuggestion = suggestions.find( + (s) => s.label === 'config [production]/', + ); + expect(configSuggestion).toBeDefined(); + expect(configSuggestion!.value).toBe('config\\ \\[production\\]/'); + + const dataSuggestion = suggestions.find( + (s) => s.label === 'data & logs/', + ); + expect(dataSuggestion).toBeDefined(); + expect(dataSuggestion!.value).toBe('data\\ \\&\\ logs/'); + }); + + it('should handle files with various shell metacharacters', async () => { + await createTestFile('', 'file$var.txt'); + await createTestFile('', 'important!.md'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestions = result.current.suggestions; + + const dollarSuggestion = suggestions.find( + (s) => s.label === 'file$var.txt', + ); + expect(dollarSuggestion).toBeDefined(); + expect(dollarSuggestion!.value).toBe('file\\$var.txt'); + + const importantSuggestion = suggestions.find( + (s) => s.label === 'important!.md', + ); + expect(importantSuggestion).toBeDefined(); + expect(importantSuggestion!.value).toBe('important\\!.md'); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.tsx b/packages/cli/src/ui/hooks/useSlashCompletion.tsx index f68d52d8..c6821358 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.tsx +++ b/packages/cli/src/ui/hooks/useSlashCompletion.tsx @@ -16,6 +16,7 @@ import { Config, FileDiscoveryService, DEFAULT_FILE_FILTERING_OPTIONS, + SHELL_SPECIAL_CHARS, } from '@google/gemini-cli-core'; import { Suggestion } from '../components/SuggestionsDisplay.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; @@ -513,11 +514,17 @@ export function useSlashCompletion( ]; } - // Like glob, we always return forwardslashes, even in windows. + // Like glob, we always return forward slashes for path separators, even on Windows. + // But preserve backslash escaping for special characters. + const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`; + const pathSeparatorRegex = new RegExp( + `\\\\${specialCharsLookahead}`, + 'g', + ); fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ ...suggestion, - label: suggestion.label.replace(/\\/g, '/'), - value: suggestion.value.replace(/\\/g, '/'), + label: suggestion.label.replace(pathSeparatorRegex, '/'), + value: suggestion.value.replace(pathSeparatorRegex, '/'), })); // Sort by depth, then directories first, then alphabetically |
