/** * formatter.ts - Output formatting utilities for QMD * * Provides methods to format search results and documents into various output formats: * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output). */ import { extractSnippet } from "./store.js"; import type { SearchResult, MultiGetFile, MultiGetResult, DocumentResult } from "./store.js"; // ============================================================================= // Types // ============================================================================= // Re-export store types for convenience export type { SearchResult, MultiGetFile, MultiGetResult, DocumentResult }; export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json"; export type FormatOptions = { full?: boolean; // Show full document content instead of snippet query?: string; // Query for snippet extraction and highlighting useColor?: boolean; // Enable terminal colors (default: false for non-CLI) }; // ============================================================================= // Escape Helpers // ============================================================================= export function escapeCSV(value: string | null | number): string { if (value === null || value === undefined) return ""; const str = String(value); if (str.includes(",") || str.includes('"') || str.includes("\n")) { return `"${str.replace(/"/g, '""')}"`; } return str; } export function escapeXml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // ============================================================================= // Search Results Formatters // ============================================================================= /** * Format search results as JSON */ export function searchResultsToJson( results: SearchResult[], opts: FormatOptions = {} ): string { const output = results.map(row => ({ score: Math.round(row.score * 100) / 100, file: row.displayPath, title: row.title, ...(row.context && { context: row.context }), ...(opts.full && { body: row.body }), ...(!opts.full && opts.query && { snippet: extractSnippet(row.body, opts.query, 300, row.chunkPos).snippet }), })); return JSON.stringify(output, null, 2); } /** * Format search results as CSV */ export function searchResultsToCsv( results: SearchResult[], opts: FormatOptions = {} ): string { const query = opts.query || ""; const header = "score,file,title,context,line,snippet"; const rows = results.map(row => { const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos); const content = opts.full ? row.body : snippet; return [ row.score.toFixed(4), escapeCSV(row.displayPath), escapeCSV(row.title), escapeCSV(row.context || ""), line, escapeCSV(content), ].join(","); }); return [header, ...rows].join("\n"); } /** * Format search results as simple files list (score,filepath,context) */ export function searchResultsToFiles(results: SearchResult[]): string { return results.map(row => { const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : ""; return `${row.score.toFixed(2)},${row.displayPath}${ctx}`; }).join("\n"); } /** * Format search results as Markdown */ export function searchResultsToMarkdown( results: SearchResult[], opts: FormatOptions = {} ): string { const query = opts.query || ""; return results.map(row => { const heading = row.title || row.displayPath; if (opts.full) { return `---\n# ${heading}\n\n${row.body}\n`; } else { const { snippet } = extractSnippet(row.body, query, 500, row.chunkPos); return `---\n# ${heading}\n\n${snippet}\n`; } }).join("\n"); } /** * Format search results as XML */ export function searchResultsToXml( results: SearchResult[], opts: FormatOptions = {} ): string { const query = opts.query || ""; const items = results.map(row => { const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : ""; const content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos).snippet; return `\n${escapeXml(content)}\n`; }); return items.join("\n\n"); } /** * Format search results for MCP (simpler CSV format with pre-extracted snippets) */ export function searchResultsToMcpCsv( results: { file: string; title: string; score: number; context: string | null; snippet: string }[] ): string { const header = "file,title,score,context,snippet"; const rows = results.map(r => [r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(",") ); return [header, ...rows].join("\n"); } // ============================================================================= // Document Formatters (for multi-get using MultiGetFile from store) // ============================================================================= /** * Format documents as JSON */ export function documentsToJson(results: MultiGetFile[]): string { const output = results.map(r => ({ file: r.displayPath, title: r.title, ...(r.context && { context: r.context }), ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }), })); return JSON.stringify(output, null, 2); } /** * Format documents as CSV */ export function documentsToCsv(results: MultiGetFile[]): string { const header = "file,title,context,skipped,body"; const rows = results.map(r => [ r.displayPath, r.title, r.context || "", r.skipped ? "true" : "false", r.skipped ? (r.skipReason || "") : r.body ].map(escapeCSV).join(",") ); return [header, ...rows].join("\n"); } /** * Format documents as files list */ export function documentsToFiles(results: MultiGetFile[]): string { return results.map(r => { const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : ""; const status = r.skipped ? ",[SKIPPED]" : ""; return `${r.displayPath}${ctx}${status}`; }).join("\n"); } /** * Format documents as Markdown */ export function documentsToMarkdown(results: MultiGetFile[]): string { return results.map(r => { let md = `## ${r.displayPath}\n\n`; if (r.title && r.title !== r.displayPath) md += `**Title:** ${r.title}\n\n`; if (r.context) md += `**Context:** ${r.context}\n\n`; if (r.skipped) { md += `> ${r.skipReason}\n`; } else { md += "```\n" + r.body + "\n```\n"; } return md; }).join("\n"); } /** * Format documents as XML */ export function documentsToXml(results: MultiGetFile[]): string { const items = results.map(r => { let xml = " \n"; xml += ` ${escapeXml(r.displayPath)}\n`; xml += ` ${escapeXml(r.title)}\n`; if (r.context) xml += ` ${escapeXml(r.context)}\n`; if (r.skipped) { xml += ` true\n`; xml += ` ${escapeXml(r.skipReason || "")}\n`; } else { xml += ` ${escapeXml(r.body)}\n`; } xml += " "; return xml; }); return `\n\n${items.join("\n")}\n`; } // ============================================================================= // Single Document Formatters // ============================================================================= /** * Format a single DocumentResult as JSON */ export function documentToJson(doc: DocumentResult): string { return JSON.stringify({ file: doc.displayPath, title: doc.title, ...(doc.context && { context: doc.context }), hash: doc.hash, modifiedAt: doc.modifiedAt, bodyLength: doc.bodyLength, ...(doc.body !== undefined && { body: doc.body }), }, null, 2); } /** * Format a single DocumentResult as Markdown */ export function documentToMarkdown(doc: DocumentResult): string { let md = `# ${doc.title || doc.displayPath}\n\n`; if (doc.context) md += `**Context:** ${doc.context}\n\n`; md += `**File:** ${doc.displayPath}\n`; md += `**Modified:** ${doc.modifiedAt}\n\n`; if (doc.body !== undefined) { md += "---\n\n" + doc.body + "\n"; } return md; } /** * Format a single DocumentResult as XML */ export function documentToXml(doc: DocumentResult): string { let xml = `\n\n`; xml += ` ${escapeXml(doc.displayPath)}\n`; xml += ` ${escapeXml(doc.title)}\n`; if (doc.context) xml += ` ${escapeXml(doc.context)}\n`; xml += ` ${escapeXml(doc.hash)}\n`; xml += ` ${escapeXml(doc.modifiedAt)}\n`; xml += ` ${doc.bodyLength}\n`; if (doc.body !== undefined) { xml += ` ${escapeXml(doc.body)}\n`; } xml += ``; return xml; } /** * Format a single document to the specified format */ export function formatDocument(doc: DocumentResult, format: OutputFormat): string { switch (format) { case "json": return documentToJson(doc); case "md": return documentToMarkdown(doc); case "xml": return documentToXml(doc); default: // Default to markdown for CLI and other formats return documentToMarkdown(doc); } } // ============================================================================= // Universal Format Function // ============================================================================= /** * Format search results to the specified output format */ export function formatSearchResults( results: SearchResult[], format: OutputFormat, opts: FormatOptions = {} ): string { switch (format) { case "json": return searchResultsToJson(results, opts); case "csv": return searchResultsToCsv(results, opts); case "files": return searchResultsToFiles(results); case "md": return searchResultsToMarkdown(results, opts); case "xml": return searchResultsToXml(results, opts); case "cli": // CLI format should be handled separately with colors // Return a simple text version as fallback return searchResultsToMarkdown(results, opts); default: return searchResultsToJson(results, opts); } } /** * Format documents to the specified output format */ export function formatDocuments( results: MultiGetFile[], format: OutputFormat ): string { switch (format) { case "json": return documentsToJson(results); case "csv": return documentsToCsv(results); case "files": return documentsToFiles(results); case "md": return documentsToMarkdown(results); case "xml": return documentsToXml(results); case "cli": // CLI format should be handled separately with colors return documentsToMarkdown(results); default: return documentsToJson(results); } }