/** * Streaming text scans: one read of a file yields its revision, its NUL and UTF-8 validity, line * statistics, and the decoded text or selected lines. * * @module pi-hashline-edit/pi */ import { createReadStream } from "node:fs"; import { createUtf8Decoder, Utf8DecodingError, type Utf8Decoding } from "../core/text.ts"; import { throwIfCancelled } from "./error-text.ts"; import { withFileRead } from "./file-read.ts"; import { createRevisionHasher } from "./file-revision.ts"; const TEXT_READ_CHUNK_BYTES = 64 * 1024; /** * Scan whole-file bytes; lossy decoding reports validity without rejecting malformed UTF-8. * The revision covers exactly the bytes this scan read; onChunk also receives the bytes read so far. */ export async function scanTextFile( path: string, options: { readonly onChunk?: (text: string, bytesRead: number) => void | Promise; readonly signal?: AbortSignal; readonly decoding?: Utf8Decoding; } = {}, ) { const { onChunk, signal, decoding = "strict" } = options; return withFileRead(path, signal, async (handle) => { const decode = createUtf8Decoder(); // Strict decoding always decides validity; lossy text is decoded only for a chunk consumer. const lossy = decoding === "lossy" && onChunk ? createUtf8Decoder("lossy") : undefined; const revision = createRevisionHasher(); let byteLength = 0; let lineFeeds = 0; let lastByte = -1; let hasNul = false; let decodingError: unknown; /** Strict text of a chunk, or of the final flush without bytes; undefined once malformed. */ const decodeStrict = (bytes?: Buffer): string | undefined => { if (decodingError) return undefined; try { return decode(bytes, bytes !== undefined); } catch (error) { if (!(error instanceof Utf8DecodingError)) throw error; decodingError = error; return undefined; } }; const stream = createReadStream(path, { fd: handle, autoClose: false, highWaterMark: TEXT_READ_CHUNK_BYTES, signal, }); try { for await (const chunk of stream) { const bytes = chunk as Buffer; revision.update(bytes); byteLength += bytes.length; hasNul ||= bytes.includes(0); lastByte = bytes[bytes.length - 1]; let offset = 0; while ((offset = bytes.indexOf(10, offset)) !== -1) { lineFeeds++; offset++; } // NUL takes precedence even when an earlier chunk contained malformed UTF-8. if (hasNul) continue; let text = decodeStrict(bytes); if (lossy) text = lossy(bytes, true); if (text !== undefined) await onChunk?.(text, byteLength); } } catch (error) { // Node's stream abort raises its own AbortError; report the shared cancellation text. throwIfCancelled(signal); throw error; } throwIfCancelled(signal); if (!hasNul) { let tail = decodeStrict(); if (lossy) tail = lossy(); else if (decodingError && decoding === "strict") throw decodingError; if (tail) await onChunk?.(tail, byteLength); } return { revision: revision.digest(), totalLines: lineFeeds + (lastByte !== -1 && lastByte !== 10 ? 1 : 0), finalNewline: lastByte === -1 || lastByte === 10, hasNul, validUtf8: !decodingError, }; }); } interface ScannedLine { readonly number: number; readonly byteLength: number; readonly carriageReturns: number; /** Absent when the selected line exceeds maxLineBytes. */ readonly text: string | undefined; } /** Visit selected logical lines, retaining at most maxLineBytes of content per line. */ export async function scanTextLines( path: string, select: (number: number) => boolean, onLine: (line: ScannedLine) => void, options: { readonly signal?: AbortSignal; readonly maxLineBytes?: number; readonly decoding?: Utf8Decoding; } = {}, ) { const { signal, decoding, maxLineBytes: maxBytes = Infinity } = options; let number = 1; let byteLength = 0; let carriageReturns = 0; let parts: string[] = []; let selected = select(number); let lastChar = ""; const finish = (terminated: boolean) => { if (selected) { const stripCr = terminated && lastChar === "\r"; const logicalBytes = byteLength - (stripCr ? 1 : 0); let text = logicalBytes <= maxBytes ? parts.join("") : undefined; if (stripCr && text !== undefined) text = text.slice(0, -1); onLine({ number, byteLength: logicalBytes, carriageReturns: carriageReturns - (stripCr ? 1 : 0), text, }); carriageReturns = 0; byteLength = 0; parts = []; lastChar = ""; } number++; selected = select(number); }; const stats = await scanTextFile(path, { signal, decoding, onChunk: (text) => { let start = 0; while (start < text.length) { const lf = text.indexOf("\n", start); if (selected) { const end = lf === -1 ? text.length : lf; const fragment = text.slice(start, end); byteLength += Buffer.byteLength(fragment); let cr = 0; while ((cr = fragment.indexOf("\r", cr)) !== -1) { carriageReturns++; cr++; } if (fragment) lastChar = fragment[fragment.length - 1]; // Keep one extra byte until the terminator decides whether a final CR is content. if (byteLength <= maxBytes + 1) parts.push(fragment); else parts = []; } if (lf === -1) break; finish(true); start = lf + 1; } }, }); if (!stats.hasNul && byteLength > 0) finish(false); return stats; }