UNPKG

markdown-editor-mcp

Version:

MCP server for markdown editing and management

175 lines (174 loc) 7.51 kB
import { spawn } from 'child_process'; import path from 'path'; import fs from 'fs/promises'; import { validatePath } from './filesystem.js'; import { rgPath } from '@vscode/ripgrep'; import { capture } from "../utils/capture.js"; // Function to search file contents using ripgrep export async function searchCode(options) { const { rootPath, pattern, filePattern, ignoreCase = true, maxResults = 1000, includeHidden = false, contextLines = 0 } = options; // Validate path for security const validPath = await validatePath(rootPath); // Build command arguments const args = [ '--json', // Output in JSON format for easier parsing '--line-number', // Include line numbers ]; if (ignoreCase) { args.push('-i'); } if (maxResults) { args.push('-m', maxResults.toString()); } if (includeHidden) { args.push('--hidden'); } if (contextLines > 0) { args.push('-C', contextLines.toString()); } if (filePattern) { args.push('-g', filePattern); } // Add pattern and path args.push(pattern, validPath); // Run ripgrep command return new Promise((resolve, reject) => { const results = []; const rg = spawn(rgPath, args); let stdoutBuffer = ''; // Store a reference to the child process for potential termination const childProcess = rg; // Store in a process list - this could be expanded to a global registry // of running search processes if needed for management globalThis.currentSearchProcess = childProcess; rg.stdout.on('data', (data) => { stdoutBuffer += data.toString(); }); rg.stderr.on('data', (data) => { console.error(`ripgrep error: ${data}`); }); rg.on('close', (code) => { // Clean up the global reference if (globalThis.currentSearchProcess === childProcess) { delete globalThis.currentSearchProcess; } if (code === 0 || code === 1) { // Process the buffered output const lines = stdoutBuffer.trim().split('\n'); for (const line of lines) { if (!line) continue; try { const result = JSON.parse(line); if (result.type === 'match') { result.data.submatches.forEach((submatch) => { results.push({ file: result.data.path.text, line: result.data.line_number, match: submatch.match.text }); }); } else if (result.type === 'context' && contextLines > 0) { results.push({ file: result.data.path.text, line: result.data.line_number, match: result.data.lines.text.trim() }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); capture('server_request_error', { error: `Error parsing ripgrep output: ${errorMessage}` }); console.error(`Error parsing ripgrep output: ${errorMessage}`); } } resolve(results); } else { reject(new Error(`ripgrep process exited with code ${code}`)); } }); }); } // Fallback implementation using Node.js for environments without ripgrep export async function searchCodeFallback(options) { const { rootPath, pattern, filePattern, ignoreCase = true, maxResults = 1000, excludeDirs = ['node_modules', '.git'], contextLines = 0 } = options; const validPath = await validatePath(rootPath); const results = []; const regex = new RegExp(pattern, ignoreCase ? 'i' : ''); const fileRegex = filePattern ? new RegExp(filePattern) : null; async function searchDir(dirPath) { if (results.length >= maxResults) return; try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (results.length >= maxResults) break; const fullPath = path.join(dirPath, entry.name); try { await validatePath(fullPath); if (entry.isDirectory()) { if (!excludeDirs.includes(entry.name)) { await searchDir(fullPath); } } else if (entry.isFile()) { if (!fileRegex || fileRegex.test(entry.name)) { const content = await fs.readFile(fullPath, 'utf-8'); const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { // Add the matched line results.push({ file: fullPath, line: i + 1, match: lines[i].trim() }); // Add context lines if (contextLines > 0) { const startIdx = Math.max(0, i - contextLines); const endIdx = Math.min(lines.length - 1, i + contextLines); for (let j = startIdx; j <= endIdx; j++) { if (j !== i) { // Skip the match line as it's already added results.push({ file: fullPath, line: j + 1, match: lines[j].trim() }); } } } if (results.length >= maxResults) break; } } } } } catch (error) { // Skip files/directories we can't access continue; } } } catch (error) { // Skip directories we can't read } } await searchDir(validPath); return results; } // Main function that tries ripgrep first, falls back to native implementation export async function searchTextInFiles(options) { try { return await searchCode(options); } catch (error) { return searchCodeFallback({ ...options, excludeDirs: ['node_modules', '.git', 'dist'] }); } }