formatter.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. /**
  2. * formatter.ts - Output formatting utilities for QMD
  3. *
  4. * Provides methods to format search results and documents into various output formats:
  5. * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
  6. */
  7. import { extractSnippet } from "./store.js";
  8. import type { SearchResult, MultiGetFile, MultiGetResult, DocumentResult } from "./store.js";
  9. // =============================================================================
  10. // Types
  11. // =============================================================================
  12. // Re-export store types for convenience
  13. export type { SearchResult, MultiGetFile, MultiGetResult, DocumentResult };
  14. export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json";
  15. export type FormatOptions = {
  16. full?: boolean; // Show full document content instead of snippet
  17. query?: string; // Query for snippet extraction and highlighting
  18. useColor?: boolean; // Enable terminal colors (default: false for non-CLI)
  19. };
  20. // =============================================================================
  21. // Escape Helpers
  22. // =============================================================================
  23. export function escapeCSV(value: string | null | number): string {
  24. if (value === null || value === undefined) return "";
  25. const str = String(value);
  26. if (str.includes(",") || str.includes('"') || str.includes("\n")) {
  27. return `"${str.replace(/"/g, '""')}"`;
  28. }
  29. return str;
  30. }
  31. export function escapeXml(str: string): string {
  32. return str
  33. .replace(/&/g, "&")
  34. .replace(/</g, "&lt;")
  35. .replace(/>/g, "&gt;")
  36. .replace(/"/g, "&quot;")
  37. .replace(/'/g, "&apos;");
  38. }
  39. // =============================================================================
  40. // Search Results Formatters
  41. // =============================================================================
  42. /**
  43. * Format search results as JSON
  44. */
  45. export function searchResultsToJson(
  46. results: SearchResult[],
  47. opts: FormatOptions = {}
  48. ): string {
  49. const output = results.map(row => ({
  50. score: Math.round(row.score * 100) / 100,
  51. file: row.displayPath,
  52. title: row.title,
  53. ...(row.context && { context: row.context }),
  54. ...(opts.full && { body: row.body }),
  55. ...(!opts.full && opts.query && { snippet: extractSnippet(row.body, opts.query, 300, row.chunkPos).snippet }),
  56. }));
  57. return JSON.stringify(output, null, 2);
  58. }
  59. /**
  60. * Format search results as CSV
  61. */
  62. export function searchResultsToCsv(
  63. results: SearchResult[],
  64. opts: FormatOptions = {}
  65. ): string {
  66. const query = opts.query || "";
  67. const header = "score,file,title,context,line,snippet";
  68. const rows = results.map(row => {
  69. const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
  70. const content = opts.full ? row.body : snippet;
  71. return [
  72. row.score.toFixed(4),
  73. escapeCSV(row.displayPath),
  74. escapeCSV(row.title),
  75. escapeCSV(row.context || ""),
  76. line,
  77. escapeCSV(content),
  78. ].join(",");
  79. });
  80. return [header, ...rows].join("\n");
  81. }
  82. /**
  83. * Format search results as simple files list (score,filepath,context)
  84. */
  85. export function searchResultsToFiles(results: SearchResult[]): string {
  86. return results.map(row => {
  87. const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
  88. return `${row.score.toFixed(2)},${row.displayPath}${ctx}`;
  89. }).join("\n");
  90. }
  91. /**
  92. * Format search results as Markdown
  93. */
  94. export function searchResultsToMarkdown(
  95. results: SearchResult[],
  96. opts: FormatOptions = {}
  97. ): string {
  98. const query = opts.query || "";
  99. return results.map(row => {
  100. const heading = row.title || row.displayPath;
  101. if (opts.full) {
  102. return `---\n# ${heading}\n\n${row.body}\n`;
  103. } else {
  104. const { snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
  105. return `---\n# ${heading}\n\n${snippet}\n`;
  106. }
  107. }).join("\n");
  108. }
  109. /**
  110. * Format search results as XML
  111. */
  112. export function searchResultsToXml(
  113. results: SearchResult[],
  114. opts: FormatOptions = {}
  115. ): string {
  116. const query = opts.query || "";
  117. const items = results.map(row => {
  118. const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
  119. const content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos).snippet;
  120. return `<file name="${escapeXml(row.displayPath)}"${titleAttr}>\n${escapeXml(content)}\n</file>`;
  121. });
  122. return items.join("\n\n");
  123. }
  124. /**
  125. * Format search results for MCP (simpler CSV format with pre-extracted snippets)
  126. */
  127. export function searchResultsToMcpCsv(
  128. results: { file: string; title: string; score: number; context: string | null; snippet: string }[]
  129. ): string {
  130. const header = "file,title,score,context,snippet";
  131. const rows = results.map(r =>
  132. [r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(",")
  133. );
  134. return [header, ...rows].join("\n");
  135. }
  136. // =============================================================================
  137. // Document Formatters (for multi-get using MultiGetFile from store)
  138. // =============================================================================
  139. /**
  140. * Format documents as JSON
  141. */
  142. export function documentsToJson(results: MultiGetFile[]): string {
  143. const output = results.map(r => ({
  144. file: r.displayPath,
  145. title: r.title,
  146. ...(r.context && { context: r.context }),
  147. ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
  148. }));
  149. return JSON.stringify(output, null, 2);
  150. }
  151. /**
  152. * Format documents as CSV
  153. */
  154. export function documentsToCsv(results: MultiGetFile[]): string {
  155. const header = "file,title,context,skipped,body";
  156. const rows = results.map(r =>
  157. [
  158. r.displayPath,
  159. r.title,
  160. r.context || "",
  161. r.skipped ? "true" : "false",
  162. r.skipped ? (r.skipReason || "") : r.body
  163. ].map(escapeCSV).join(",")
  164. );
  165. return [header, ...rows].join("\n");
  166. }
  167. /**
  168. * Format documents as files list
  169. */
  170. export function documentsToFiles(results: MultiGetFile[]): string {
  171. return results.map(r => {
  172. const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
  173. const status = r.skipped ? ",[SKIPPED]" : "";
  174. return `${r.displayPath}${ctx}${status}`;
  175. }).join("\n");
  176. }
  177. /**
  178. * Format documents as Markdown
  179. */
  180. export function documentsToMarkdown(results: MultiGetFile[]): string {
  181. return results.map(r => {
  182. let md = `## ${r.displayPath}\n\n`;
  183. if (r.title && r.title !== r.displayPath) md += `**Title:** ${r.title}\n\n`;
  184. if (r.context) md += `**Context:** ${r.context}\n\n`;
  185. if (r.skipped) {
  186. md += `> ${r.skipReason}\n`;
  187. } else {
  188. md += "```\n" + r.body + "\n```\n";
  189. }
  190. return md;
  191. }).join("\n");
  192. }
  193. /**
  194. * Format documents as XML
  195. */
  196. export function documentsToXml(results: MultiGetFile[]): string {
  197. const items = results.map(r => {
  198. let xml = " <document>\n";
  199. xml += ` <file>${escapeXml(r.displayPath)}</file>\n`;
  200. xml += ` <title>${escapeXml(r.title)}</title>\n`;
  201. if (r.context) xml += ` <context>${escapeXml(r.context)}</context>\n`;
  202. if (r.skipped) {
  203. xml += ` <skipped>true</skipped>\n`;
  204. xml += ` <reason>${escapeXml(r.skipReason || "")}</reason>\n`;
  205. } else {
  206. xml += ` <body>${escapeXml(r.body)}</body>\n`;
  207. }
  208. xml += " </document>";
  209. return xml;
  210. });
  211. return `<?xml version="1.0" encoding="UTF-8"?>\n<documents>\n${items.join("\n")}\n</documents>`;
  212. }
  213. // =============================================================================
  214. // Single Document Formatters
  215. // =============================================================================
  216. /**
  217. * Format a single DocumentResult as JSON
  218. */
  219. export function documentToJson(doc: DocumentResult): string {
  220. return JSON.stringify({
  221. file: doc.displayPath,
  222. title: doc.title,
  223. ...(doc.context && { context: doc.context }),
  224. hash: doc.hash,
  225. modifiedAt: doc.modifiedAt,
  226. bodyLength: doc.bodyLength,
  227. ...(doc.body !== undefined && { body: doc.body }),
  228. }, null, 2);
  229. }
  230. /**
  231. * Format a single DocumentResult as Markdown
  232. */
  233. export function documentToMarkdown(doc: DocumentResult): string {
  234. let md = `# ${doc.title || doc.displayPath}\n\n`;
  235. if (doc.context) md += `**Context:** ${doc.context}\n\n`;
  236. md += `**File:** ${doc.displayPath}\n`;
  237. md += `**Modified:** ${doc.modifiedAt}\n\n`;
  238. if (doc.body !== undefined) {
  239. md += "---\n\n" + doc.body + "\n";
  240. }
  241. return md;
  242. }
  243. /**
  244. * Format a single DocumentResult as XML
  245. */
  246. export function documentToXml(doc: DocumentResult): string {
  247. let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<document>\n`;
  248. xml += ` <file>${escapeXml(doc.displayPath)}</file>\n`;
  249. xml += ` <title>${escapeXml(doc.title)}</title>\n`;
  250. if (doc.context) xml += ` <context>${escapeXml(doc.context)}</context>\n`;
  251. xml += ` <hash>${escapeXml(doc.hash)}</hash>\n`;
  252. xml += ` <modifiedAt>${escapeXml(doc.modifiedAt)}</modifiedAt>\n`;
  253. xml += ` <bodyLength>${doc.bodyLength}</bodyLength>\n`;
  254. if (doc.body !== undefined) {
  255. xml += ` <body>${escapeXml(doc.body)}</body>\n`;
  256. }
  257. xml += `</document>`;
  258. return xml;
  259. }
  260. /**
  261. * Format a single document to the specified format
  262. */
  263. export function formatDocument(doc: DocumentResult, format: OutputFormat): string {
  264. switch (format) {
  265. case "json":
  266. return documentToJson(doc);
  267. case "md":
  268. return documentToMarkdown(doc);
  269. case "xml":
  270. return documentToXml(doc);
  271. default:
  272. // Default to markdown for CLI and other formats
  273. return documentToMarkdown(doc);
  274. }
  275. }
  276. // =============================================================================
  277. // Universal Format Function
  278. // =============================================================================
  279. /**
  280. * Format search results to the specified output format
  281. */
  282. export function formatSearchResults(
  283. results: SearchResult[],
  284. format: OutputFormat,
  285. opts: FormatOptions = {}
  286. ): string {
  287. switch (format) {
  288. case "json":
  289. return searchResultsToJson(results, opts);
  290. case "csv":
  291. return searchResultsToCsv(results, opts);
  292. case "files":
  293. return searchResultsToFiles(results);
  294. case "md":
  295. return searchResultsToMarkdown(results, opts);
  296. case "xml":
  297. return searchResultsToXml(results, opts);
  298. case "cli":
  299. // CLI format should be handled separately with colors
  300. // Return a simple text version as fallback
  301. return searchResultsToMarkdown(results, opts);
  302. default:
  303. return searchResultsToJson(results, opts);
  304. }
  305. }
  306. /**
  307. * Format documents to the specified output format
  308. */
  309. export function formatDocuments(
  310. results: MultiGetFile[],
  311. format: OutputFormat
  312. ): string {
  313. switch (format) {
  314. case "json":
  315. return documentsToJson(results);
  316. case "csv":
  317. return documentsToCsv(results);
  318. case "files":
  319. return documentsToFiles(results);
  320. case "md":
  321. return documentsToMarkdown(results);
  322. case "xml":
  323. return documentsToXml(results);
  324. case "cli":
  325. // CLI format should be handled separately with colors
  326. return documentsToMarkdown(results);
  327. default:
  328. return documentsToJson(results);
  329. }
  330. }