Files
omp-edit/lib/text.js
T

79 lines
2.8 KiB
JavaScript

// 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))