/** * Rendered page metadata returned alongside the text body. * `text` may include a final `-- page offset=... returned=... total=... next_offset=... use_offset=...` or `end` footer when entries exist. * The shape is computed only from the supplied entries and has no external side effects. */ export interface PageResult { text: string; returned: number; total: number; offset: number; limit: number; hasMore: boolean; nextOffset: number | null; } /** * Coerces an untrusted paging value into an integer within the allowed range. * Non-finite input returns the fallback, and finite input is rounded then clamped. * This helper performs no logging or mutation, so callers own any validation messaging. */ export function clampPageValue(value: unknown, fallback: number, min: number, max: number): number { const numeric = typeof value === "number" ? value : Number(value); if (!Number.isFinite(numeric)) return fallback; return Math.max(min, Math.min(max, Math.round(numeric))); } /** * Slices entries into a page and encodes continuation in a machine-readable footer. * Empty input returns `emptyText`; non-empty input returns metadata plus a footer with `next_offset`/`use_offset` or `end`. * The footer is appended to the user-visible text, so downstream parsers can continue without reading separate metadata. */ export function renderPage(entries: string[], offset: number, limit: number, emptyText: string): PageResult { const normalizedOffset = Math.max(0, offset); const normalizedLimit = Math.max(1, limit); const page = entries.slice(normalizedOffset, normalizedOffset + normalizedLimit); const hasMore = normalizedOffset + page.length < entries.length; const nextOffset = hasMore ? normalizedOffset + page.length : null; const footer = entries.length > 0 ? `\n\n-- page offset=${normalizedOffset} returned=${page.length} total=${entries.length}${hasMore ? ` next_offset=${nextOffset} use_offset=${nextOffset}` : " end"}` : ""; return { text: page.length > 0 ? `${page.join("\n")}${footer}` : emptyText, returned: page.length, total: entries.length, offset: normalizedOffset, limit: normalizedLimit, hasMore, nextOffset, }; } /** * Splits command output into non-empty logical lines while normalizing trailing carriage returns. * The result is an array of lines and never throws for ordinary string input. * Blank lines are discarded, which changes line numbering relative to raw command output. */ export function splitOutputLines(text: string): string[] { return text .split("\n") .map((line) => line.replace(/\r$/, "")) .filter((line) => line.length > 0); } /** * Splits ripgrep output into pageable entries, preserving context blocks when requested. * Empty or whitespace-only output returns an empty array; context mode groups lines separated by `--`. * Context separators are consumed rather than returned so each entry can render as one search result. */ export function splitRgEntries(text: string, hasContext: boolean): string[] { const trimmed = text.trim(); if (!trimmed) return []; if (!hasContext) { return splitOutputLines(trimmed); } const lines = trimmed.split("\n"); const entries: string[] = []; let current: string[] = []; for (const line of lines) { if (line === "--") { if (current.length > 0) { entries.push(current.join("\n")); current = []; } continue; } current.push(line); } if (current.length > 0) entries.push(current.join("\n")); return entries; }