UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

1,263 lines • 58.1 kB
import { spawn } from 'child_process';
import { constants } from 'buffer';
import { once } from 'events';
import path from 'path';
import fs from 'fs/promises';
import { validatePath } from './tools/filesystem.js';
import { capture } from './utils/capture.js';
import { logger } from './utils/logger.js';
import { getRipgrepPath } from './utils/ripgrep-resolver.js';
import { isExcelFile } from './utils/files/index.js';
import { exceljsPackage } from './utils/files/excel.js';
import { pizzipPackage } from './utils/files/docx.js';
/**
 * Default time limit of a file search for an exact filename ("package.json"):
 * a quick lookup of one known file, which gives up rather than walk a huge tree.
 * A content search for the same text looks for every reference to that file,
 * so it has no default limit.
 */
const EXACT_FILENAME_SEARCH_TIMEOUT_MS = 1500;
/** The longest a timer waits (2^31 - 1 ms, ~24.8 days): setTimeout fires a longer delay after 1 ms */
const LONGEST_TIMER_MS = 2 ** 31 - 1;
/** The extensions of the files the Excel and DOCX searches are for (see targetsOfficeFiles) */
const EXCEL_EXTENSIONS = ['.xlsx', '.xls', '.xlsm', '.xlsb'];
const DOCX_EXTENSIONS = ['.docx'];
/**
 * The OS errors ripgrep reports for a path it may not read ("rg: <path>: ...
 * (os error N)"): on Windows ERROR_ACCESS_DENIED (5); elsewhere EACCES (13) and
 * EPERM (1: on macOS, a folder its privacy settings protect). On Windows, 1 is
 * an error of another kind.
 */
const PERMISSION_OS_ERRORS = process.platform === 'win32' ? [5] : [1, 13];
/** Whether an error of the Excel/DOCX search is about permissions */
const isPermissionError = (error) => ['EACCES', 'EPERM'].includes(error?.code ?? '');
const errorText = (error) => error instanceof Error ? error.message : String(error);
/** The alternatives of a filePattern ("*.js|*.ts") */
const filePatternAlternatives = (filePattern) => (filePattern ?? '').split('|').map(p => p.trim()).filter(Boolean);
/** Answers show this many characters of a result's text, then '...' if there is more */
export const SHOWN_TEXT_CHARS = 100;
/**
 * What a session keeps of a line's text: what answers show, and one character
 * more so they still know to add '...'. ripgrep sends each line whole (--json
 * ignores --max-columns), and a line can be megabytes long. Copied, because a
 * V8 substring keeps the whole string it was cut from alive. No text (ripgrep
 * sends text that is not valid UTF-8 as "bytes") stays no text, as it always was.
 */
const keptText = (text) => text === undefined ? undefined : Buffer.from(text.slice(0, SHOWN_TEXT_CHARS + 1), 'utf16le').toString('utf16le');
/**
 * A glob as ripgrep matches its -g globs (gitignore style), for the files
 * ripgrep doesn't select itself: a file search's files, and the Excel and DOCX
 * files. Without a '/' it is matched against the file's name, with one against
 * its path below the search path ('/'-separated). '*' and '?' stop at '/', "**"
 * as a whole path segment spans folders, "[...]" is a character class ("[!...]"
 * negated) and "{a,b}" a choice.
 */
function ripgrepGlobMatcher(glob, ignoreCase) {
    const byPath = glob.includes('/'); // "/x.ts" too: x.ts in the search path itself
    let regex;
    try {
        regex = new RegExp(`^${globRegexSource(glob.replace(/^\//, ''))}$`, ignoreCase ? 'i' : '');
    }
    catch {
        // A glob ripgrep rejects too ("[z-a]"): every glob a search uses reaches
        // ripgrep (see buildRipgrepArgs), and the search answers with its error
        return () => false;
    }
    return relativePath => regex.test(byPath ? relativePath : relativePath.slice(relativePath.lastIndexOf('/') + 1));
}
/** The regular expression source of a glob (see ripgrepGlobMatcher) */
function globRegexSource(glob) {
    let source = '';
    for (let i = 0; i < glob.length; i++) {
        const c = glob[i];
        const classEnd = c === '[' ? characterClassEnd(glob, i) : -1;
        const choiceEnd = c === '{' ? glob.indexOf('}', i) : -1;
        if (c === '*' && glob[i + 1] === '*' && (i === 0 || glob[i - 1] === '/') && (i + 2 === glob.length || glob[i + 2] === '/')) {
            // "**" as a whole path segment: any number of folders ("**/": none too)
            source += i + 2 === glob.length ? '.*' : '(?:.*/)?';
            i += i + 2 === glob.length ? 1 : 2;
        }
        else if (c === '*') {
            source += '[^/]*';
        }
        else if (c === '?') {
            source += '[^/]';
        }
        else if (classEnd > 0) {
            let body = glob.slice(i + 1, classEnd);
            const negated = body.startsWith('!') || body.startsWith('^');
            if (negated)
                body = body.slice(1);
            source += `[${negated ? '^/' : ''}${body.replace(/[\\\]^]/g, '\\$&')}]`;
            i = classEnd;
        }
        else if (choiceEnd > 0) {
            source += `(?:${glob.slice(i + 1, choiceEnd).split(',').map(globRegexSource).join('|')})`;
            i = choiceEnd;
        }
        else {
            source += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        }
    }
    return source;
}
/** A file's path below the search path, '/'-separated (just its name when it is the search path) */
const pathBelow = (root, file) => path.relative(root, file).split(path.sep).join('/') || path.basename(file);
/** A glob that matches `text` itself: each glob character in a class of its own ("[*]", "[[]") */
const literalGlob = (text) => text.replace(/[*?[\]{}]/g, '[$&]');
/** The ']' that closes the character class opening at `start` (one right after "[" or "[!" is part of it), or -1 */
function characterClassEnd(glob, start) {
    let i = start + 1;
    if (glob[i] === '!' || glob[i] === '^')
        i++;
    if (glob[i] === ']')
        i++;
    return glob.indexOf(']', i);
}
/**
 * The longest line of ripgrep output the server assembles, in characters: a
 * match or context line in its JSON. Half of V8's longest string (2^29 - 24
 * characters in Node 24), past which the append throws and the server exits:
 * only a longer line is skipped, and answers name its file. Any shorter line is
 * processed whole.
 */
