@svilupp/hash-edit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,564 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { realpathSync } from "node:fs"
3
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
4
+ import { basename, dirname, isAbsolute, join as joinPath, relative, resolve } from "node:path"
5
+
6
+ export type NewlineStyle = "lf" | "crlf" | "cr" | "none"
7
+
8
+ export type ReplaceOp = {
9
+ op: "replace"
10
+ line: number
11
+ hash: string
12
+ lines: string[]
13
+ end_line?: number
14
+ end_hash?: string
15
+ }
16
+
17
+ export type InsertBeforeOp = {
18
+ op: "insert_before"
19
+ line: number
20
+ hash: string
21
+ lines: string[]
22
+ }
23
+
24
+ export type InsertAfterOp = {
25
+ op: "insert_after"
26
+ line: number
27
+ hash: string
28
+ lines: string[]
29
+ }
30
+
31
+ export type DeleteOp = {
32
+ op: "delete"
33
+ line: number
34
+ hash: string
35
+ end_line?: number
36
+ end_hash?: string
37
+ }
38
+
39
+ export type EditOp = ReplaceOp | InsertBeforeOp | InsertAfterOp | DeleteOp
40
+
41
+ export type ReadResult = {
42
+ path: string
43
+ version: string
44
+ encoding: string
45
+ bom: boolean
46
+ newline: NewlineStyle
47
+ has_final_newline: boolean
48
+ hash_length: number
49
+ total_lines: number
50
+ start_line: number
51
+ end_line: number
52
+ lines: string[]
53
+ }
54
+
55
+ export type EditResult = {
56
+ path: string
57
+ version_before: string
58
+ version_after: string
59
+ applied: number
60
+ first_changed_line: number | null
61
+ encoding: string
62
+ bom: boolean
63
+ newline: NewlineStyle
64
+ has_final_newline: boolean
65
+ total_lines: number
66
+ }
67
+
68
+ export type WriteResult = {
69
+ path: string
70
+ version_after: string
71
+ created: boolean
72
+ encoding: string
73
+ bom: boolean
74
+ newline: NewlineStyle
75
+ has_final_newline: boolean
76
+ total_lines: number
77
+ }
78
+
79
+ type Snapshot = {
80
+ path: string
81
+ version: string
82
+ encoding: string
83
+ bom: boolean
84
+ newline: NewlineStyle
85
+ has_final_newline: boolean
86
+ lines: string[]
87
+ }
88
+
89
+ const DEFAULT_ENCODING = "utf-8"
90
+ const DEFAULT_HASH_LENGTH = 2
91
+ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf])
92
+ const PREFIX_RE = /^\s*\d+:[0-9a-f]{2,}\|/
93
+
94
+ export class HashEditError extends Error {}
95
+ export class PathEscapeError extends HashEditError {}
96
+ export class MixedNewlineError extends HashEditError {}
97
+ export class FileEncodingError extends HashEditError {}
98
+ export class InvalidOperationError extends HashEditError {}
99
+
100
+ export class VersionConflictError extends HashEditError {
101
+ path: string
102
+ expected: string | null
103
+ actual: string
104
+
105
+ constructor(path: string, expected: string | null, actual: string) {
106
+ const message =
107
+ expected === null
108
+ ? `${path} already exists. read() first and pass expected_version to overwrite safely.`
109
+ : `Version conflict for ${path}: expected ${expected}, actual ${actual}. Re-read the file first.`
110
+ super(message)
111
+ this.path = path
112
+ this.expected = expected
113
+ this.actual = actual
114
+ }
115
+ }
116
+
117
+ export class AnchorMismatchError extends HashEditError {
118
+ mismatches: Array<{ line: number; expected: string; actual: string }>
119
+
120
+ constructor(
121
+ message: string,
122
+ mismatches: Array<{ line: number; expected: string; actual: string }>,
123
+ ) {
124
+ super(message)
125
+ this.mismatches = mismatches
126
+ }
127
+ }
128
+
129
+ function newlineSeparator(style: NewlineStyle): string {
130
+ if (style === "lf") return "\n"
131
+ if (style === "crlf") return "\r\n"
132
+ if (style === "cr") return "\r"
133
+ return "\n"
134
+ }
135
+
136
+ function detectNewlineStyle(text: string): {
137
+ newline: NewlineStyle
138
+ has_final_newline: boolean
139
+ lines: string[]
140
+ } {
141
+ const matches = text.match(/\r\n|\n|\r/g) ?? []
142
+ const unique = new Set(matches)
143
+ if (unique.size > 1) {
144
+ throw new MixedNewlineError(`Mixed newline styles detected: ${Array.from(unique).join(", ")}`)
145
+ }
146
+ if (matches.length === 0) {
147
+ return { newline: "none", has_final_newline: false, lines: text === "" ? [] : [text] }
148
+ }
149
+ const separator = matches[0] ?? "\n"
150
+ const newline = separator === "\n" ? "lf" : separator === "\r\n" ? "crlf" : "cr"
151
+ const has_final_newline = text.endsWith(separator)
152
+ const split = text.split(separator)
153
+ const lines = has_final_newline ? split.slice(0, -1) : split
154
+ return { newline, has_final_newline, lines }
155
+ }
156
+
157
+ function joinText(lines: string[], newline: NewlineStyle, hasFinalNewline: boolean): string {
158
+ if (lines.length === 0) return ""
159
+ const separator = newlineSeparator(newline)
160
+ let text = lines.join(separator)
161
+ if (hasFinalNewline) text += separator
162
+ return text
163
+ }
164
+
165
+ function versionForBytes(data: Buffer): string {
166
+ return createHash("blake2s256").update(data).digest("hex").slice(0, 32)
167
+ }
168
+
169
+ async function pathExists(path: string): Promise<boolean> {
170
+ try {
171
+ await stat(path)
172
+ return true
173
+ } catch {
174
+ return false
175
+ }
176
+ }
177
+
178
+ export function computeLineHash(
179
+ lineNumber: number,
180
+ text: string,
181
+ hashLength = DEFAULT_HASH_LENGTH,
182
+ ): string {
183
+ if (hashLength < 2) throw new Error("hashLength must be >= 2")
184
+ const normalized = text.replace(/\r/g, "").replace(/[ \t]+$/u, "")
185
+ const material = /[\p{L}\p{N}]/u.test(normalized) ? normalized : `${lineNumber}\0${normalized}`
186
+ return createHash("blake2s256")
187
+ .update(material, DEFAULT_ENCODING)
188
+ .digest("hex")
189
+ .slice(0, hashLength)
190
+ }
191
+
192
+ export function renderLine(
193
+ lineNumber: number,
194
+ text: string,
195
+ hashLength = DEFAULT_HASH_LENGTH,
196
+ ): string {
197
+ return `${lineNumber}:${computeLineHash(lineNumber, text, hashLength)}|${text}`
198
+ }
199
+
200
+ export function renderLines(
201
+ lines: string[],
202
+ startLine = 1,
203
+ hashLength = DEFAULT_HASH_LENGTH,
204
+ ): string[] {
205
+ return lines.map((line, index) => renderLine(startLine + index, line, hashLength))
206
+ }
207
+
208
+ export function stripRenderPrefixes(lines: string[]): string[] {
209
+ const nonEmpty = lines.filter((line) => line !== "")
210
+ if (nonEmpty.length > 0 && nonEmpty.every((line) => PREFIX_RE.test(line))) {
211
+ return lines.map((line) => line.replace(PREFIX_RE, ""))
212
+ }
213
+ return [...lines]
214
+ }
215
+
216
+ function mismatchMessage(
217
+ path: string,
218
+ lines: string[],
219
+ hashLength: number,
220
+ mismatches: Array<{ line: number; expected: string; actual: string }>,
221
+ ): string {
222
+ const displayLines = new Set<number>()
223
+ for (const mismatch of mismatches) {
224
+ for (
225
+ let line = Math.max(1, mismatch.line - 2);
226
+ line <= Math.min(lines.length, mismatch.line + 2);
227
+ line += 1
228
+ ) {
229
+ displayLines.add(line)
230
+ }
231
+ }
232
+ const rendered: string[] = [
233
+ `Anchors are stale in ${path}. Re-read the file and use the updated rendered lines below.`,
234
+ ]
235
+ if (displayLines.size === 0) return rendered.join("\n")
236
+ rendered.push("")
237
+ const mismatchSet = new Set(mismatches.map((mismatch) => mismatch.line))
238
+ let previous = -1
239
+ for (const lineNumber of [...displayLines].sort((left, right) => left - right)) {
240
+ if (previous !== -1 && lineNumber > previous + 1) {
241
+ rendered.push(" ...")
242
+ }
243
+ const prefix = mismatchSet.has(lineNumber) ? ">>> " : " "
244
+ rendered.push(prefix + renderLine(lineNumber, lines[lineNumber - 1] ?? "", hashLength))
245
+ previous = lineNumber
246
+ }
247
+ return rendered.join("\n")
248
+ }
249
+
250
+ export class HashEditHarness {
251
+ #root: string
252
+ #resolvedRoot: string
253
+ #encoding: string
254
+ #hashLength: number
255
+
256
+ constructor(root = ".", options: { default_encoding?: string; hash_length?: number } = {}) {
257
+ this.#root = resolve(root)
258
+ this.#resolvedRoot = resolveExistingPath(this.#root)
259
+ this.#encoding = options.default_encoding ?? DEFAULT_ENCODING
260
+ this.#hashLength = options.hash_length ?? DEFAULT_HASH_LENGTH
261
+ }
262
+
263
+ resolvePath(path: string): string {
264
+ const absolute = resolve(this.#root, path)
265
+ const resolved = resolveExistingPath(absolute)
266
+ const rel = relative(this.#resolvedRoot, resolved)
267
+ if (rel.startsWith("..") || isAbsolute(rel)) {
268
+ throw new PathEscapeError(`${path} escapes root ${this.#root}`)
269
+ }
270
+ return resolved
271
+ }
272
+
273
+ async read(
274
+ path: string,
275
+ options: { start_line?: number; end_line?: number } = {},
276
+ ): Promise<ReadResult> {
277
+ const resolved = this.resolvePath(path)
278
+ const snapshot = await this.readSnapshot(resolved)
279
+ if (snapshot.lines.length === 0) {
280
+ return {
281
+ path: snapshot.path,
282
+ version: snapshot.version,
283
+ encoding: snapshot.encoding,
284
+ bom: snapshot.bom,
285
+ newline: snapshot.newline,
286
+ has_final_newline: snapshot.has_final_newline,
287
+ hash_length: this.#hashLength,
288
+ total_lines: 0,
289
+ start_line: 0,
290
+ end_line: 0,
291
+ lines: [],
292
+ }
293
+ }
294
+
295
+ const startLine = options.start_line ?? 1
296
+ const endLine = options.end_line ?? snapshot.lines.length
297
+ if (startLine < 1 || endLine < startLine || endLine > snapshot.lines.length) {
298
+ throw new InvalidOperationError(
299
+ `Invalid read window for ${snapshot.path}: start_line=${startLine}, end_line=${endLine}, total_lines=${snapshot.lines.length}`,
300
+ )
301
+ }
302
+ return {
303
+ path: snapshot.path,
304
+ version: snapshot.version,
305
+ encoding: snapshot.encoding,
306
+ bom: snapshot.bom,
307
+ newline: snapshot.newline,
308
+ has_final_newline: snapshot.has_final_newline,
309
+ hash_length: this.#hashLength,
310
+ total_lines: snapshot.lines.length,
311
+ start_line: startLine,
312
+ end_line: endLine,
313
+ lines: renderLines(snapshot.lines.slice(startLine - 1, endLine), startLine, this.#hashLength),
314
+ }
315
+ }
316
+
317
+ async edit(
318
+ path: string,
319
+ ops: EditOp | EditOp[],
320
+ options: { expected_version: string },
321
+ ): Promise<EditResult> {
322
+ const resolved = this.resolvePath(path)
323
+ const snapshot = await this.readSnapshot(resolved)
324
+ if (options.expected_version !== snapshot.version) {
325
+ throw new VersionConflictError(snapshot.path, options.expected_version, snapshot.version)
326
+ }
327
+
328
+ const operations = Array.isArray(ops) ? [...ops] : [ops]
329
+ if (operations.length === 0) {
330
+ throw new InvalidOperationError("edit() requires at least one operation")
331
+ }
332
+ const lines = [...snapshot.lines]
333
+ const mismatches: Array<{ line: number; expected: string; actual: string }> = []
334
+
335
+ const validateAnchor = (line: number, expectedHash: string) => {
336
+ if (line < 1 || line > lines.length) {
337
+ throw new InvalidOperationError(
338
+ `Line ${line} is out of range for ${snapshot.path} (total_lines=${lines.length})`,
339
+ )
340
+ }
341
+ const actual = computeLineHash(line, lines[line - 1] ?? "", this.#hashLength)
342
+ if (actual !== expectedHash) {
343
+ mismatches.push({ line, expected: expectedHash, actual })
344
+ }
345
+ }
346
+
347
+ for (const operation of operations) {
348
+ validateAnchor(operation.line, operation.hash)
349
+ if ("end_line" in operation && operation.end_line !== undefined) {
350
+ if (!operation.end_hash) {
351
+ throw new InvalidOperationError("Range edits require end_hash")
352
+ }
353
+ validateAnchor(operation.end_line, operation.end_hash)
354
+ if (operation.end_line < operation.line) {
355
+ throw new InvalidOperationError("end_line must be >= line")
356
+ }
357
+ }
358
+ }
359
+
360
+ if (mismatches.length > 0) {
361
+ throw new AnchorMismatchError(
362
+ mismatchMessage(snapshot.path, lines, this.#hashLength, mismatches),
363
+ mismatches,
364
+ )
365
+ }
366
+
367
+ const sortKey = (operation: EditOp): [number, number] => {
368
+ const lineNumber =
369
+ "end_line" in operation && operation.end_line !== undefined
370
+ ? operation.end_line
371
+ : operation.line
372
+ const precedence = { replace: 0, delete: 0, insert_after: 1, insert_before: 2 }[operation.op]
373
+ return [-lineNumber, precedence]
374
+ }
375
+
376
+ let firstChangedLine: number | null = null
377
+ for (const operation of [...operations].sort((left, right) => {
378
+ const [leftLine, leftPrec] = sortKey(left)
379
+ const [rightLine, rightPrec] = sortKey(right)
380
+ return leftLine - rightLine || leftPrec - rightPrec
381
+ })) {
382
+ if (operation.op === "replace") {
383
+ const replacement = stripRenderPrefixes([...operation.lines])
384
+ const endLine = operation.end_line ?? operation.line
385
+ const current = lines.slice(operation.line - 1, endLine)
386
+ if (
387
+ current.length === replacement.length &&
388
+ current.every((line, index) => line === replacement[index])
389
+ ) {
390
+ throw new InvalidOperationError(
391
+ `No changes made to ${snapshot.path}. Replacement for ${operation.line}:${operation.hash} is identical.`,
392
+ )
393
+ }
394
+ lines.splice(operation.line - 1, endLine - operation.line + 1, ...replacement)
395
+ firstChangedLine =
396
+ firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line)
397
+ } else if (operation.op === "delete") {
398
+ const endLine = operation.end_line ?? operation.line
399
+ lines.splice(operation.line - 1, endLine - operation.line + 1)
400
+ firstChangedLine =
401
+ firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line)
402
+ } else if (operation.op === "insert_before") {
403
+ const inserted = stripRenderPrefixes([...operation.lines])
404
+ const payload = inserted.length === 0 ? [""] : inserted
405
+ lines.splice(operation.line - 1, 0, ...payload)
406
+ firstChangedLine =
407
+ firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line)
408
+ } else {
409
+ const inserted = stripRenderPrefixes([...operation.lines])
410
+ const payload = inserted.length === 0 ? [""] : inserted
411
+ lines.splice(operation.line, 0, ...payload)
412
+ const insertionLine = operation.line + 1
413
+ firstChangedLine =
414
+ firstChangedLine === null ? insertionLine : Math.min(firstChangedLine, insertionLine)
415
+ }
416
+ }
417
+
418
+ const versionAfter = await this.writeSnapshot(resolved, {
419
+ lines,
420
+ bom: snapshot.bom,
421
+ newline: snapshot.newline,
422
+ has_final_newline: snapshot.has_final_newline,
423
+ })
424
+
425
+ return {
426
+ path: snapshot.path,
427
+ version_before: snapshot.version,
428
+ version_after: versionAfter,
429
+ applied: operations.length,
430
+ first_changed_line: firstChangedLine,
431
+ encoding: snapshot.encoding,
432
+ bom: snapshot.bom,
433
+ newline: snapshot.newline,
434
+ has_final_newline: snapshot.has_final_newline,
435
+ total_lines: lines.length,
436
+ }
437
+ }
438
+
439
+ async write(
440
+ path: string,
441
+ text: string,
442
+ options: { expected_version?: string } = {},
443
+ ): Promise<WriteResult> {
444
+ const resolved = this.resolvePath(path)
445
+ const created = !(await pathExists(resolved))
446
+ const input = detectNewlineStyle(text)
447
+ let bom = false
448
+ let newline = input.newline
449
+
450
+ let versionAfter: string
451
+ if (created) {
452
+ versionAfter = await this.writeSnapshot(resolved, {
453
+ lines: input.lines,
454
+ bom: false,
455
+ newline: input.newline,
456
+ has_final_newline: input.has_final_newline,
457
+ })
458
+ } else {
459
+ const snapshot = await this.readSnapshot(resolved)
460
+ if ((options.expected_version ?? null) !== snapshot.version) {
461
+ throw new VersionConflictError(
462
+ snapshot.path,
463
+ options.expected_version ?? null,
464
+ snapshot.version,
465
+ )
466
+ }
467
+ bom = snapshot.bom
468
+ newline = snapshot.newline === "none" ? input.newline : snapshot.newline
469
+ versionAfter = await this.writeSnapshot(resolved, {
470
+ lines: input.lines,
471
+ bom,
472
+ newline,
473
+ has_final_newline: input.has_final_newline,
474
+ })
475
+ }
476
+
477
+ return {
478
+ path: relative(this.#root, resolved),
479
+ version_after: versionAfter,
480
+ created,
481
+ encoding: this.#encoding,
482
+ bom,
483
+ newline,
484
+ has_final_newline: input.has_final_newline,
485
+ total_lines: input.lines.length,
486
+ }
487
+ }
488
+
489
+ private async readSnapshot(path: string): Promise<Snapshot> {
490
+ const raw = Buffer.from(await readFile(path))
491
+ const version = versionForBytes(raw)
492
+ const bom = raw.subarray(0, UTF8_BOM.length).equals(UTF8_BOM)
493
+ const payload = bom ? raw.subarray(UTF8_BOM.length) : raw
494
+ let text: string
495
+ try {
496
+ text = new TextDecoder(this.#encoding as Bun.Encoding, { fatal: true }).decode(payload)
497
+ } catch (error) {
498
+ throw new FileEncodingError(`${relative(this.#root, path)} is not valid ${this.#encoding}`, {
499
+ cause: error,
500
+ })
501
+ }
502
+ const parsed = detectNewlineStyle(text)
503
+ return {
504
+ path: relative(this.#root, path),
505
+ version,
506
+ encoding: this.#encoding,
507
+ bom,
508
+ newline: parsed.newline,
509
+ has_final_newline: parsed.has_final_newline,
510
+ lines: parsed.lines,
511
+ }
512
+ }
513
+
514
+ private async writeSnapshot(
515
+ path: string,
516
+ options: { lines: string[]; bom: boolean; newline: NewlineStyle; has_final_newline: boolean },
517
+ ): Promise<string> {
518
+ await mkdir(dirname(path), { recursive: true })
519
+ const text = joinText(options.lines, options.newline, options.has_final_newline)
520
+ let payload = Buffer.from(text, this.#encoding as BufferEncoding)
521
+ if (options.bom) {
522
+ payload = Buffer.concat([UTF8_BOM, payload])
523
+ }
524
+
525
+ const tempPath = `${path}.${randomUUID()}.tmp`
526
+ let mode: number | undefined
527
+ try {
528
+ mode = (await stat(path)).mode
529
+ } catch {}
530
+ try {
531
+ await writeFile(tempPath, payload)
532
+ if (mode !== undefined) {
533
+ await chmod(tempPath, mode)
534
+ }
535
+ await rename(tempPath, path)
536
+ } finally {
537
+ await rm(tempPath, { force: true })
538
+ }
539
+ return versionForBytes(payload)
540
+ }
541
+ }
542
+
543
+ function resolveExistingPath(path: string): string {
544
+ const missingSegments: string[] = []
545
+ let current = path
546
+ while (true) {
547
+ try {
548
+ const resolved = realpathSync.native(current)
549
+ const suffix = [...missingSegments].reverse()
550
+ return missingSegments.length === 0 ? resolved : joinPath(resolved, ...suffix)
551
+ } catch (error) {
552
+ const candidate = error as NodeJS.ErrnoException
553
+ if (candidate.code !== "ENOENT") {
554
+ throw candidate
555
+ }
556
+ const parent = dirname(current)
557
+ if (parent === current) {
558
+ return joinPath(current, ...[...missingSegments].reverse())
559
+ }
560
+ missingSegments.push(basename(current))
561
+ current = parent
562
+ }
563
+ }
564
+ }