/ src / utils / stringUtils.ts
stringUtils.ts
  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   */
  9  export 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   */
 20  export 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   */
 32  export 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   */
 44  export 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   */
 54  export 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   */
 72  export 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   */
 82  export 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.
 88  const 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   */
 98  export 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   */
140  export 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   */
229  export 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  }