// Text helpers shared by every edit mode: line splitting that remembers the // trailing newline, snapshot tags, and compact numbered previews. import crypto from 'node:crypto' /** Normalize CRLF/CR to LF. */ export const toLF = (s) => String(s).replace(/\r\n?/g, '\n') /** Split LF text into lines plus whether it ended with a newline. Empty text has no lines. */ export function splitLines(text) { const t = toLF(text) if (t === '') return { lines: [], eol: false } const eol = t.endsWith('\n') const lines = (eol ? t.slice(0, -1) : t).split('\n') return { lines, eol } } /** Inverse of splitLines. A file that gained content keeps (or adopts) a trailing newline. */ export function joinLines(lines, eol) { if (lines.length === 0) return '' return lines.join('\n') + (eol ? '\n' : '') } /** Dominant line ending of the original file, so writes preserve CRLF files. */ export function detectEol(raw) { const crlf = (raw.match(/\r\n/g) || []).length const lf = (raw.match(/\n/g) || []).length - crlf return crlf > lf ? '\r\n' : '\n' } export const restoreEol = (lfText, eol) => (eol === '\r\n' ? lfText.replace(/\n/g, '\r\n') : lfText) /** Four uppercase hex characters derived from the whole LF-normalized file. */ export function fileTag(text) { return crypto.createHash('sha256').update(toLF(text)).digest('hex').slice(0, 4).toUpperCase() } export const HEADER_RE = /^\[(.+?)#([0-9A-Fa-f]{4})\]\s*$/ export const formatHeader = (path, tag) => `[${path}#${tag}]` export const numbered = (n, text) => `${n}:${text}` /** * Compact post-edit preview: changed regions of the new text (by line numbers) * with `context` lines around them, separated by `…`. * @param {string[]} lines new file lines * @param {Array<[number, number]>} regions 1-based inclusive [start, end] in new numbering */ export function preview(lines, regions, { context = 2, maxLines = 80 } = {}) { if (!regions.length || !lines.length) return [] const spans = regions .map(([a, b]) => [Math.max(1, a - context), Math.min(lines.length, Math.max(a, b) + context)]) .sort((x, y) => x[0] - y[0]) const merged = [] for (const s of spans) { const last = merged.at(-1) if (last && s[0] <= last[1] + 1) last[1] = Math.max(last[1], s[1]) else merged.push([...s]) } const out = [] for (const [a, b] of merged) { if (out.length) out.push('…') for (let n = a; n <= b; n++) { if (out.length >= maxLines) { out.push('… (preview truncated; read the file for more)'); return out } out.push(numbered(n, lines[n - 1])) } } return out } /** Error type that carries a model-facing message. */ export class EditError extends Error { constructor(message, extra = {}) { super(message) this.name = 'EditError' Object.assign(this, extra) } } export const jsonQuote = (s) => JSON.stringify(String(s))