truncate.ts

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: GPL-3.0-or-later
  4
  5export const DEFAULT_MAX_LINES = 2000;
  6export const DEFAULT_MAX_BYTES = 50 * 1024;
  7export const GREP_MAX_LINE_LENGTH = 500;
  8
  9export interface TruncationResult {
 10  content: string;
 11  truncated: boolean;
 12  truncatedBy: "lines" | "bytes" | null;
 13  totalLines: number;
 14  totalBytes: number;
 15  outputLines: number;
 16  outputBytes: number;
 17  lastLinePartial: boolean;
 18  firstLineExceedsLimit: boolean;
 19  maxLines: number;
 20  maxBytes: number;
 21}
 22
 23export interface TruncationOptions {
 24  maxLines?: number;
 25  maxBytes?: number;
 26}
 27
 28export function formatSize(bytes: number): string {
 29  if (bytes < 1024) return `${bytes}B`;
 30  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
 31  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
 32}
 33
 34export function truncateHead(
 35  content: string,
 36  options: TruncationOptions = {},
 37): TruncationResult {
 38  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
 39  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
 40  const totalBytes = Buffer.byteLength(content, "utf-8");
 41  const lines = content.split("\n");
 42  const totalLines = lines.length;
 43
 44  if (totalLines <= maxLines && totalBytes <= maxBytes) {
 45    return {
 46      content,
 47      truncated: false,
 48      truncatedBy: null,
 49      totalLines,
 50      totalBytes,
 51      outputLines: totalLines,
 52      outputBytes: totalBytes,
 53      lastLinePartial: false,
 54      firstLineExceedsLimit: false,
 55      maxLines,
 56      maxBytes,
 57    };
 58  }
 59
 60  const firstLineBytes = Buffer.byteLength(lines[0]!, "utf-8");
 61  if (firstLineBytes > maxBytes) {
 62    return {
 63      content: "",
 64      truncated: true,
 65      truncatedBy: "bytes",
 66      totalLines,
 67      totalBytes,
 68      outputLines: 0,
 69      outputBytes: 0,
 70      lastLinePartial: false,
 71      firstLineExceedsLimit: true,
 72      maxLines,
 73      maxBytes,
 74    };
 75  }
 76
 77  const outputLinesArr: string[] = [];
 78  let outputBytesCount = 0;
 79  let truncatedBy: "lines" | "bytes" = "lines";
 80
 81  for (let i = 0; i < lines.length && i < maxLines; i++) {
 82    const line = lines[i]!;
 83    const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0);
 84    if (outputBytesCount + lineBytes > maxBytes) {
 85      truncatedBy = "bytes";
 86      break;
 87    }
 88    outputLinesArr.push(line);
 89    outputBytesCount += lineBytes;
 90  }
 91
 92  if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
 93    truncatedBy = "lines";
 94  }
 95
 96  const outputContent = outputLinesArr.join("\n");
 97  const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
 98
 99  return {
100    content: outputContent,
101    truncated: true,
102    truncatedBy,
103    totalLines,
104    totalBytes,
105    outputLines: outputLinesArr.length,
106    outputBytes: finalOutputBytes,
107    lastLinePartial: false,
108    firstLineExceedsLimit: false,
109    maxLines,
110    maxBytes,
111  };
112}
113
114function truncateStringToBytesFromEnd(
115  str: string,
116  maxBytes: number,
117): string {
118  const buf = Buffer.from(str, "utf-8");
119  if (buf.length <= maxBytes) return str;
120  let end = buf.length;
121  let start = end - maxBytes;
122  // Walk forward to a valid UTF-8 boundary
123  while (start < end && (buf[start]! & 0xc0) === 0x80) {
124    start++;
125  }
126  return buf.subarray(start, end).toString("utf-8");
127}
128
129export function truncateTail(
130  content: string,
131  options: TruncationOptions = {},
132): TruncationResult {
133  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
134  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
135  const totalBytes = Buffer.byteLength(content, "utf-8");
136  const lines = content.split("\n");
137  const totalLines = lines.length;
138
139  if (totalLines <= maxLines && totalBytes <= maxBytes) {
140    return {
141      content,
142      truncated: false,
143      truncatedBy: null,
144      totalLines,
145      totalBytes,
146      outputLines: totalLines,
147      outputBytes: totalBytes,
148      lastLinePartial: false,
149      firstLineExceedsLimit: false,
150      maxLines,
151      maxBytes,
152    };
153  }
154
155  const outputLinesArr: string[] = [];
156  let outputBytesCount = 0;
157  let truncatedBy: "lines" | "bytes" = "lines";
158
159  for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
160    const line = lines[i]!;
161    const lineBytes =
162      Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0);
163    if (outputBytesCount + lineBytes > maxBytes) {
164      truncatedBy = "bytes";
165      break;
166    }
167    outputLinesArr.unshift(line);
168    outputBytesCount += lineBytes;
169  }
170
171  if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
172    truncatedBy = "lines";
173  }
174
175  let lastLinePartial = false;
176
177  // If we couldn't fit any lines (last line alone exceeds byte limit),
178  // take partial content from the end.
179  if (outputLinesArr.length === 0) {
180    const lastLine = lines[lines.length - 1]!;
181    const partial = truncateStringToBytesFromEnd(lastLine, maxBytes);
182    outputLinesArr.push(partial);
183    outputBytesCount = Buffer.byteLength(partial, "utf-8");
184    truncatedBy = "bytes";
185    lastLinePartial = true;
186  }
187
188  const outputContent = outputLinesArr.join("\n");
189  const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
190
191  return {
192    content: outputContent,
193    truncated: true,
194    truncatedBy,
195    totalLines,
196    totalBytes,
197    outputLines: outputLinesArr.length,
198    outputBytes: finalOutputBytes,
199    lastLinePartial,
200    firstLineExceedsLimit: false,
201    maxLines,
202    maxBytes,
203  };
204}
205
206export function truncateLine(
207  line: string,
208  maxChars: number = GREP_MAX_LINE_LENGTH,
209): { text: string; wasTruncated: boolean } {
210  if (line.length <= maxChars) {
211    return { text: line, wasTruncated: false };
212  }
213  return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
214}