diff --git a/packages/dora/package.json b/packages/dora/package.json index fa07f44..49fab42 100644 --- a/packages/dora/package.json +++ b/packages/dora/package.json @@ -2,7 +2,7 @@ "name": "@pleaseai/dora", "type": "module", "version": "0.1.3", - "description": "MCP server for JetBrains IDE integration", + "description": "MCP server for AI-assisted coding with LSP and file tools", "author": "Minsu Lee", "license": "MIT", "repository": { @@ -11,8 +11,7 @@ }, "keywords": [ "mcp", - "jetbrains", - "ide", + "lsp", "ai-coding" ], "main": "src/index.ts", @@ -26,7 +25,7 @@ "dev": "bun --watch run src/cli.ts", "build": "echo 'Build handled by root build:npm'", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "bun test ./src" + "test": "bun test ./test" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/packages/dora/src/providers/file/index.ts b/packages/dora/src/providers/file/index.ts new file mode 100644 index 0000000..288d8c6 --- /dev/null +++ b/packages/dora/src/providers/file/index.ts @@ -0,0 +1,538 @@ +/** + * File Provider + * + * Provides file system operations inspired by Serena's file tools. + * Includes: read_file, create_file, list_dir, find_file, search_for_pattern, replace_content + */ + +import type { Provider, ToolDefinition, ToolResult } from '../provider' +import type { RegistryConfig } from '../registry' +import fs from 'node:fs' +import path from 'node:path' +import { z } from 'zod' + +/** + * Check if a path should be ignored (basic gitignore-like patterns) + */ +function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean { + const defaultIgnores = ['node_modules', '.git', 'dist', '.next', '__pycache__', '.venv'] + const allPatterns = [...defaultIgnores, ...ignorePatterns] + + return allPatterns.some((pattern) => { + if (pattern.includes('*')) { + const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`) + return regex.test(relativePath) || regex.test(path.basename(relativePath)) + } + return relativePath.includes(pattern) || path.basename(relativePath) === pattern + }) +} + +/** + * Recursively scan directory + */ +function scanDirectory( + dirPath: string, + relativeTo: string, + recursive: boolean, + ignorePatterns: string[] = [], +): { dirs: string[], files: string[] } { + const dirs: string[] = [] + const files: string[] = [] + + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name) + const relativePath = path.relative(relativeTo, fullPath) + + if (shouldIgnore(relativePath, ignorePatterns)) { + continue + } + + if (entry.isDirectory()) { + dirs.push(relativePath) + if (recursive) { + const sub = scanDirectory(fullPath, relativeTo, recursive, ignorePatterns) + dirs.push(...sub.dirs) + files.push(...sub.files) + } + } + else if (entry.isFile()) { + files.push(relativePath) + } + } + } + catch { + // Silently skip directories we can't read + } + + return { dirs, files } +} + +/** + * Search for pattern in files + */ +function searchFiles( + filePaths: string[], + pattern: string, + rootPath: string, + contextLinesBefore: number = 0, + contextLinesAfter: number = 0, +): Array<{ file: string, line: number, content: string, context: string }> { + const results: Array<{ file: string, line: number, content: string, context: string }> = [] + const regex = new RegExp(pattern, 'gm') + + for (const filePath of filePaths) { + try { + const fullPath = path.join(rootPath, filePath) + const content = fs.readFileSync(fullPath, 'utf-8') + const lines = content.split('\n') + + for (let i = 0; i < lines.length; i++) { + const currentLine = lines[i] + if (currentLine !== undefined && regex.test(currentLine)) { + const startLine = Math.max(0, i - contextLinesBefore) + const endLine = Math.min(lines.length - 1, i + contextLinesAfter) + const contextLines = lines.slice(startLine, endLine + 1) + + results.push({ + file: filePath, + line: i + 1, + content: currentLine, + context: contextLines.map((l, idx) => `${startLine + idx + 1}: ${l}`).join('\n'), + }) + } + // Reset regex state for next line + regex.lastIndex = 0 + } + } + catch { + // Skip files we can't read + } + } + + return results +} + +/** + * Limit output length + */ +function limitLength(result: string, maxChars: number, defaultMax: number = 50000): string { + const limit = maxChars === -1 ? defaultMax : maxChars + if (result.length > limit) { + return `The answer is too long (${result.length} characters). Please use more specific parameters to narrow down the results.` + } + return result +} + +/** + * Tool definitions for File provider + */ +const FILE_TOOLS: ToolDefinition[] = [ + { + name: 'read_file', + description: + 'Reads a file within the project directory. Supports reading specific line ranges. ' + + 'Prefer symbolic operations (find_symbol) when you know what symbol you need.', + inputSchema: z.object({ + relative_path: z.string().describe('Relative path to the file from project root'), + start_line: z.number().default(0).describe('0-based index of first line to read'), + end_line: z.number().optional().describe('0-based index of last line to read (inclusive). If not provided, reads to end.'), + max_answer_chars: z.number().default(-1).describe('Max characters in response. -1 for default limit.'), + }), + }, + { + name: 'create_text_file', + description: + 'Creates or overwrites a file in the project directory.', + inputSchema: z.object({ + relative_path: z.string().describe('Relative path where file should be created'), + content: z.string().describe('Content to write to the file'), + }), + }, + { + name: 'list_dir', + description: + 'Lists files and directories in the given directory. Can scan recursively and skip ignored files.', + inputSchema: z.object({ + relative_path: z.string().describe('Relative path to directory. Pass "." for project root.'), + recursive: z.boolean().describe('Whether to scan subdirectories recursively'), + skip_ignored_files: z.boolean().default(true).describe('Whether to skip commonly ignored files (node_modules, .git, etc.)'), + max_answer_chars: z.number().default(-1).describe('Max characters in response. -1 for default limit.'), + }), + }, + { + name: 'find_file', + description: + 'Finds files matching a filename pattern (with wildcards * and ?) within the project.', + inputSchema: z.object({ + file_mask: z.string().describe('Filename or pattern with wildcards (* or ?) to search for'), + relative_path: z.string().describe('Directory to search in. Pass "." for project root.'), + }), + }, + { + name: 'search_for_pattern', + description: + 'Searches for a regex pattern across files in the project. ' + + 'Returns matching lines with optional context. ' + + 'Prefer symbolic operations when searching for code symbols.', + inputSchema: z.object({ + pattern: z.string().describe('Regular expression pattern to search for'), + relative_path: z.string().default('.').describe('Restrict search to this path'), + context_lines_before: z.number().default(0).describe('Lines of context before each match'), + context_lines_after: z.number().default(0).describe('Lines of context after each match'), + paths_include_glob: z.string().default('').describe('Glob pattern for files to include (e.g., "*.ts", "src/**/*.js")'), + paths_exclude_glob: z.string().default('').describe('Glob pattern for files to exclude'), + max_answer_chars: z.number().default(-1).describe('Max characters in response. -1 for default limit.'), + }), + }, + { + name: 'replace_content', + description: + 'Replaces content in a file using literal string or regex. ' + + 'IMPORTANT: Regex mode is powerful - use patterns like "beginning.*?end" to avoid specifying exact content. ' + + 'Use regex mode with wildcards for efficiency.', + inputSchema: z.object({ + relative_path: z.string().describe('Relative path to the file'), + needle: z.string().describe('String or regex pattern to find'), + repl: z.string().describe('Replacement string. In regex mode, use $1, $2 for backreferences.'), + mode: z.enum(['literal', 'regex']).describe('Search mode: "literal" for exact match, "regex" for pattern'), + allow_multiple: z.boolean().default(false).describe('Allow replacing multiple occurrences. If false and multiple found, returns error.'), + }), + }, +] + +/** + * File Provider implementation + */ +export class FileProvider implements Provider { + readonly name = 'file' + + private config: RegistryConfig + private connected = false + + constructor(config: RegistryConfig) { + this.config = config + } + + async connect(): Promise { + // Verify project path exists + if (!fs.existsSync(this.config.projectPath)) { + throw new Error(`Project path does not exist: ${this.config.projectPath}`) + } + this.connected = true + } + + async disconnect(): Promise { + this.connected = false + } + + isConnected(): boolean { + return this.connected + } + + listTools(): ToolDefinition[] { + return FILE_TOOLS + } + + async callTool(name: string, args: unknown): Promise { + if (!this.connected) { + return { + content: [{ type: 'text', text: 'File provider not connected' }], + isError: true, + } + } + + try { + switch (name) { + case 'read_file': + return this.handleReadFile(args) + case 'create_text_file': + return this.handleCreateFile(args) + case 'list_dir': + return this.handleListDir(args) + case 'find_file': + return this.handleFindFile(args) + case 'search_for_pattern': + return this.handleSearchForPattern(args) + case 'replace_content': + return this.handleReplaceContent(args) + default: + return { + content: [{ type: 'text', text: `Unknown tool: ${name}` }], + isError: true, + } + } + } + catch (error) { + return { + content: [ + { + type: 'text', + text: `File error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + } + } + } + + private handleReadFile(args: unknown): ToolResult { + const parsed = z.object({ + relative_path: z.string(), + start_line: z.number().default(0), + end_line: z.number().optional(), + max_answer_chars: z.number().default(-1), + }).parse(args) + + const fullPath = path.join(this.config.projectPath, parsed.relative_path) + + if (!fs.existsSync(fullPath)) { + return { + content: [{ type: 'text', text: `File not found: ${parsed.relative_path}` }], + isError: true, + } + } + + const content = fs.readFileSync(fullPath, 'utf-8') + const lines = content.split('\n') + + const startLine = parsed.start_line + const endLine = parsed.end_line ?? lines.length - 1 + + const selectedLines = lines.slice(startLine, endLine + 1) + const result = selectedLines.map((line, idx) => `${startLine + idx + 1}: ${line}`).join('\n') + + return { + content: [{ type: 'text', text: limitLength(result, parsed.max_answer_chars) }], + } + } + + private handleCreateFile(args: unknown): ToolResult { + const parsed = z.object({ + relative_path: z.string(), + content: z.string(), + }).parse(args) + + const fullPath = path.join(this.config.projectPath, parsed.relative_path) + const willOverwrite = fs.existsSync(fullPath) + + // Ensure parent directory exists + const dir = path.dirname(fullPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(fullPath, parsed.content, 'utf-8') + + let message = `File created: ${parsed.relative_path}` + if (willOverwrite) { + message += ' (overwrote existing file)' + } + + return { + content: [{ type: 'text', text: message }], + } + } + + private handleListDir(args: unknown): ToolResult { + const parsed = z.object({ + relative_path: z.string(), + recursive: z.boolean(), + skip_ignored_files: z.boolean().default(true), + max_answer_chars: z.number().default(-1), + }).parse(args) + + const fullPath = path.join(this.config.projectPath, parsed.relative_path) + + if (!fs.existsSync(fullPath)) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: `Directory not found: ${parsed.relative_path}`, + project_root: this.config.projectPath, + }), + }, + ], + isError: true, + } + } + + const ignorePatterns = parsed.skip_ignored_files ? [] : ['__IGNORE_NOTHING__'] + const { dirs, files } = scanDirectory( + fullPath, + this.config.projectPath, + parsed.recursive, + ignorePatterns, + ) + + const result = JSON.stringify({ dirs, files }, null, 2) + return { + content: [{ type: 'text', text: limitLength(result, parsed.max_answer_chars) }], + } + } + + private handleFindFile(args: unknown): ToolResult { + const parsed = z.object({ + file_mask: z.string(), + relative_path: z.string(), + }).parse(args) + + const fullPath = path.join(this.config.projectPath, parsed.relative_path) + const { files } = scanDirectory(fullPath, this.config.projectPath, true) + + // Convert file mask to regex + const maskRegex = new RegExp( + `^${parsed.file_mask.replace(/\*/g, '.*').replace(/\?/g, '.')}$`, + ) + + const matchingFiles = files.filter(f => maskRegex.test(path.basename(f))) + + return { + content: [{ type: 'text', text: JSON.stringify({ files: matchingFiles }, null, 2) }], + } + } + + private handleSearchForPattern(args: unknown): ToolResult { + const parsed = z.object({ + pattern: z.string(), + relative_path: z.string().default('.'), + context_lines_before: z.number().default(0), + context_lines_after: z.number().default(0), + paths_include_glob: z.string().default(''), + paths_exclude_glob: z.string().default(''), + max_answer_chars: z.number().default(-1), + }).parse(args) + + const searchPath = path.join(this.config.projectPath, parsed.relative_path) + + if (!fs.existsSync(searchPath)) { + return { + content: [{ type: 'text', text: `Path not found: ${parsed.relative_path}` }], + isError: true, + } + } + + // Get files to search + let filesToSearch: string[] + if (fs.statSync(searchPath).isFile()) { + filesToSearch = [parsed.relative_path] + } + else { + const { files } = scanDirectory(searchPath, this.config.projectPath, true) + filesToSearch = files + } + + // Apply include/exclude globs + if (parsed.paths_include_glob) { + const includeRegex = new RegExp( + `^${parsed.paths_include_glob.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\?/g, '.')}$`, + ) + filesToSearch = filesToSearch.filter(f => includeRegex.test(f)) + } + + if (parsed.paths_exclude_glob) { + const excludeRegex = new RegExp( + `^${parsed.paths_exclude_glob.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\?/g, '.')}$`, + ) + filesToSearch = filesToSearch.filter(f => !excludeRegex.test(f)) + } + + const results = searchFiles( + filesToSearch, + parsed.pattern, + this.config.projectPath, + parsed.context_lines_before, + parsed.context_lines_after, + ) + + // Group by file + const grouped: Record = {} + for (const result of results) { + const fileGroup = grouped[result.file] + if (!fileGroup) { + grouped[result.file] = [result.context || result.content] + } + else { + fileGroup.push(result.context || result.content) + } + } + + const output = JSON.stringify(grouped, null, 2) + return { + content: [{ type: 'text', text: limitLength(output, parsed.max_answer_chars) }], + } + } + + private handleReplaceContent(args: unknown): ToolResult { + const parsed = z.object({ + relative_path: z.string(), + needle: z.string(), + repl: z.string(), + mode: z.enum(['literal', 'regex']), + allow_multiple: z.boolean().default(false), + }).parse(args) + + const fullPath = path.join(this.config.projectPath, parsed.relative_path) + + if (!fs.existsSync(fullPath)) { + return { + content: [{ type: 'text', text: `File not found: ${parsed.relative_path}` }], + isError: true, + } + } + + const content = fs.readFileSync(fullPath, 'utf-8') + + let pattern: RegExp + if (parsed.mode === 'literal') { + pattern = new RegExp(parsed.needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') + } + else { + pattern = new RegExp(parsed.needle, 'gms') + } + + const matches = content.match(pattern) + const matchCount = matches?.length ?? 0 + + if (matchCount === 0) { + return { + content: [{ type: 'text', text: `No matches found for pattern in ${parsed.relative_path}` }], + isError: true, + } + } + + if (!parsed.allow_multiple && matchCount > 1) { + return { + content: [{ + type: 'text', + text: `Error: Pattern matches ${matchCount} occurrences. Use allow_multiple=true or make pattern more specific.`, + }], + isError: true, + } + } + + // Convert $1, $2 to regex backreferences + let replacement = parsed.repl + if (parsed.mode === 'regex') { + replacement = replacement.replace(/\$(\d+)/g, '$$$$1') + } + + const newContent = content.replace(pattern, replacement) + fs.writeFileSync(fullPath, newContent, 'utf-8') + + return { + content: [{ type: 'text', text: `OK - Replaced ${matchCount} occurrence(s) in ${parsed.relative_path}` }], + } + } +} + +/** + * Factory function for creating FileProvider + */ +export function createFileProvider(config: RegistryConfig): FileProvider { + return new FileProvider(config) +} diff --git a/packages/dora/src/providers/index.ts b/packages/dora/src/providers/index.ts index bd45704..1257035 100644 --- a/packages/dora/src/providers/index.ts +++ b/packages/dora/src/providers/index.ts @@ -2,7 +2,8 @@ * Providers module exports */ -export * from './jetbrains' +export * from './file' +// export * from './jetbrains' // TBD: JetBrains integration export * from './lsp' export * from './provider' -export * from './registry' +// export * from './registry' // TBD: Provider registry for dynamic loading diff --git a/packages/dora/src/providers/provider.ts b/packages/dora/src/providers/provider.ts index 1c4c0cb..279f176 100644 --- a/packages/dora/src/providers/provider.ts +++ b/packages/dora/src/providers/provider.ts @@ -53,4 +53,5 @@ export interface Provider { /** * Provider type enumeration */ -export type ProviderType = 'jetbrains-mcp' | 'lsp' +export type ProviderType = 'lsp' | 'file' +// TBD: 'jetbrains-mcp' - JetBrains IDE integration via code-please plugin diff --git a/packages/dora/src/providers/registry.ts b/packages/dora/src/providers/registry.ts index 799ea77..8af343f 100644 --- a/packages/dora/src/providers/registry.ts +++ b/packages/dora/src/providers/registry.ts @@ -1,5 +1,9 @@ /** * Provider registry for managing and selecting backends + * + * TBD: Dynamic provider registration and selection + * - JetBrains MCP integration (via code-please plugin) + * - Automatic fallback between providers */ import type { Provider, ProviderType } from './provider' @@ -19,6 +23,9 @@ export type ProviderFactory = (config: RegistryConfig) => Provider /** * Registry for managing provider instances + * + * Currently disabled - providers are directly instantiated in server.ts + * Will be re-enabled when JetBrains MCP integration is implemented. */ export class ProviderRegistry { private factories = new Map() @@ -46,7 +53,8 @@ export class ProviderRegistry { } // Try providers in order of preference - const preferredOrder: ProviderType[] = ['jetbrains-mcp', 'lsp'] + // TBD: Add 'jetbrains-mcp' when implemented + const preferredOrder: ProviderType[] = ['lsp', 'file'] for (const type of preferredOrder) { const factory = this.factories.get(type) @@ -65,7 +73,7 @@ export class ProviderRegistry { } } - throw new Error('No provider available. Ensure JetBrains IDE is running.') + throw new Error('No provider available.') } /** diff --git a/packages/dora/src/server.ts b/packages/dora/src/server.ts index 673f67a..ccc73b6 100644 --- a/packages/dora/src/server.ts +++ b/packages/dora/src/server.ts @@ -2,13 +2,10 @@ * MCP server setup and tool registration */ -import type { Provider } from './providers' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { - createJetBrainsMcpProvider, + createFileProvider, createLSPProvider, - - ProviderRegistry, } from './providers' export interface ServerConfig { @@ -27,37 +24,22 @@ export async function createDoraServer( version: '0.1.0', }) - // Create provider registry and register providers - const registry = new ProviderRegistry(config) - registry.register('jetbrains-mcp', createJetBrainsMcpProvider) - registry.register('lsp', createLSPProvider) - // Initialize providers - const jetbrainsProvider = createJetBrainsMcpProvider(config) const lspProvider = createLSPProvider(config) + const fileProvider = createFileProvider(config) - // Connect LSP provider immediately (it doesn't require external service) + // Connect providers immediately await lspProvider.connect() - - // Lazy JetBrains provider initialization - let activeJetbrainsProvider: Provider | null = null - - async function getJetbrainsProvider(): Promise { - if (activeJetbrainsProvider === null) { - await jetbrainsProvider.connect() - activeJetbrainsProvider = jetbrainsProvider - } - return activeJetbrainsProvider - } + await fileProvider.connect() // Collect tools from all providers - const jetbrainsTools = jetbrainsProvider.listTools() const lspTools = lspProvider.listTools() - const allTools = [...jetbrainsTools, ...lspTools] + const fileTools = fileProvider.listTools() + const allTools = [...lspTools, ...fileTools] // Map tool names to their providers - const jetbrainsToolNames = new Set(jetbrainsTools.map(t => t.name)) const lspToolNames = new Set(lspTools.map(t => t.name)) + const fileToolNames = new Set(fileTools.map(t => t.name)) // Register each tool dynamically for (const tool of allTools) { @@ -70,12 +52,11 @@ export async function createDoraServer( async (params) => { try { // Route to appropriate provider - if (lspToolNames.has(tool.name)) { - return await lspProvider.callTool(tool.name, params) + if (fileToolNames.has(tool.name)) { + return await fileProvider.callTool(tool.name, params) } - else if (jetbrainsToolNames.has(tool.name)) { - const provider = await getJetbrainsProvider() - return await provider.callTool(tool.name, params) + else if (lspToolNames.has(tool.name)) { + return await lspProvider.callTool(tool.name, params) } return { diff --git a/packages/dora/test/file-provider.test.ts b/packages/dora/test/file-provider.test.ts new file mode 100644 index 0000000..6c8b7da --- /dev/null +++ b/packages/dora/test/file-provider.test.ts @@ -0,0 +1,365 @@ +/** + * Tests for FileProvider + */ + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { createFileProvider, FileProvider } from '../src/providers/file' + +/** Helper to get text from tool result */ +function getText(result: { content: Array<{ type: string, text: string }> }): string { + return result.content[0]?.text ?? '' +} + +describe('FileProvider', () => { + let testDir: string + let provider: FileProvider + + beforeEach(() => { + // Create a temp directory for each test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-provider-test-')) + + // Create some test files + fs.mkdirSync(path.join(testDir, 'src')) + fs.writeFileSync(path.join(testDir, 'test.txt'), 'Hello World\nLine 2\nLine 3') + fs.writeFileSync(path.join(testDir, 'src', 'app.ts'), 'export const foo = "bar";\n') + + provider = createFileProvider({ projectPath: testDir }) + }) + + afterEach(() => { + // Clean up temp directory + fs.rmSync(testDir, { recursive: true, force: true }) + }) + + describe('connection', () => { + it('has correct name', () => { + expect(provider.name).toBe('file') + }) + + it('starts disconnected', () => { + expect(provider.isConnected()).toBe(false) + }) + + it('connects successfully', async () => { + await provider.connect() + expect(provider.isConnected()).toBe(true) + }) + + it('disconnect sets connected to false', async () => { + await provider.connect() + await provider.disconnect() + expect(provider.isConnected()).toBe(false) + }) + }) + + describe('listTools', () => { + it('lists file tools', () => { + const tools = provider.listTools() + const toolNames = tools.map(t => t.name) + + expect(toolNames).toContain('read_file') + expect(toolNames).toContain('create_text_file') + expect(toolNames).toContain('list_dir') + expect(toolNames).toContain('find_file') + expect(toolNames).toContain('search_for_pattern') + expect(toolNames).toContain('replace_content') + }) + }) + + describe('read_file', () => { + beforeEach(async () => { + await provider.connect() + }) + + it('reads entire file', async () => { + const result = await provider.callTool('read_file', { + relative_path: 'test.txt', + }) + + expect(result.isError).toBeFalsy() + const text = getText(result) + expect(text).toContain('Hello World') + expect(text).toContain('Line 2') + expect(text).toContain('Line 3') + }) + + it('reads specific lines', async () => { + const result = await provider.callTool('read_file', { + relative_path: 'test.txt', + start_line: 1, + end_line: 1, + }) + + expect(result.isError).toBeFalsy() + const text = getText(result) + expect(text).toContain('Line 2') + expect(text).not.toContain('Hello World') + }) + + it('returns error for non-existent file', async () => { + const result = await provider.callTool('read_file', { + relative_path: 'nonexistent.txt', + }) + + expect(result.isError).toBe(true) + expect(getText(result)).toContain('not found') + }) + }) + + describe('create_text_file', () => { + beforeEach(async () => { + await provider.connect() + }) + + it('creates new file', async () => { + const result = await provider.callTool('create_text_file', { + relative_path: 'new-file.txt', + content: 'New content', + }) + + expect(result.isError).toBeFalsy() + expect(getText(result)).toContain('created') + + const content = fs.readFileSync(path.join(testDir, 'new-file.txt'), 'utf-8') + expect(content).toBe('New content') + }) + + it('creates file in new directory', async () => { + const result = await provider.callTool('create_text_file', { + relative_path: 'new-dir/nested/file.txt', + content: 'Nested content', + }) + + expect(result.isError).toBeFalsy() + const content = fs.readFileSync(path.join(testDir, 'new-dir/nested/file.txt'), 'utf-8') + expect(content).toBe('Nested content') + }) + + it('overwrites existing file', async () => { + const result = await provider.callTool('create_text_file', { + relative_path: 'test.txt', + content: 'Overwritten', + }) + + expect(result.isError).toBeFalsy() + expect(getText(result)).toContain('overwrote') + + const content = fs.readFileSync(path.join(testDir, 'test.txt'), 'utf-8') + expect(content).toBe('Overwritten') + }) + }) + + describe('list_dir', () => { + beforeEach(async () => { + await provider.connect() + }) + + it('lists directory contents non-recursively', async () => { + const result = await provider.callTool('list_dir', { + relative_path: '.', + recursive: false, + }) + + expect(result.isError).toBeFalsy() + const data = JSON.parse(getText(result)) + + expect(data.dirs).toContain('src') + expect(data.files).toContain('test.txt') + }) + + it('lists directory contents recursively', async () => { + const result = await provider.callTool('list_dir', { + relative_path: '.', + recursive: true, + }) + + expect(result.isError).toBeFalsy() + const data = JSON.parse(getText(result)) + + expect(data.dirs).toContain('src') + expect(data.files).toContain('test.txt') + expect(data.files).toContain(path.join('src', 'app.ts')) + }) + + it('returns error for non-existent directory', async () => { + const result = await provider.callTool('list_dir', { + relative_path: 'nonexistent', + recursive: false, + }) + + expect(result.isError).toBe(true) + expect(getText(result)).toContain('not found') + }) + }) + + describe('find_file', () => { + beforeEach(async () => { + await provider.connect() + }) + + it('finds files by exact name', async () => { + const result = await provider.callTool('find_file', { + file_mask: 'test.txt', + relative_path: '.', + }) + + expect(result.isError).toBeFalsy() + const data = JSON.parse(getText(result)) + expect(data.files).toContain('test.txt') + }) + + it('finds files by wildcard', async () => { + const result = await provider.callTool('find_file', { + file_mask: '*.ts', + relative_path: '.', + }) + + expect(result.isError).toBeFalsy() + const data = JSON.parse(getText(result)) + expect(data.files.length).toBeGreaterThan(0) + expect(data.files.some((f: string) => f.endsWith('.ts'))).toBe(true) + }) + }) + + describe('search_for_pattern', () => { + beforeEach(async () => { + await provider.connect() + }) + + it('finds pattern in files', async () => { + const result = await provider.callTool('search_for_pattern', { + pattern: 'Hello', + relative_path: '.', + }) + + expect(result.isError).toBeFalsy() + const data = JSON.parse(getText(result)) + expect(data['test.txt']).toBeDefined() + }) + + it('finds pattern with context', async () => { + const result = await provider.callTool('search_for_pattern', { + pattern: 'Line 2', + relative_path: '.', + context_lines_before: 1, + context_lines_after: 1, + }) + + expect(result.isError).toBeFalsy() + const data = JSON.parse(getText(result)) + expect(data['test.txt'][0]).toContain('Hello') + expect(data['test.txt'][0]).toContain('Line 3') + }) + + it('finds regex pattern', async () => { + const result = await provider.callTool('search_for_pattern', { + pattern: 'Line \\d+', + relative_path: '.', + }) + + expect(result.isError).toBeFalsy() + const data = JSON.parse(getText(result)) + expect(data['test.txt']).toBeDefined() + expect(data['test.txt'].length).toBeGreaterThan(0) + }) + }) + + describe('replace_content', () => { + beforeEach(async () => { + await provider.connect() + }) + + it('replaces literal string', async () => { + const result = await provider.callTool('replace_content', { + relative_path: 'test.txt', + needle: 'Hello World', + repl: 'Goodbye World', + mode: 'literal', + }) + + expect(result.isError).toBeFalsy() + expect(getText(result)).toContain('OK') + + const content = fs.readFileSync(path.join(testDir, 'test.txt'), 'utf-8') + expect(content).toContain('Goodbye World') + expect(content).not.toContain('Hello World') + }) + + it('replaces regex pattern', async () => { + const result = await provider.callTool('replace_content', { + relative_path: 'test.txt', + needle: 'Line \\d+', + repl: 'Row', + mode: 'regex', + allow_multiple: true, + }) + + expect(result.isError).toBeFalsy() + + const content = fs.readFileSync(path.join(testDir, 'test.txt'), 'utf-8') + expect(content).toContain('Row') + expect(content).not.toContain('Line 2') + }) + + it('returns error when multiple matches without allow_multiple', async () => { + const result = await provider.callTool('replace_content', { + relative_path: 'test.txt', + needle: 'Line', + repl: 'Row', + mode: 'literal', + allow_multiple: false, + }) + + expect(result.isError).toBe(true) + expect(getText(result)).toContain('matches') + }) + + it('returns error when no match found', async () => { + const result = await provider.callTool('replace_content', { + relative_path: 'test.txt', + needle: 'NonexistentText', + repl: 'Replacement', + mode: 'literal', + }) + + expect(result.isError).toBe(true) + expect(getText(result)).toContain('No matches') + }) + }) + + describe('error handling', () => { + it('returns error when not connected', async () => { + const result = await provider.callTool('read_file', { + relative_path: 'test.txt', + }) + + expect(result.isError).toBe(true) + expect(getText(result)).toContain('not connected') + }) + + it('returns error for unknown tool', async () => { + await provider.connect() + const result = await provider.callTool('unknown_tool', {}) + + expect(result.isError).toBe(true) + expect(getText(result)).toContain('Unknown tool') + }) + }) +}) + +describe('createFileProvider', () => { + it('creates provider instance', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-test-')) + try { + const provider = createFileProvider({ projectPath: tmpDir }) + expect(provider).toBeInstanceOf(FileProvider) + expect(provider.name).toBe('file') + } + finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/dora/src/providers/lsp/__tests__/index.test.ts b/packages/dora/test/lsp-provider.test.ts similarity index 97% rename from packages/dora/src/providers/lsp/__tests__/index.test.ts rename to packages/dora/test/lsp-provider.test.ts index 7a2207d..d21e207 100644 --- a/packages/dora/src/providers/lsp/__tests__/index.test.ts +++ b/packages/dora/test/lsp-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { createLSPProvider, LSPProvider } from '../index' +import { createLSPProvider, LSPProvider } from '../src/providers/lsp' describe('LSPProvider', () => { let provider: LSPProvider | null = null