@@ -4,6 +4,7 @@ import os from 'os';
44import { randomBytes } from 'crypto' ;
55import { diffLines , createTwoFilesPatch } from 'diff' ;
66import { minimatch } from 'minimatch' ;
7+ import { StringDecoder } from 'string_decoder' ;
78import { normalizePath , expandHome } from './path-utils.js' ;
89import { isPathWithinAllowedDirectories } from './path-validation.js' ;
910
@@ -281,9 +282,9 @@ export async function applyFileEdits(
281282 return formattedDiff ;
282283}
283284
284- // Memory-efficient implementation to get the last N lines of a file
285+ // Read the last N lines of a file (UTF-8 safe)
285286export async function tailFile ( filePath : string , numLines : number ) : Promise < string > {
286- const CHUNK_SIZE = 1024 ; // Read 1KB at a time
287+ const CHUNK_SIZE = 1024 ;
287288 const stats = await fs . stat ( filePath ) ;
288289 const fileSize = stats . size ;
289290
@@ -292,62 +293,68 @@ export async function tailFile(filePath: string, numLines: number): Promise<stri
292293 // Open file for reading
293294 const fileHandle = await fs . open ( filePath , 'r' ) ;
294295 try {
295- const lines : string [ ] = [ ] ;
296+ // Collect raw byte buffers (read backwards from the end)
297+ const rawBuffers : Buffer [ ] = [ ] ;
296298 let position = fileSize ;
297- let chunk = Buffer . alloc ( CHUNK_SIZE ) ;
298- let linesFound = 0 ;
299- let remainingText = '' ;
299+ const chunk = Buffer . alloc ( CHUNK_SIZE ) ;
300+ let newlineCount = 0 ;
300301
301- // Read chunks from the end of the file until we have enough lines
302- while ( position > 0 && linesFound < numLines ) {
302+ // Read from the end until we have enough lines or reach start
303+ while ( position > 0 && newlineCount <= numLines ) {
303304 const size = Math . min ( CHUNK_SIZE , position ) ;
304305 position -= size ;
305306
306307 const { bytesRead } = await fileHandle . read ( chunk , 0 , size , position ) ;
307308 if ( ! bytesRead ) break ;
308309
309- // Get the chunk as a string and prepend any remaining text from previous iteration
310- const readData = chunk . slice ( 0 , bytesRead ) . toString ( 'utf-8' ) ;
311- const chunkText = readData + remainingText ;
312-
313- // Split by newlines and count
314- const chunkLines = normalizeLineEndings ( chunkText ) . split ( '\n' ) ;
310+ const data = chunk . slice ( 0 , bytesRead ) ;
311+ rawBuffers . push ( data ) ;
315312
316- // If this isn't the end of the file, the first line is likely incomplete
317- // Save it to prepend to the next chunk
318- if ( position > 0 ) {
319- remainingText = chunkLines [ 0 ] ;
320- chunkLines . shift ( ) ; // Remove the first (incomplete) line
321- }
322-
323- // Add lines to our result (up to the number we need)
324- for ( let i = chunkLines . length - 1 ; i >= 0 && linesFound < numLines ; i -- ) {
325- lines . unshift ( chunkLines [ i ] ) ;
326- linesFound ++ ;
313+ // Count newlines in raw bytes (0x0A is single-byte even in UTF-8)
314+ for ( let i = 0 ; i < data . length ; i ++ ) {
315+ if ( data [ i ] === 0x0A ) newlineCount ++ ;
327316 }
328317 }
329318
330- return lines . join ( '\n' ) ;
319+ // Reverse to get forward chronological order
320+ rawBuffers . reverse ( ) ;
321+
322+ // Decode the concatenated buffer with StringDecoder (handles cross-chunk UTF-8)
323+ const decoder = new StringDecoder ( 'utf-8' ) ;
324+ const fullText = rawBuffers . map ( b => decoder . write ( b ) ) . join ( '' ) ;
325+ const finalChunk = decoder . end ( ) ;
326+
327+ // Split into lines and take the last numLines
328+ const allLines = normalizeLineEndings ( fullText + finalChunk ) . split ( '\n' ) ;
329+
330+ // Filter out the last empty element if the file ends with newline
331+ const relevantLines = allLines . filter ( Boolean ) . length > numLines
332+ ? allLines . slice ( - numLines - 1 ) // include empty trailing line
333+ : allLines ;
334+
335+ return allLines . slice ( - numLines ) . join ( '\n' ) ;
331336 } finally {
332337 await fileHandle . close ( ) ;
333338 }
339+ } }
334340}
335341
336- // New function to get the first N lines of a file
342+ // Read the first N lines of a file (UTF-8 safe)
337343export async function headFile ( filePath : string , numLines : number ) : Promise < string > {
338344 const fileHandle = await fs . open ( filePath , 'r' ) ;
339345 try {
340346 const lines : string [ ] = [ ] ;
341347 let buffer = '' ;
342348 let bytesRead = 0 ;
343349 const chunk = Buffer . alloc ( 1024 ) ; // 1KB buffer
350+ const decoder = new StringDecoder ( 'utf-8' ) ;
344351
345352 // Read chunks and count lines until we have enough or reach EOF
346353 while ( lines . length < numLines ) {
347354 const result = await fileHandle . read ( chunk , 0 , chunk . length , bytesRead ) ;
348355 if ( result . bytesRead === 0 ) break ; // End of file
349356 bytesRead += result . bytesRead ;
350- buffer += chunk . slice ( 0 , result . bytesRead ) . toString ( 'utf-8' ) ;
357+ buffer += decoder . write ( chunk . slice ( 0 , result . bytesRead ) ) ;
351358
352359 const newLineIndex = buffer . lastIndexOf ( '\n' ) ;
353360 if ( newLineIndex !== - 1 ) {
@@ -360,7 +367,8 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
360367 }
361368 }
362369
363- // If there is leftover content and we still need lines, add it
370+ // Flush any remaining buffered bytes, and add leftover content if needed
371+ buffer += decoder . end ( ) ;
364372 if ( buffer . length > 0 && lines . length < numLines ) {
365373 lines . push ( buffer ) ;
366374 }
0 commit comments