import { Buffer, isUtf8 } from "node:buffer"; import { createHash } from "node:crypto"; import { structuredPatch } from "diff"; import anchorData from "./anchor-pieces.json" with { type: "json" }; export const MAX_LINES = 2000; export const MAX_BYTES = 50 * 1024; export function hash(line: string): string { const value = createHash("sha256").update(line).digest().readUInt32BE(0); const prefixCount = anchorData.prefixes.length / 2; const prefix = (value % prefixCount) * 2; const suffix = (Math.floor(value / prefixCount) % (anchorData.suffixes.length / 2)) * 2; return anchorData.prefixes.slice(prefix, prefix + 2) + anchorData.suffixes.slice(suffix, suffix + 2); } export type TextFile = { readonly raw: string; readonly bom: string; readonly text: string; /** Lines with their line endings. */ readonly rows: readonly string[]; readonly lines: readonly string[]; }; /** The most text read or edit takes in. */ export const MAX_TEXT_BYTES = 50 * 1024 * 1024; export function decodeText(content: Uint8Array): TextFile { const bytes = Buffer.from(content.buffer, content.byteOffset, content.byteLength); if (!isUtf8(bytes) || bytes.includes(0)) { throw new Error("Read and edit require UTF-8 text without NUL bytes."); } const raw = bytes.toString("utf8"); const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : ""; const text = raw.slice(bom.length); const rows = text.match(/[^\n]*\n|[^\n]+$/g) ?? []; return { raw, bom, text, rows, lines: rows.map((row) => row.replace(/\r?\n$/, "")) }; } /** How many leading rows fit in `maxBytes`, newlines between them included. */ export function fitting(rows: readonly string[], maxBytes = MAX_BYTES, separatorBytes = 1): number { let bytes = -separatorBytes; for (const [i, row] of rows.entries()) { bytes += Buffer.byteLength(row) + separatorBytes; if (bytes > maxBytes) return i; } return rows.length; } export function renderRead( file: TextFile, offset = 1, limit = MAX_LINES, hashes = true, ): string { const { lines } = file; const start = offset - 1; if (start > 0 && start >= lines.length) throw new Error("Offset is beyond end of file."); if (!lines.length) { return hashes ? "[Empty file; edit with old: [] to insert.]" : '[Empty file; edit with oldText: "" to insert.]'; } const end = start + Math.min(limit, MAX_LINES); const rows = hashes ? lines.slice(start, end).map((line) => `${hash(line)}│${line}`) : file.rows.slice(start, end); const count = fitting(rows, MAX_BYTES, hashes ? 1 : 0); if (count === 0) throw new Error(`Line ${start + 1} exceeds 50 KiB; use bash to inspect it.`); const next = start + count; const continuation = next < lines.length ? `\n\n[Showing lines ${start + 1}-${next} of ${lines.length}. Use offset=${ next + 1 } to continue.]` : ""; return rows.slice(0, count).join(hashes ? "\n" : "") + continuation; } export type HashlineEdit = { readonly before?: readonly string[]; readonly old: readonly string[]; readonly after?: readonly string[]; readonly new: readonly string[]; }; /** The edited file contents and a diff; `content === file.raw` when nothing changes. */ export function planEdit( file: TextFile, edits: readonly (HashlineEdit | { readonly oldText: string; readonly newText: string })[], hashes = true, ): { content: string; diff: string[] } { const { bom, text, rows, lines } = file; const anchors = edits.some((edit) => "old" in edit) ? lines.map(hash) : []; const eol = text.match(/\r?\n/)?.[0] ?? "\n"; const offsets = [0]; for (const row of rows) offsets.push(offsets.at(-1)! + row.length); const changes = edits.map((edit, index) => { if ("oldText" in edit) { if (/[\0]/.test(edit.oldText + edit.newText)) { throw new Error(`Edit ${index + 1}: text must not contain NUL.`); } if (!edit.oldText && text) { throw new Error(`Edit ${index + 1}: empty oldText is allowed only for an empty file.`); } const start = text.indexOf(edit.oldText); if (start === -1) { throw new Error( `Edit ${index + 1}: oldText not found (stale reference); read the file again.`, ); } if (edit.oldText && text.indexOf(edit.oldText, start + 1) !== -1) { throw new Error(`Edit ${index + 1}: ambiguous oldText; include more surrounding text.`); } return { start, end: start + edit.oldText.length, replacement: edit.newText, hashline: false, separate: false, }; } const before = edit.before ?? []; const sequence = [...before, ...edit.old, ...(edit.after ?? [])]; if (!sequence.length && lines.length) { throw new Error(`Edit ${index + 1}: supply old or adjacent context hashes.`); } if (edit.new.some((line) => /[\r\n\0]/.test(line))) { throw new Error(`Edit ${index + 1}: new must contain individual lines, without CR/LF/NUL.`); } let match = -1; for (let i = 0; i <= anchors.length - sequence.length; i++) { if (!sequence.every((anchor, j) => anchor === anchors[i + j])) continue; if (match !== -1) { throw new Error(`Edit ${index + 1}: ambiguous hashes; add adjacent before/after context.`); } match = i; } if (match === -1) { throw new Error( `Edit ${index + 1}: hashes not found (stale reference); read the file again.`, ); } const start = match + before.length; const end = start + edit.old.length; const replacement = edit.new.map((line, i) => { const row = start + i < end && line === lines[start + i] ? rows[start + i] : line + eol; return i < edit.new.length - 1 && !row.endsWith("\n") ? row + eol : row; }).join(""); return { start: offsets[start], end: offsets[end], replacement, hashline: true, separate: start === rows.length, }; }).sort((a, b) => a.start - b.start); for (let i = 1; i < changes.length; i++) { if (changes[i].start < changes[i - 1].end || changes[i].start === changes[i - 1].start) { throw new Error("Overlapping edits (or insertions at the same position); merge them."); } } const updated: string[] = []; let cursor = 0; for (const { start, end, replacement, separate } of changes) { updated.push(text.slice(cursor, start)); // Inserting after an unterminated last line needs a separator, not a joined line. if ( separate && replacement && updated.findLast((part) => part.length)?.endsWith("\n") === false ) { updated.push(eol); } updated.push(replacement); cursor = end; } updated.push(text.slice(cursor)); let result = updated.join(""); const last = changes.at(-1); if ( last?.hashline && last.end === text.length && text && !text.endsWith("\n") && result !== eol && !result.endsWith("\n" + eol) ) { result = result.replace(/\r?\n$/, ""); } const diff = structuredPatch("", "", text, result, "", "", { context: 3 }).hunks.flatMap(( hunk, ) => [ `@@ original line ${hunk.oldStart} @@`, ...hunk.lines.flatMap((row, i) => { if (row.startsWith("\\")) return []; const line = hunk.lines[i + 1]?.startsWith("\\") ? row.slice(1) : row.slice(1).replace(/\r$/, ""); return [ row.startsWith("+") || row.startsWith("-") ? `${row[0]}${hashes ? `${hash(line)}│` : ""}${line}` : ` ${line}`, ]; }), ]); return { content: bom + result, diff }; }