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}