@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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/index.cjs +443 -0
- package/dist/index.d.cts +118 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +407 -0
- package/package.json +37 -0
- package/src/index.ts +564 -0
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
|
+
}
|