export const MAX_OUTPUT_LINE_CHARS = Math.floor(constants.MAX_STRING_LENGTH / 2);
/** The most of ripgrep's error output (stderr) a session keeps, in characters */
const MAX_KEPT_ERROR_CHARS = 64 * 1024;
/**
 * Search Session Manager - handles ripgrep processes like terminal sessions
 * Supports both file search and content search with progressive results
 */ export class SearchManager {
    constructor() {
        this.sessions = new Map();
        this.sessionCounter = 0;
        this.cleanupTimer = null;
    }
    /**
     * Start a new search session (like start_process)
     * Returns immediately with initial state and results
     */
    async startSearch(options) {
        const sessionId = `search_${++this.sessionCounter}_${Date.now()}`;
        // Validate path first
        const validPath = await validatePath(options.rootPath);
        // Build ripgrep arguments
        const args = this.buildRipgrepArgs({ ...options, rootPath: validPath });
        // Get ripgrep path with fallback resolution
        let rgPath;
        try {
            rgPath = await getRipgrepPath();
        }
        catch (err) {
            throw new Error(`Failed to locate ripgrep binary: ${err instanceof Error ? err.message : String(err)}`);
        }
        // Start ripgrep process. It matches a glob with a '/' ("src/*.ts") against
        // the path below its working directory: that must be the root path. A root
        // that can't be stat'ed (missing...) can't be searched: start_search answers
        // with why, as for any search that can't start.
        const rootIsDirectory = (await fs.stat(validPath)).isDirectory();
        const rgProcess = spawn(rgPath, args, {
            windowsHide: true, // Prevent visible console windows on Windows
            cwd: rootIsDirectory ? validPath : undefined
        });
        await this.whenStarted(rgProcess);
        // Create session
        let markCompleted;
        const completed = new Promise(resolve => { markCompleted = resolve; });
        const session = {
            id: sessionId,
            process: rgProcess,
            results: [],
            isComplete: false,
            startTime: Date.now(),
            lastReadTime: Date.now(),
            options,
            buffer: '',
            skippedLines: new Map(),
            totalMatches: 0,
            totalContextLines: 0,
            filePatternIncludes: this.filePatternIncludes(options, validPath),
            pendingSources: new Set(['ripgrep']),
            unsearched: new Map(),
            completed,
            markCompleted
        };
        this.sessions.set(sessionId, session);
        // Set up process event handlers
        this.setupProcessHandlers(session);
        // Start cleanup interval now that we have a session
        this.startCleanupIfNeeded();
        // Set up the time limit, if any: it stops every source still running.
        // For exact filename file searches, use a shorter default timeout
        const timeoutMs = options.timeout ??
            (this.isExactFilenameSearch(options) ? EXACT_FILENAME_SEARCH_TIMEOUT_MS : undefined);
        if (timeoutMs) {
            session.timeLimitMs = timeoutMs;
            session.timeoutTimer = setTimeout(() => {
                session.stopReason ?? (session.stopReason = 'timed_out');
                this.stopSources(session);
            }, Math.min(timeoutMs, LONGEST_TIMER_MS));
        }
        capture('search_session_started', {
            sessionId,
            searchType: options.searchType,
            hasTimeout: !!timeoutMs,
            timeoutMs,
            requestedPath: options.rootPath,
            validatedPath: validPath
        });
        // For content searches, only search Excel files when contextually relevant:
        // - filePattern explicitly targets Excel files (*.xlsx, *.xls, etc.)
        // - or rootPath is an Excel file itself
        const shouldSearchExcel = options.searchType === 'content' &&
            this.targetsOfficeFiles(EXCEL_EXTENSIONS, options.filePattern, validPath);
        if (shouldSearchExcel) {
            this.startOfficeSource(session, 'excel', sink => this.searchExcelFiles(validPath, options.pattern, options.ignoreCase !== false, sink, options.filePattern, // Pass filePattern to filter Excel files too
            options.includeHidden === true));
        }
        // For content searches, also search DOCX files
        const shouldSearchDocx = options.searchType === 'content' &&
            this.targetsOfficeFiles(DOCX_EXTENSIONS, options.filePattern, validPath);
        if (shouldSearchDocx) {
            this.startOfficeSource(session, 'docx', sink => this.searchDocxFiles(validPath, options.pattern, options.ignoreCase !== false, sink, options.filePattern, options.includeHidden === true));
        }
        // Wait for first chunk of data or early completion instead of fixed delay
        // Office searches run in background and add their matches to the session as they find them
        const firstChunk = new Promise(resolve => {
            const onData = () => {
                session.process.stdout?.off('data', onData);
                resolve();
            };
            session.process.stdout?.once('data', onData);
            setTimeout(resolve, 40); // cap at 40ms instead of 50-100ms
        });
        // Only wait for ripgrep first chunk - Excel results merge asynchronously
        await firstChunk;
        return { ...this.stateOf(session), results: [...session.results] };
    }
    /**
     * Read search results with offset-based pagination (like read_file)
     * Supports both range reading and tail behavior
     */
    readSearchResults(sessionId, offset = 0, length = 100) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            throw new Error(`Search session ${sessionId} not found`);
        }
        // Get all results (excluding internal markers)
        const allResults = session.results.filter(r => r.file !== '__LAST_READ_MARKER__');
        // Every read keeps the session from cleanup, a read of the last results too
        session.lastReadTime = Date.now();
        // Handle negative offsets (tail behavior) - like file reading
        if (offset < 0) {
            const tailCount = Math.abs(offset);
            const tailResults = allResults.slice(-tailCount);
            return {
                ...this.stateOf(session),
                results: tailResults,
                returnedCount: tailResults.length,
                hasMoreResults: false // Tail always returns what's available
            };
        }
        // Handle positive offsets (range behavior) - like file reading
        const slicedResults = allResults.slice(offset, offset + length);
        const hasMoreResults = offset + length < allResults.length || !session.isComplete;
        return {
            ...this.stateOf(session),
            results: slicedResults,
            returnedCount: slicedResults.length,
            hasMoreResults
        };
    }
    /** What answers say about a session (see handlers/search-answers.ts) */
    stateOf(session) {
        return {
            sessionId: session.id,
            isComplete: session.isComplete,
            outcome: session.outcome,
            failure: session.failure,
            unsearched: [...session.unsearched.values()],
            timeLimitMs: session.timeLimitMs,
            maxResults: session.options.maxResults,
            totalMatches: session.totalMatches,
            totalResults: session.totalMatches + session.totalContextLines,
            runtime: (session.endTime ?? Date.now()) - session.startTime,
            skippedLines: this.skippedLinesOf(session)
        };
    }
    skippedLinesOf(session) {
        return [...session.skippedLines].map(([file, count]) => ({ file, count }));
    }
    /**
     * Keeps ripgrep's error output for the answer (shown when a search fails
     * without results), each piece once and up to MAX_KEPT_ERROR_CHARS: a search
     * over folders it can't read can print an error per entry, and the session
     * keeps this until it is cleaned up. What comes first is kept.
     */
    keepErrorOutput(session, text) {
        const kept = session.error ?? '';
        if (kept.length >= MAX_KEPT_ERROR_CHARS)
            return;
        session.error = kept + text.slice(0, MAX_KEPT_ERROR_CHARS - kept.length);
    }
    /**
     * Resolves with all of a session's results once it is complete: every source
     * has finished, or the search was stopped.
     */
    async waitForCompletion(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            throw new Error(`Search session ${sessionId} not found`);
        }
        await session.completed;
        return [...session.results];
    }
    /**
     * Terminate a search session (like force_terminate)
     */
    terminateSearch(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return false;
        }
        if (!session.isComplete)
            session.stopReason ?? (session.stopReason = 'stopped');
        this.stopSources(session);
        // Don't delete session immediately - let user read final results
        // It will be cleaned up by cleanup process
        return true;
    }
    /**
     * Stop every running search and drop all sessions and the cleanup timer.
     * For owners that tear the search manager down (shutdown, tests); a later
     * startSearch() starts afresh.
     */
    dispose() {
        for (const session of this.sessions.values()) {
            this.stopSources(session);
        }
        this.sessions.clear();
        if (this.cleanupTimer) {
            clearInterval(this.cleanupTimer);
            this.cleanupTimer = null;
        }
    }
    /**
     * Start the periodic cleanup of old sessions. It is housekeeping only, so it
     * is unref'd: it must never keep the process alive on its own.
     */
    startCleanupIfNeeded() {
        if (this.cleanupTimer)
            return;
        this.cleanupTimer = setInterval(() => this.cleanupSessions(), 5 * 60 * 1000);
        this.cleanupTimer.unref();
    }
    /**
     * Get list of active search sessions (like list_sessions)
     */
    listSearchSessions() {
        return Array.from(this.sessions.values()).map(session => ({
            id: session.id,
            searchType: session.options.searchType,
            pattern: session.options.pattern,
            isComplete: session.isComplete,
            isError: session.outcome === 'failed',
            runtime: (session.endTime ?? Date.now()) - session.startTime,
            totalResults: session.totalMatches + session.totalContextLines
        }));
    }
    /**
     * Run an Excel or DOCX search as one of the session's sources: its matches are
     * collected as it finds them, until it has searched every file or the session
     * stops it (stop_search, timeout, maxResults).
     */
    startOfficeSource(session, source, search) {
        session.pendingSources.add(source);
        const sink = {
            onMatch: result => {
                if (session.pendingSources.has(source))
                    this.collectMatch(session, result);
            },
            isStopped: () => !session.pendingSources.has(source),
            couldNotRead: (what, filePath, error) => {
                if (!session.pendingSources.has(source))
                    return;
                const code = error?.code;
                if (isPermissionError(error))
                    this.noteUnsearched(session, 'permissions', filePath);
                else
                    this.noteUnsearched(session, `${source}_${what}`, what === 'folder' ? `${filePath}: ${code ?? errorText(error)}` : filePath);
            }
        };
        search(sink)
            .catch((err) => {
            // Log Office search errors but don't fail the whole search: its answer
            // says that part couldn't be searched
            const message = errorText(err);
            logger.error(`The ${source} part of search ${session.id} failed; its matches are missing: ${message}`);
            capture(`${source}_search_error`, { error: message });
            if (session.pendingSources.has(source))
                this.noteUnsearched(session, `${source}_search`, message);
        })
            .finally(() => this.finishSource(session, source));
    }
    /**
     * Search Excel files for content matches
     * Called during content search to include Excel files alongside text files
     * Searches ALL sheets in each Excel file (row-wise for cross-column matching)
     *
     * TODO: Refactor - Extract Excel search logic to separate module (src/utils/search/excel-search.ts)
     * and inject into SearchManager, similar to how file handlers are structured in src/utils/files/
     * This would allow adding other file type searches (PDF, etc.) without bloating search-manager.ts
     */
    async searchExcelFiles(rootPath, pattern, ignoreCase, sink, filePattern, includeHidden = false) {
        // Office file search always uses literal matching to prevent ReDoS.
        // Regex patterns are treated as literal strings — this is intentional.
        const searchTerm = ignoreCase ? pattern.toLowerCase() : pattern;
        // Find Excel files recursively
        let excelFiles = await this.findExcelFiles(rootPath, sink, includeHidden);
        // Filter by filePattern if provided
        if (filePattern) {
            excelFiles = this.filterOfficeFiles(excelFiles, filePattern, rootPath);
        }
        if (excelFiles.length === 0)
            return;
        // ExcelJS, to search all sheets: loaded here if the server hasn't yet
        // (a search runs in the background, so it isn't refused while it loads)
        const ExcelJS = exceljsPackage.load();
        for (const filePath of excelFiles) {
            if (sink.isStopped())
                break;
            try {
                const workbook = new ExcelJS.Workbook();
                await workbook.xlsx.readFile(filePath);
                // Search ALL sheets in the workbook (row-wise for speed and cross-column matching)
                for (const worksheet of workbook.worksheets) {
                    if (sink.isStopped())
                        break;
                    const sheetName = worksheet.name;
                    // Iterate through rows (faster than cell-by-cell)
                    worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
                        if (sink.isStopped())
                            return;
                        // Build a concatenated string of all cell values in the row
                        const rowValues = [];
                        row.eachCell({ includeEmpty: false }, (cell) => {
                            if (cell.value === null || cell.value === undefined)
                                return;
                            let cellStr;
                            if (typeof cell.value === 'object') {
                                if ('result' in cell.value) {
                                    cellStr = String(cell.value.result ?? '');
                                }
                                else if ('richText' in cell.value) {
                                    cellStr = cell.value.richText.map((rt) => rt.text).join('');
                                }
                                else if ('text' in cell.value) {
                                    cellStr = String(cell.value.text);
                                }
                                else {
                                    cellStr = String(cell.value);
                                }
                            }
                            else {
                                cellStr = String(cell.value);
                            }
                            if (cellStr.trim()) {
                                rowValues.push(cellStr);
                            }
                        });
                        // Join all cell values with space for cross-column matching
                        const rowText = rowValues.join(' ');
                        const textToSearch = ignoreCase ? rowText.toLowerCase() : rowText;
                        const matchIndex = textToSearch.indexOf(searchTerm);
                        if (matchIndex !== -1) {
                            const matchContext = this.getMatchContext(rowText, matchIndex, searchTerm.length);
                            sink.onMatch({
                                file: `${filePath}:${sheetName}!Row${rowNumber}`,
                                line: rowNumber,
                                match: matchContext,
                                type: 'content'
                            });
                        }
                    });
                }
            }
            catch (error) {
                // Skip files that can't be read (permission issues, corrupted, etc.); the answer says so
                sink.couldNotRead('file', filePath, error);
                continue;
            }
        }
    }
    /**
     * Whether the Excel/DOCX search walks into a folder: never node_modules, and a
     * hidden one (a name starting with '.') only with includeHidden, as ripgrep
     * enters hidden folders only with --hidden.
     */
    officeWalkEnters(name, includeHidden) {
        return name !== 'node_modules' && (includeHidden || !name.startsWith('.'));
    }
    /**
     * Find all Excel files in a directory recursively. Stops walking once the
     * search is stopped; a folder it can't list is reported to the sink.
     */
    async findExcelFiles(rootPath, sink, includeHidden = false) {
        const excelFiles = [];
        const enters = (name) => this.officeWalkEnters(name, includeHidden);
        async function walk(dir) {
            if (sink.isStopped())
                return;
            try {
                const entries = await fs.readdir(dir, { withFileTypes: true });
                for (const entry of entries) {
                    const fullPath = path.join(dir, entry.name);
                    if (entry.isDirectory()) {
                        if (enters(entry.name)) {
                            await walk(fullPath);
                        }
                    }
                    else if (entry.isFile() && isExcelFile(entry.name)) {
                        excelFiles.push(fullPath);
                    }
                }
            }
            catch (error) {
                // Skip directories we can't read; the answer says so
                sink.couldNotRead('folder', dir, error);
            }
        }
        // Check if rootPath is a file or directory
        try {
            const stats = await fs.stat(rootPath);
            if (stats.isFile() && isExcelFile(rootPath)) {
                return [rootPath];
            }
            else if (stats.isDirectory()) {
                await walk(rootPath);
            }
        }
        catch {
            // Path doesn't exist or can't be accessed
        }
        return excelFiles;
    }
    /**
     * The Excel/DOCX files a filePattern selects, as ripgrep's globs select text
     * files (ripgrepGlobMatcher), ignoring case: one of its alternatives matches
     * the file (any file, when all of them are "!" alternatives), and none of its
     * "!" alternatives leaves it out (see officeFileExcluded). A search path that
     * is itself a file is searched whatever the pattern says, as ripgrep searches
     * a file it is given.
     */
    filterOfficeFiles(files, filePattern, rootPath) {
        const patterns = filePatternAlternatives(filePattern);
        const includes = patterns.filter(pat => !pat.startsWith('!')).map(pat => ripgrepGlobMatcher(pat, true));
        const excludes = patterns.filter(pat => pat.startsWith('!')).map(pat => pat.slice(1));
        return files.filter(filePath => {
            if (filePath === rootPath)
                return true;
            const relativePath = pathBelow(rootPath, filePath);
            return (includes.length === 0 || includes.some(matches => matches(relativePath))) &&
                !excludes.some(pat => this.officeFileExcluded(relativePath, pat));
        });
    }
    /**
     * Whether a "!" alternative of a filePattern (given without its "!") leaves an
     * Excel/DOCX file out - as it leaves text files out of ripgrep's search: when
     * it matches the file, or a directory between the search path and the file;
     * ending in '/', only directories.
     */
    officeFileExcluded(relativePath, exclusion) {
        const dirOnly = exclusion.endsWith('/');
        const matches = ripgrepGlobMatcher(exclusion.replace(/\/+$/, ''), true);
        const parts = relativePath.split('/');
        // The directories between the search path and the file, then the file itself
        const candidates = parts.map((_, i) => parts.slice(0, i + 1).join('/')).slice(0, dirOnly ? -1 : undefined);
        return candidates.some(candidate => matches(candidate));
    }
    /**
     * Whether a content search runs the Excel or DOCX search, the one for files
     * with these extensions. Only when contextually relevant: the rootPath is
     * such a file, or a filePattern alternative targets them ("*.xlsx",
     * "budget.xlsx") - which one that leaves files out ("!*.xlsx") does not.
     * Each alternative is checked on its own, so "report.xlsx|memo.docx" runs both.
     */
    targetsOfficeFiles(extensions, filePattern, rootPath) {
        const lowerRootPath = rootPath.toLowerCase();
        const targetingPatterns = filePatternAlternatives(filePattern)
            .filter(pattern => !pattern.startsWith('!'))
            .map(pattern => pattern.toLowerCase());
        return extensions.some(ext => lowerRootPath.endsWith(ext) ||
            targetingPatterns.some(pattern => pattern.includes(`*${ext}`) || pattern.endsWith(ext)));
    }
    /**
     * Search DOCX files for content matches
     * Extracts <w:t> text from document.xml and searches it
     */
    async searchDocxFiles(rootPath, pattern, ignoreCase, sink, filePattern, includeHidden = false) {
        // Office file search always uses literal matching to prevent ReDoS.
        // Regex patterns are treated as literal strings — this is intentional.
        const searchTerm = ignoreCase ? pattern.toLowerCase() : pattern;
        let docxFiles = await this.findDocxFiles(rootPath, sink, includeHidden);
        if (filePattern) {
            docxFiles = this.filterOfficeFiles(docxFiles, filePattern, rootPath);
        }
        if (docxFiles.length === 0)
            return;
        // PizZip, to open the DOCX files: loaded here if the server hasn't yet
        const PizZip = pizzipPackage.load();
        for (const filePath of docxFiles) {
            if (sink.isStopped())
                break;
            try {
                const buf = await fs.readFile(filePath);
                const zip = new PizZip(buf);
                // Search all XML parts that can contain text
                const xmlParts = ['word/document.xml', 'word/header1.xml', 'word/header2.xml',
                    'word/header3.xml', 'word/footer1.xml', 'word/footer2.xml', 'word/footer3.xml'];
                for (const xmlPath of xmlParts) {
                    if (sink.isStopped())
                        break;
                    const file = zip.file(xmlPath);
                    if (!file)
                        continue;
                    const xml = file.asText();
                    // Extract all <w:t> text with position tracking
                    const wtRe = /<w:t(?:\s[^>]*)?>([^<]*)<\/w:t>/g;
                    let m;
                    let lineNum = 0;
                    while ((m = wtRe.exec(xml)) !== null) {
                        if (sink.isStopped())
                            break;
                        const text = m[1];
                        if (!text || !text.trim())
                            continue;
                        lineNum++;
                        const textToSearch = ignoreCase ? text.toLowerCase() : text;
                        const matchIndex = textToSearch.indexOf(searchTerm);
                        if (matchIndex !== -1) {
                            const matchContext = this.getMatchContext(text, matchIndex, searchTerm.length);
                            const partName = xmlPath === 'word/document.xml' ? '' : `:${xmlPath.replace('word/', '')}`;
                            sink.onMatch({
                                file: `${filePath}${partName}`,
                                line: lineNum,
                                match: matchContext,
                                type: 'content'
                            });
                        }
                    }
                }
            }
            catch (error) {
                // A file that can't be read: the answer says so
                sink.couldNotRead('file', filePath, error);
                continue;
            }
        }
    }
    /**
     * Find all DOCX files in a directory recursively. Stops walking once the
     * search is stopped; a folder it can't list is reported to the sink.
     */
    async findDocxFiles(rootPath, sink, includeHidden = false) {
        const docxFiles = [];
        const enters = (name) => this.officeWalkEnters(name, includeHidden);
        const isDocx = (name) => name.toLowerCase().endsWith('.docx');
        async function walk(dir) {
            if (sink.isStopped())
                return;
            try {
                const entries = await fs.readdir(dir, { withFileTypes: true });
                for (const entry of entries) {
                    const fullPath = path.join(dir, entry.name);
                    if (entry.isDirectory()) {
                        if (enters(entry.name)) {
                            await walk(fullPath);
                        }
                    }
                    else if (entry.isFile() && isDocx(entry.name)) {
                        docxFiles.push(fullPath);
                    }
                }
            }
            catch (error) {
                sink.couldNotRead('folder', dir, error);
            }
        }
        try {
            const stats = await fs.stat(rootPath);
            if (stats.isFile() && isDocx(rootPath)) {
                return [rootPath];
            }
            else if (stats.isDirectory()) {
                await walk(rootPath);
            }
        }
        catch { /* skip */ }
        return docxFiles;
    }
    /**
     * Extract context around a match for display (show surrounding text)
     */
    getMatchContext(text, matchStart, matchLength) {
        const contextChars = 50; // chars before and after match
        const start = Math.max(0, matchStart - contextChars);
        const end = Math.min(text.length, matchStart + matchLength + contextChars);
        let context = text.substring(start, end);
        // Add ellipsis if truncated
        if (start > 0)
            context = '...' + context;
        if (end < text.length)
            context = context + '...';
        return context;
    }
    /**
     * Clean up completed sessions older than specified time
     * Called automatically by cleanup interval
     */
    cleanupSessions(maxAge = 5 * 60 * 1000) {
        const cutoffTime = Date.now() - maxAge;
        for (const [sessionId, session] of this.sessions) {
            if (session.isComplete && session.lastReadTime < cutoffTime) {
                this.sessions.delete(sessionId);
            }
        }
    }
    /**
     * Get total number of active sessions (excluding completed ones)
     */
    getActiveSessionCount() {
        return Array.from(this.sessions.values()).filter(session => !session.isComplete).length;
    }
    /**
     * A file search for one exact filename ("package.json"): a lookup of a known
     * file, unlike a content search for that same text
     */
    isExactFilenameSearch(options) {
        return options.searchType === 'files' && this.isExactFilename(options.pattern, options.literalSearch);
    }
    /**
     * Detect if pattern looks like an exact filename
     * (has file extension and no glob wildcards; a literal pattern has none)
     */
    isExactFilename(pattern, literal = false) {
        return /\.[a-zA-Z0-9]+$/.test(pattern) &&
            (literal || !this.isGlobPattern(pattern));
    }
    /**
     * Detect if pattern contains glob wildcards
     */
    isGlobPattern(pattern) {
        return pattern.includes('*') ||
            pattern.includes('?') ||
            pattern.includes('[') ||
            pattern.includes('{') ||
            pattern.includes(']') ||
            pattern.includes('}');
    }
    /**
     * A file search's filePattern alternatives other than "!", which a file it
     * finds must match one of (see filePatternSelects); undefined when there are none
     */
    filePatternIncludes(options, root) {
        if (options.searchType !== 'files')
            return undefined;
        const includes = filePatternAlternatives(options.filePattern).filter(pattern => !pattern.startsWith('!'));
        if (includes.length === 0)
            return undefined;
        return { root, matchers: includes.map(glob => ripgrepGlobMatcher(glob, options.ignoreCase !== false)) };
    }
    /**
     * Whether a file a file search found is one its filePattern selects: ripgrep
     * checked the "!" alternatives (see buildRipgrepArgs), one of the others must
     * match it. ripgrep can't check those itself: with the pattern's glob in the
     * same list, any glob that matches lets a file in.
     */
    filePatternSelects(session, file) {
        const includes = session.filePatternIncludes;
        if (!includes)
            return true;
        const relativePath = pathBelow(includes.root, file);
        return includes.matchers.some(matches => matches(relativePath));
    }
    buildRipgrepArgs(options) {
        // These arguments are the whole search: the user's ripgrep config file
        // (RIPGREP_CONFIG_PATH) must not add flags such as --hidden, --glob,
        // --max-count or --null that change what is found or the output parsed here
        const args = ['--no-config'];
        if (options.searchType === 'content') {
            // Content search mode
            args.push('--json', '--line-number');
            // Add literal search support for content searches
            if (options.literalSearch) {
                args.push('-F'); // Fixed string matching (literal)
            }
            if (options.contextLines && options.contextLines > 0) {
                args.push('-C', options.contextLines.toString());
            }
        }
        else {
            // File search mode
            args.push('--files');
        }
        // Case-insensitive: content searches use -i flag, file searches use --iglob
        if (options.searchType === 'content' && options.ignoreCase !== false) {
            args.push('-i');
        }
        if (options.includeHidden) {
            args.push('--hidden');
        }
        // maxResults caps the TOTAL number of matches (see collectMatch()), so no
        // file can contribute more than that: -m stops ripgrep there in each file.
        // Without it a folder search keeps a file's whole output in ripgrep's memory
        // until the file is done, however little of it the search can use. (ripgrep
        // still prints a match that falls in the trailing context of its last one;
        // collectRipgrepLine() takes it like any other. --files ignores -m.)
        if (options.maxResults && options.maxResults > 0) {
            args.push('-m', options.maxResults.toString());
        }
        // File pattern filtering (for file type restrictions like *.js, *.d.ts)
        if (options.filePattern && options.searchType === 'content') {
            for (const p of filePatternAlternatives(options.filePattern)) {
                args.push('-g', p);
            }
        }
        // Handle the main search pattern
        if (options.searchType === 'files') {
            // For file search: determine how to treat the pattern
            // (--iglob for case-insensitive or --glob for case-sensitive)
            const globFlag = options.ignoreCase !== false ? '--iglob' : '--glob';
            // literalSearch: the pattern is an exact string, glob characters and all
            const name = options.literalSearch ? literalGlob(options.pattern) : options.pattern;
            if (this.isExactFilename(options.pattern, options.literalSearch)) {
                // Exact filename: use appropriate glob flag with the exact pattern
                args.push(globFlag, name);
            }
            else if (!options.literalSearch && this.isGlobPattern(options.pattern)) {
                // Already a glob pattern: use appropriate glob flag as-is
                args.push(globFlag, options.pattern);
            }
            else {
                // Substring/fuzzy search: wrap with wildcards
                args.push(globFlag, `*${name}*`);
            }
            // filePattern narrows the files the pattern finds. Its "!" alternatives
            // come after the pattern's glob, since ripgrep's last matching glob wins;
            // the others are checked on what ripgrep finds (filePatternSelects), and
            // still reach ripgrep as --pre-globs: it parses them (an invalid one is its
            // error, as for any glob) but without --pre never applies them
            for (const p of filePatternAlternatives(options.filePattern)) {
                args.push(...(p.startsWith('!') ? [globFlag, p] : ['--pre-glob', p]));
            }
            // Add the root path for file mode
            args.push(options.rootPath);
        }
        else {
            // Content search: terminate options before the pattern to prevent 
            // patterns starting with '-' being interpreted as flags
            args.push('--', options.pattern, options.rootPath);
        }
        return args;
    }
    /**
     * Resolves once ripgrep has started; else logs why it could not start (not
     * found, not executable...) and rejects with the error start_search always
     * reported. 'spawn' or 'error' comes on the next tick, before any I/O, so
     * the caller still sets up its handlers in time.
     */
    async whenStarted(child) {
        try {
            await once(child, 'spawn');
        }
        catch (error) {
            const reason = error instanceof Error ? error.message : String(error);
            logger.error(`Failed to start ripgrep: ${reason}`);
            throw Object.assign(new Error('Failed to start ripgrep process'), { cause: error });
        }
    }
    setupProcessHandlers(session) {
        const { process } = session;
        // Decoded as streams: a character cut between two chunks stays whole
        // (decoding each chunk on its own made it U+FFFD, in matches and paths)
        process.stdout?.setEncoding('utf8');
        process.stderr?.setEncoding('utf8');
        process.stdout?.on('data', (data) => {
            session.printedOutput = true;
            this.processOutput(session, data);
        });
        process.stderr?.on('data', (data) => {
            const errorText = data.toString();
            // Store error text for potential user display (bounded: see keepErrorOutput)
            this.keepErrorOutput(session, errorText);
            // Filter meaningful errors, for telemetry; they are part of the text kept above
            const filteredErrors = errorText
                .split('\n')
                .filter(line => {
                const trimmed = line.trim();
                // Skip empty lines and lines with just symbols/numbers/colons
                if (!trimmed || trimmed.match(/^[\)\(\s\d:]*$/))
                    return false;
                // Skip all ripgrep system errors that start with "rg:"
                if (trimmed.startsWith('rg:'))
                    return false;
                return true;
            });
            if (filteredErrors.length > 0) {
                const meaningfulErrors = filteredErrors.join('\n').trim();
                if (meaningfulErrors) {
                    capture('search_session_error', {
                        sessionId: session.id,
                        error: meaningfulErrors.substring(0, 200)
                    });
                }
            }
        });
        process.on('close', (code) => {
            // Process the last line, if the output did not end with a newline
            this.processOutput(session, '', true);
            // Exit codes: 0 found, 1 found nothing, 2 an error. No code: a signal
            // ended it, ours (time limit, stop_search, maxResults) or not.
            // A content search's ripgrep prints a JSON line for each file it searches
            // and a summary at the end: exiting 2 with nothing printed, it could not
            // search at all (an invalid pattern or glob), rather than meeting files it
            // couldn't read. A file search prints only the names it finds, so for it
            // the error must say so: a glob it could not parse. Its error is the answer,
            // whatever the Excel/DOCX searches find.
            const couldNotSearch = session.options.searchType === 'content' || /^rg: error parsing glob /m.test(session.error ?? '');
            if (code === 2 && !session.printedOutput && session.error?.trim() && couldNotSearch) {
                session.failure = { error: session.error.trim() };
            }
            else if (code === 2) {
                this.noteRipgrepErrors(session);
            }
            else if (code !== 0 && code !== 1 && !session.stoppedRipgrep) {
                const how = code === null ? `signal ${session.process.signalCode}` : `exit code ${code}`;
                session.ripgrepTrouble ?? (session.ripgrepTrouble = how);
                this.noteUnsearched(session, 'ripgrep_ended', how);
            }
            session.exitCode = code;
            this.finishSource(session, 'ripgrep');
        });
        process.on('error', (error) => {
            session.error = `Process error: ${error.message}`;
            session.ripgrepTrouble ?? (session.ripgrepTrouble = session.error);
            this.noteUnsearched(session, 'ripgrep_ended', session.error);
            this.finishSource(session, 'ripgrep');
        });
    }
    /**
     * ripgrep searched, and ended with exit code 2: some paths couldn't be
     * searched, each named on stderr ("rg: <path>: <why> (os error N)"). Those it
     * may not read are about permissions; any other error is trouble, which fails
     * a search that found nothing (see outcomeOf).
     */
    noteRipgrepErrors(session) {
        const errors = (session.error ?? '').split('\n').filter(line => line.startsWith('rg: ')).map(line => line.slice(4).trim());
        for (const error of errors) {
            const osError = Number(/\(os error (\d+)\)$/.exec(error)?.[1]);
            if (PERMISSION_OS_ERRORS.includes(osError)) {
                this.noteUnsearched(session, 'permissions', error);
            }
            else {
                session.ripgrepTrouble ?? (session.ripgrepTrouble = error);
                this.noteUnsearched(session, 'ripgrep_error', error);
            }
        }
        if (errors.length === 0) {
            session.ripgrepTrouble ?? (session.ripgrepTrouble = 'exit code 2');
            this.noteUnsearched(session, 'ripgrep_ended', 'exit code 2');
        }
    }
    /** Something the search couldn't search: counted by kind, the first one kept as the example */
    noteUnsearched(session, kind, example) {
        const noted = session.unsearched.get(kind);
        if (noted)
            noted.count++;
        else
            session.unsearched.set(kind, { kind, count: 1, example });
    }
    /**
     * The session's one outcome, once every source is done. A failure first:
     * ripgrep couldn't search at all, or had trouble and nothing was found. Then
     * what stopped the search, if anything did; then what couldn't be searched.
     */
    outcomeOf(session) {
        if (!session.failure && session.ripgrepTrouble && session.totalMatches === 0) {
            const error = session.error?.trim();
            session.failure = error ? { error } : { ripgrepEnded: session.ripgrepTrouble };
        }
        if (session.failure)
            return 'failed';
        if (session.stopReason)
            return session.stopReason;
        return session.unsearched.size > 0 ? 'partial' : 'completed';
    }
    /**
     * A source has ended, or was stopped. This is the one place a session
     * completes: when its last source is done, whatever the order they end in.
     */
    finishSource(session, source) {
        session.pendingSources.delete(source);
        if (session.pendingSources.size > 0 || session.isComplete)
            return;
        session.outcome = this.outcomeOf(session);
        session.isComplete = true;
        session.endTime = Date.now();
        clearTimeout(session.timeoutTimer);
        capture('search_session_completed', {
            sessionId: session.id,
            exitCode: session.exitCode,
            totalResults: session.totalMatches + session.totalContextLines,
            totalMatches: session.totalMatches,
            runtime: session.endTime - session.startTime,
            outcome: session.outcome,
            wasIncomplete: session.outcome === 'partial' // Some files couldn't be searched
        });
        session.markCompleted();
        // Rely on cleanupSessions(maxAge) only; no per-session timer
    }
    /**
     * Stop every source still running (stop_search, timeout, dispose); the
     * session completes once ripgrep has exited.
     */
    stopSources(session) {
        this.stopOfficeSources(session);
        this.stopRipgrep(session);
    }
    /**
     * The Excel/DOCX searches are done as of now. They can't be interrupted
     * mid-file: each stops at its next check, and anything it still finds is dropped.
     */
    stopOfficeSources(session) {
        for (const source of session.pendingSources) {
            if (source === 'excel' || source === 'docx')
                this.finishSource(session, source);
        }
    }
    stopRipgrep(session) {
        if (session.pendingSources.has('ripgrep') && !session.process.killed) {
            session.stoppedRipgrep = true;
            session.process.kill('SIGTERM');
        }
    }
    /**
     * Split ripgrep's output into lines as it arrives. Only the new text is split,
     * its first piece joined to the line pending from earlier chunks, so a line
     * costs time linear in its length (splitting the whole pending line again for
     * every chunk made a long line quadratic). With isFinal, the pending line is
     * complete as it is.
     */
    processOutput(session, text, isFinal = false) {
        const pieces = text.split('\n');
        const lines = [];
        pieces.forEach((piece, i) => {
            this.appendToLine(session, piece);
            if (i < pieces.length - 1 || isFinal) {
                const line = this.takeLine(session);
                if (line !== undefined)
                    lines.push(line);
            }
        });
        this.processLines(session, lines);
    }
    /**
     * Add text to the line being received, unless that makes it longer than
     * MAX_OUTPUT_LINE_CHARS: then the line is skipped, noted by its file, and
     * the rest of it dropped as it arrives.
     */
    appendToLine(session, text) {
        if (session.skippingLine || !text)
            return;
        if (session.buffer.length + text.length <= MAX_OUTPUT_LINE_CHARS) {
            session.buffer += text;
            return;
        }
        const file = this.fileOfOutputLine((session.buffer + text).slice(0, 4096)) ?? '';
        session.skippedLines.set(file, (session.skippedLines.get(file) ?? 0) + 1);
        session.skippingLine = true;
        session.buffer = '';
    }
    /** The line being received, now complete; undefined if it was skipped */
    takeLine(session) {
        const line = session.buffer;
        session.buffer = '';
        if (session.skippingLine) {
            session.skippingLine = false;
            return undefined;
        }
        return line;
    }
    /**
     * The file a line of ripgrep's --json output is about, read from the line's
     * start: {"type":"match","data":{"path":{"text":"..."},... (a path that is
     * not valid UTF-8 comes as "bytes" and is not read)
     */
    fileOfOutputLine(start) {
        const quoted = /^\{"type":"\w+","data":\{"path":\{"text":("(?:[^"\\]|\\.)*")/.exec(start)?.[1];
        try {
            return quoted ? JSON.parse(quoted) : undefined;
        }
        catch {
            // A malformed escape in the path: the skipped line is then counted without a file name
            return undefined;
        }
    }
    processLines(session, lines) {
        for (const line of lines) {
            if (!line.trim())
                continue;
            const parsed = this.parseLine(line, session.options.searchType);
            if (parsed?.kind === 'match' && parsed.result.type === 'file' && !this.filePatternSelects(session, parsed.result.file)) {
                continue;
            }
            if (parsed) {
                this.collectRipgrepLine(session, parsed);
                // Early termination for exact filename matches (if enabled)
                if (parsed.kind === 'match' &&
                    session.options.earlyTermination !== false && // Default to true
                    this.isExactFilenameSearch(session.options)) {
                    const pat = path.normalize(session.options.pattern);
                    const filePath = path.normalize(parsed.result.file);
                    const ignoreCase = session.options.ignoreCase !== false;
                    const ends = ignoreCase
                        ? filePath.toLowerCase().endsWith(pat.toLowerCase())
                        : filePath.endsWith(pat);
                    if (ends) {
                        // Found exact match, terminate search early
                        setTimeout(() => this.stopRipgrep(session), 100); // Small delay to allow any remaining results
                        break;
                    }
                }
            }
        }
    }
    /**
     * Collect one line of ripgrep output. Once maxResults matches are in, only the
     * last match's trailing context is still collected - a matching line inside it
     * is kept as context, like grep -m - and ripgrep is stopped when it ends.
     */
    collectRipgrepLine(session, line) {
        if (line.kind !== 'match' && line.kind !== 'context') {
            // A file ended (or the next began): no trailing context is pending any more
            session.trailingContext = undefined;
        }
        else if (!this.atMaxResults(session)) {
            if (line.kind === 'context') {
                session.results.push(line.result);
                session.totalContextLines++;
            }
            else {
                // Note where this match's trailing context ends, in case it is the last match
                const contextLines = session.options.contextLines ?? 0;
                session.trailingContext = line.result.type === 'content' && line.result.line !== undefined && contextLines > 0
                    ? { file: line.result.file, lastLine: line.result.line + contextLines }
                    : undefined;
                this.collectMatch(session, line.result);
            }
        }
        else {
            const trailing = session.trailingContext;
            const lineNumber = line.result.line;
            if (trailing && line.result.file === trailing.file && lineNumber !== undefined && lineNumber <= trailing.lastLine) {
                session.results.push({ ...line.result, context: true });
                session.totalContextLines++;
                if (lineNumber === trailing.lastLine) {
                    session.trailingContext = undefined;
                }
            }
            else {
                // Past the last match's trailing context: everything from here on is dropped
                session.trailingContext = undefined;
            }
        }
        this.stopAtMaxResults(session);
    }
    /**
     * The one place a session counts a match - from ripgrep, Excel or DOCX alike -
     * so maxResults caps the TOTAL number of matches a search returns.
     */
    collectMatch(session, result) {
        if (this.atMaxResults(session))
            return;
        session.results.push(result);
        session.totalMatches++;
        if (this.atMaxResults(session)) {
            session.stopReason ?? (session.stopReason = 'max_results');
            this.stopAtMaxResults(session);
        }
    }
    /** Whether options.maxResults matches are in: later ones are dropped */
    atMaxResults(session) {
        const { maxResults } = session.options;
        return !!maxResults && maxResults > 0 && session.totalMatches >= maxResults;
    }
    /**
     * Once maxResults matches are in, stop the sources that cannot contribute
     * anything more: the Excel/DOCX searches at once, ripgrep when the last
     * match's trailing context is complete.
     */
    stopAtMaxResults(session) {
        if (!this.atMaxResults(session))
            return;
        this.stopOfficeSources(session);
        if (!session.trailingContext) {
            this.stopRipgrep(session);
        }
    }
    parseLine(line, searchType) {
        if (searchType === 'content') {
            // Parse JSON output from content search
            try {
                const parsed = JSON.parse(line);
                if (parsed.type === 'match') {
                    // Handle multiple submatches per line - return first submatch
                    const submatch = parsed.data?.submatches?.[0];
                    return {
                        kind: 'match',
                        result: {
                            file: parsed.data.path.text,
                            line: parsed.data.line_number,
                            match: keptText(submatch?.match?.text || parsed.data.lines.text),
                            type: 'content'
                        }
                    };
                }
                if (parsed.type === 'context') {
                    return {
                        kind: 'context',
                        result: {
                            context: true,
                            file: parsed.data.path.text,
                            line: parsed.data.line_number,
                            match: keptText(parsed.data.lines.text.trim()),
                            type: 'content'
                        }
                    };
                }
                // begin/end frame each file's lines; summary closes the output
                if (parsed.type === 'begin' || parsed.type === 'end') {
                    return { kind: parsed.type, file: parsed.data.path.text };
                }
                return { kind: 'summary' };
            }
            catch (error) {
                // Skip invalid JSON lines
                return null;
            }
        }
        else {
            // File search - each line is a file path
            return {
                kind: 'match',
                result: {
                    file: line.trim(),
                    type: 'file'
                }
            };
        }
    }
}
// Global search manager instance
export const searchManager = new SearchManager();