Skip to content

Commit 5a4d254

Browse files
committed
fix(filesystem): preserve UTF-8 across head and tail chunks
1 parent 599dafc commit 5a4d254

2 files changed

Lines changed: 65 additions & 27 deletions

File tree

src/filesystem/__tests__/lib.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ import {
2626
vi.mock('fs/promises');
2727
const mockFs = fs as any;
2828

29+
function createMockFileHandle(content: Buffer) {
30+
return {
31+
read: vi.fn(
32+
async (buffer: Buffer, offset: number, length: number, position: number) => {
33+
const bytesRead = content.copy(
34+
buffer,
35+
offset,
36+
position,
37+
Math.min(position + length, content.length),
38+
);
39+
return { bytesRead, buffer };
40+
},
41+
),
42+
close: vi.fn().mockResolvedValue(undefined),
43+
};
44+
}
45+
2946
describe('Lib Functions', () => {
3047
beforeEach(() => {
3148
vi.clearAllMocks();
@@ -643,6 +660,23 @@ describe('Lib Functions', () => {
643660
expect(mockFileHandle.close).toHaveBeenCalled();
644661
});
645662

663+
it('preserves UTF-8 characters split across chunk boundaries', async () => {
664+
const content = Buffer.concat([
665+
Buffer.from('discard\n'),
666+
Buffer.from('界'),
667+
Buffer.alloc(1017, 'a'),
668+
Buffer.from('\nlast'),
669+
]);
670+
const mockFileHandle = createMockFileHandle(content);
671+
672+
mockFs.stat.mockResolvedValue({ size: content.length } as any);
673+
mockFs.open.mockResolvedValue(mockFileHandle);
674+
675+
const result = await tailFile('/test/file.txt', 2);
676+
677+
expect(result).toBe(`界${'a'.repeat(1017)}\nlast`);
678+
});
679+
646680
it('handles read errors gracefully', async () => {
647681
mockFs.stat.mockResolvedValue({ size: 100 } as any);
648682

@@ -699,6 +733,20 @@ describe('Lib Functions', () => {
699733
expect(mockFileHandle.close).toHaveBeenCalled();
700734
});
701735

736+
it('preserves UTF-8 characters split across chunk boundaries', async () => {
737+
const content = Buffer.concat([
738+
Buffer.alloc(1023, 'a'),
739+
Buffer.from('界\nsecond'),
740+
]);
741+
const mockFileHandle = createMockFileHandle(content);
742+
743+
mockFs.open.mockResolvedValue(mockFileHandle);
744+
745+
const result = await headFile('/test/file.txt', 1);
746+
747+
expect(result).toBe(`${'a'.repeat(1023)}界`);
748+
});
749+
702750
it('handles files with leftover content', async () => {
703751
const mockFileHandle = {
704752
read: vi.fn(),

src/filesystem/lib.ts

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "fs/promises";
22
import path from "path";
33
import os from 'os';
44
import { randomBytes } from 'crypto';
5+
import { StringDecoder } from 'string_decoder';
56
import { diffLines, createTwoFilesPatch } from 'diff';
67
import { minimatch } from 'minimatch';
78
import { normalizePath, expandHome } from './path-utils.js';
@@ -292,42 +293,28 @@ 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+
const chunks: Buffer[] = [];
296297
let position = fileSize;
297-
let chunk = Buffer.alloc(CHUNK_SIZE);
298-
let linesFound = 0;
299-
let remainingText = '';
298+
const chunk = Buffer.alloc(CHUNK_SIZE);
299+
let newlinesFound = 0;
300300

301301
// Read chunks from the end of the file until we have enough lines
302-
while (position > 0 && linesFound < numLines) {
302+
while (position > 0 && newlinesFound < numLines) {
303303
const size = Math.min(CHUNK_SIZE, position);
304304
position -= size;
305305

306306
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
307307
if (!bytesRead) break;
308-
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');
315-
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++;
308+
309+
const readData = Buffer.from(chunk.subarray(0, bytesRead));
310+
chunks.unshift(readData);
311+
for (const byte of readData) {
312+
if (byte === 0x0a) newlinesFound++;
327313
}
328314
}
329-
330-
return lines.join('\n');
315+
316+
const text = normalizeLineEndings(Buffer.concat(chunks).toString('utf-8'));
317+
return text.split('\n').slice(-numLines).join('\n');
331318
} finally {
332319
await fileHandle.close();
333320
}
@@ -341,13 +328,14 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
341328
let buffer = '';
342329
let bytesRead = 0;
343330
const chunk = Buffer.alloc(1024); // 1KB buffer
331+
const decoder = new StringDecoder('utf-8');
344332

345333
// Read chunks and count lines until we have enough or reach EOF
346334
while (lines.length < numLines) {
347335
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
348336
if (result.bytesRead === 0) break; // End of file
349337
bytesRead += result.bytesRead;
350-
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
338+
buffer += decoder.write(chunk.subarray(0, result.bytesRead));
351339

352340
const newLineIndex = buffer.lastIndexOf('\n');
353341
if (newLineIndex !== -1) {
@@ -359,6 +347,8 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
359347
}
360348
}
361349
}
350+
351+
buffer += decoder.end();
362352

363353
// If there is leftover content and we still need lines, add it
364354
if (buffer.length > 0 && lines.length < numLines) {

0 commit comments

Comments
 (0)