source dump of claude code
at main 235 lines 6.6 kB view raw
1/** 2 * General string utility functions and classes for safe string accumulation 3 */ 4 5/** 6 * Escapes special regex characters in a string so it can be used as a literal 7 * pattern in a RegExp constructor. 8 */ 9export function escapeRegExp(str: string): string { 10 return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 11} 12 13/** 14 * Uppercases the first character of a string, leaving the rest unchanged. 15 * Unlike lodash `capitalize`, this does NOT lowercase the remaining characters. 16 * 17 * @example capitalize('fooBar') → 'FooBar' 18 * @example capitalize('hello world') → 'Hello world' 19 */ 20export function capitalize(str: string): string { 21 return str.charAt(0).toUpperCase() + str.slice(1) 22} 23 24/** 25 * Returns the singular or plural form of a word based on count. 26 * Replaces the inline `word${n === 1 ? '' : 's'}` idiom. 27 * 28 * @example plural(1, 'file') → 'file' 29 * @example plural(3, 'file') → 'files' 30 * @example plural(2, 'entry', 'entries') → 'entries' 31 */ 32export function plural( 33 n: number, 34 word: string, 35 pluralWord = word + 's', 36): string { 37 return n === 1 ? word : pluralWord 38} 39 40/** 41 * Returns the first line of a string without allocating a split array. 42 * Used for shebang detection in diff rendering. 43 */ 44export function firstLineOf(s: string): string { 45 const nl = s.indexOf('\n') 46 return nl === -1 ? s : s.slice(0, nl) 47} 48 49/** 50 * Counts occurrences of `char` in `str` using indexOf jumps instead of 51 * per-character iteration. Structurally typed so Buffer works too 52 * (Buffer.indexOf accepts string needles). 53 */ 54export function countCharInString( 55 str: { indexOf(search: string, start?: number): number }, 56 char: string, 57 start = 0, 58): number { 59 let count = 0 60 let i = str.indexOf(char, start) 61 while (i !== -1) { 62 count++ 63 i = str.indexOf(char, i + 1) 64 } 65 return count 66} 67 68/** 69 * Normalize full-width (zenkaku) digits to half-width digits. 70 * Useful for accepting input from Japanese/CJK IMEs. 71 */ 72export function normalizeFullWidthDigits(input: string): string { 73 return input.replace(/[0-9]/g, ch => 74 String.fromCharCode(ch.charCodeAt(0) - 0xfee0), 75 ) 76} 77 78/** 79 * Normalize full-width (zenkaku) space to half-width space. 80 * Useful for accepting input from Japanese/CJK IMEs (U+3000 → U+0020). 81 */ 82export function normalizeFullWidthSpace(input: string): string { 83 return input.replace(/\u3000/g, ' ') 84} 85 86// Keep in-memory accumulation modest to avoid blowing up RSS. 87// Overflow beyond this limit is spilled to disk by ShellCommand. 88const MAX_STRING_LENGTH = 2 ** 25 89 90/** 91 * Safely joins an array of strings with a delimiter, truncating if the result exceeds maxSize. 92 * 93 * @param lines Array of strings to join 94 * @param delimiter Delimiter to use between strings (default: ',') 95 * @param maxSize Maximum size of the resulting string 96 * @returns The joined string, truncated if necessary 97 */ 98export function safeJoinLines( 99 lines: string[], 100 delimiter: string = ',', 101 maxSize: number = MAX_STRING_LENGTH, 102): string { 103 const truncationMarker = '...[truncated]' 104 let result = '' 105 106 for (const line of lines) { 107 const delimiterToAdd = result ? delimiter : '' 108 const fullAddition = delimiterToAdd + line 109 110 if (result.length + fullAddition.length <= maxSize) { 111 // The full line fits 112 result += fullAddition 113 } else { 114 // Need to truncate 115 const remainingSpace = 116 maxSize - 117 result.length - 118 delimiterToAdd.length - 119 truncationMarker.length 120 121 if (remainingSpace > 0) { 122 // Add delimiter and as much of the line as will fit 123 result += 124 delimiterToAdd + line.slice(0, remainingSpace) + truncationMarker 125 } else { 126 // No room for any of this line, just add truncation marker 127 result += truncationMarker 128 } 129 return result 130 } 131 } 132 return result 133} 134 135/** 136 * A string accumulator that safely handles large outputs by truncating from the end 137 * when a size limit is exceeded. This prevents RangeError crashes while preserving 138 * the beginning of the output. 139 */ 140export class EndTruncatingAccumulator { 141 private content: string = '' 142 private isTruncated = false 143 private totalBytesReceived = 0 144 145 /** 146 * Creates a new EndTruncatingAccumulator 147 * @param maxSize Maximum size in characters before truncation occurs 148 */ 149 constructor(private readonly maxSize: number = MAX_STRING_LENGTH) {} 150 151 /** 152 * Appends data to the accumulator. If the total size exceeds maxSize, 153 * the end is truncated to maintain the size limit. 154 * @param data The string data to append 155 */ 156 append(data: string | Buffer): void { 157 const str = typeof data === 'string' ? data : data.toString() 158 this.totalBytesReceived += str.length 159 160 // If already at capacity and truncated, don't modify content 161 if (this.isTruncated && this.content.length >= this.maxSize) { 162 return 163 } 164 165 // Check if adding the string would exceed the limit 166 if (this.content.length + str.length > this.maxSize) { 167 // Only append what we can fit 168 const remainingSpace = this.maxSize - this.content.length 169 if (remainingSpace > 0) { 170 this.content += str.slice(0, remainingSpace) 171 } 172 this.isTruncated = true 173 } else { 174 this.content += str 175 } 176 } 177 178 /** 179 * Returns the accumulated string, with truncation marker if truncated 180 */ 181 toString(): string { 182 if (!this.isTruncated) { 183 return this.content 184 } 185 186 const truncatedBytes = this.totalBytesReceived - this.maxSize 187 const truncatedKB = Math.round(truncatedBytes / 1024) 188 return this.content + `\n... [output truncated - ${truncatedKB}KB removed]` 189 } 190 191 /** 192 * Clears all accumulated data 193 */ 194 clear(): void { 195 this.content = '' 196 this.isTruncated = false 197 this.totalBytesReceived = 0 198 } 199 200 /** 201 * Returns the current size of accumulated data 202 */ 203 get length(): number { 204 return this.content.length 205 } 206 207 /** 208 * Returns whether truncation has occurred 209 */ 210 get truncated(): boolean { 211 return this.isTruncated 212 } 213 214 /** 215 * Returns total bytes received (before truncation) 216 */ 217 get totalBytes(): number { 218 return this.totalBytesReceived 219 } 220} 221 222/** 223 * Truncates text to a maximum number of lines, adding an ellipsis if truncated. 224 * 225 * @param text The text to truncate 226 * @param maxLines Maximum number of lines to keep 227 * @returns The truncated text with ellipsis if truncated 228 */ 229export function truncateToLines(text: string, maxLines: number): string { 230 const lines = text.split('\n') 231 if (lines.length <= maxLines) { 232 return text 233 } 234 return lines.slice(0, maxLines).join('\n') + '…' 235}