diff --git a/src/core/CodeActionProvider.ts b/src/core/CodeActionProvider.ts new file mode 100644 index 0000000..11bafdb --- /dev/null +++ b/src/core/CodeActionProvider.ts @@ -0,0 +1,181 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; + +const ACTION_NAMES = { + EXPLAIN: 'Roo Cline: Explain Code', + FIX: 'Roo Cline: Fix Code', + IMPROVE: 'Roo Cline: Improve Code' +} as const; + +const COMMAND_IDS = { + EXPLAIN: 'roo-cline.explainCode', + FIX: 'roo-cline.fixCode', + IMPROVE: 'roo-cline.improveCode' +} as const; + +interface DiagnosticData { + message: string; + severity: vscode.DiagnosticSeverity; + code?: string | number | { value: string | number; target: vscode.Uri }; + source?: string; + range: vscode.Range; +} + +interface EffectiveRange { + range: vscode.Range; + text: string; +} + +export class CodeActionProvider implements vscode.CodeActionProvider { + public static readonly providedCodeActionKinds = [ + vscode.CodeActionKind.QuickFix, + vscode.CodeActionKind.RefactorRewrite, + ]; + + // Cache file paths for performance + private readonly filePathCache = new WeakMap(); + + private getEffectiveRange( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection + ): EffectiveRange | null { + try { + const selectedText = document.getText(range); + if (selectedText) { + return { range, text: selectedText }; + } + + const currentLine = document.lineAt(range.start.line); + if (!currentLine.text.trim()) { + return null; + } + + // Optimize range creation by checking bounds first + const startLine = Math.max(0, currentLine.lineNumber - 1); + const endLine = Math.min(document.lineCount - 1, currentLine.lineNumber + 1); + + // Only create new positions if needed + const effectiveRange = new vscode.Range( + startLine === currentLine.lineNumber ? range.start : new vscode.Position(startLine, 0), + endLine === currentLine.lineNumber ? range.end : new vscode.Position(endLine, document.lineAt(endLine).text.length) + ); + + return { + range: effectiveRange, + text: document.getText(effectiveRange) + }; + } catch (error) { + console.error('Error getting effective range:', error); + return null; + } + } + + private getFilePath(document: vscode.TextDocument): string { + // Check cache first + let filePath = this.filePathCache.get(document); + if (filePath) { + return filePath; + } + + try { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!workspaceFolder) { + filePath = document.uri.fsPath; + } else { + const relativePath = path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath); + filePath = (!relativePath || relativePath.startsWith('..')) ? document.uri.fsPath : relativePath; + } + + // Cache the result + this.filePathCache.set(document, filePath); + return filePath; + } catch (error) { + console.error('Error getting file path:', error); + return document.uri.fsPath; + } + } + + private createDiagnosticData(diagnostic: vscode.Diagnostic): DiagnosticData { + return { + message: diagnostic.message, + severity: diagnostic.severity, + code: diagnostic.code, + source: diagnostic.source, + range: diagnostic.range // Reuse the range object + }; + } + + private createAction( + title: string, + kind: vscode.CodeActionKind, + command: string, + args: any[] + ): vscode.CodeAction { + const action = new vscode.CodeAction(title, kind); + action.command = { command, title, arguments: args }; + return action; + } + + private hasIntersectingRange(range1: vscode.Range, range2: vscode.Range): boolean { + // Optimize range intersection check + return !( + range2.end.line < range1.start.line || + range2.start.line > range1.end.line || + (range2.end.line === range1.start.line && range2.end.character < range1.start.character) || + (range2.start.line === range1.end.line && range2.start.character > range1.end.character) + ); + } + + public provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext + ): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> { + try { + const effectiveRange = this.getEffectiveRange(document, range); + if (!effectiveRange) { + return []; + } + + const filePath = this.getFilePath(document); + const actions: vscode.CodeAction[] = []; + + // Create actions using helper method + actions.push(this.createAction( + ACTION_NAMES.EXPLAIN, + vscode.CodeActionKind.QuickFix, + COMMAND_IDS.EXPLAIN, + [filePath, effectiveRange.text] + )); + + // Only process diagnostics if they exist + if (context.diagnostics.length > 0) { + const relevantDiagnostics = context.diagnostics.filter(d => + this.hasIntersectingRange(effectiveRange.range, d.range) + ); + + if (relevantDiagnostics.length > 0) { + const diagnosticMessages = relevantDiagnostics.map(this.createDiagnosticData); + actions.push(this.createAction( + ACTION_NAMES.FIX, + vscode.CodeActionKind.QuickFix, + COMMAND_IDS.FIX, + [filePath, effectiveRange.text, diagnosticMessages] + )); + } + } + + actions.push(this.createAction( + ACTION_NAMES.IMPROVE, + vscode.CodeActionKind.RefactorRewrite, + COMMAND_IDS.IMPROVE, + [filePath, effectiveRange.text] + )); + + return actions; + } catch (error) { + console.error('Error providing code actions:', error); + return []; + } + } +} \ No newline at end of file diff --git a/src/core/__tests__/CodeActionProvider.test.ts b/src/core/__tests__/CodeActionProvider.test.ts new file mode 100644 index 0000000..cdc1acf --- /dev/null +++ b/src/core/__tests__/CodeActionProvider.test.ts @@ -0,0 +1,145 @@ +import * as vscode from 'vscode'; +import { CodeActionProvider } from '../CodeActionProvider'; + +// Mock VSCode API +jest.mock('vscode', () => ({ + CodeAction: jest.fn().mockImplementation((title, kind) => ({ + title, + kind, + command: undefined + })), + CodeActionKind: { + QuickFix: { value: 'quickfix' }, + RefactorRewrite: { value: 'refactor.rewrite' } + }, + Range: jest.fn().mockImplementation((startLine, startChar, endLine, endChar) => ({ + start: { line: startLine, character: startChar }, + end: { line: endLine, character: endChar } + })), + Position: jest.fn().mockImplementation((line, character) => ({ + line, + character + })), + workspace: { + getWorkspaceFolder: jest.fn() + }, + DiagnosticSeverity: { + Error: 0, + Warning: 1, + Information: 2, + Hint: 3 + } +})); + +describe('CodeActionProvider', () => { + let provider: CodeActionProvider; + let mockDocument: any; + let mockRange: any; + let mockContext: any; + + beforeEach(() => { + provider = new CodeActionProvider(); + + // Mock document + mockDocument = { + getText: jest.fn(), + lineAt: jest.fn(), + lineCount: 10, + uri: { fsPath: '/test/file.ts' } + }; + + // Mock range + mockRange = new vscode.Range(0, 0, 0, 10); + + // Mock context + mockContext = { + diagnostics: [] + }; + }); + + describe('getEffectiveRange', () => { + it('should return selected text when available', () => { + mockDocument.getText.mockReturnValue('selected text'); + + const result = (provider as any).getEffectiveRange(mockDocument, mockRange); + + expect(result).toEqual({ + range: mockRange, + text: 'selected text' + }); + }); + + it('should return null for empty line', () => { + mockDocument.getText.mockReturnValue(''); + mockDocument.lineAt.mockReturnValue({ text: '', lineNumber: 0 }); + + const result = (provider as any).getEffectiveRange(mockDocument, mockRange); + + expect(result).toBeNull(); + }); + }); + + describe('getFilePath', () => { + it('should return relative path when in workspace', () => { + const mockWorkspaceFolder = { + uri: { fsPath: '/test' } + }; + (vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(mockWorkspaceFolder); + + const result = (provider as any).getFilePath(mockDocument); + + expect(result).toBe('file.ts'); + }); + + it('should return absolute path when not in workspace', () => { + (vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(null); + + const result = (provider as any).getFilePath(mockDocument); + + expect(result).toBe('/test/file.ts'); + }); + }); + + describe('provideCodeActions', () => { + beforeEach(() => { + mockDocument.getText.mockReturnValue('test code'); + mockDocument.lineAt.mockReturnValue({ text: 'test code', lineNumber: 0 }); + }); + + it('should provide explain and improve actions by default', () => { + const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext); + + expect(actions).toHaveLength(2); + expect((actions as any)[0].title).toBe('Roo Cline: Explain Code'); + expect((actions as any)[1].title).toBe('Roo Cline: Improve Code'); + }); + + it('should provide fix action when diagnostics exist', () => { + mockContext.diagnostics = [{ + message: 'test error', + severity: vscode.DiagnosticSeverity.Error, + range: mockRange + }]; + + const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext); + + expect(actions).toHaveLength(3); + expect((actions as any).some((a: any) => a.title === 'Roo Cline: Fix Code')).toBe(true); + }); + + it('should handle errors gracefully', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockDocument.getText.mockImplementation(() => { + throw new Error('Test error'); + }); + mockDocument.lineAt.mockReturnValue({ text: 'test', lineNumber: 0 }); + + const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext); + + expect(actions).toEqual([]); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error getting effective range:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); +}); \ No newline at end of file