@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jan Siml
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# hash-edit
|
|
2
|
+
|
|
3
|
+
Minimal AI-agent file read/edit/write tools with line-hash anchors for TypeScript.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Bun:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @svilupp/hash-edit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node.js:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @svilupp/hash-edit
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { HashEditHarness } from "@svilupp/hash-edit";
|
|
23
|
+
|
|
24
|
+
const h = new HashEditHarness("/path/to/project");
|
|
25
|
+
|
|
26
|
+
// 1. Read — returns rendered lines with hashes and a file version
|
|
27
|
+
const result = await h.read("src/app.ts");
|
|
28
|
+
// result.version === "4a3f..."
|
|
29
|
+
// result.lines === ["1:ab|import fs from 'fs'", "2:cd|", "3:ef|export function main() {}"]
|
|
30
|
+
|
|
31
|
+
// 2. Edit — anchored ops, verified against expected_version
|
|
32
|
+
await h.edit(
|
|
33
|
+
"src/app.ts",
|
|
34
|
+
[
|
|
35
|
+
{ op: "replace", line: 3, hash: "ef", lines: ["export function main() {", " return 0;", "}"] },
|
|
36
|
+
],
|
|
37
|
+
{ expected_version: result.version },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// 3. Write — safely overwrite an existing file (version required) or create a new one
|
|
41
|
+
const current = await h.read("src/app.ts");
|
|
42
|
+
await h.write("src/app.ts", "// new content\n", { expected_version: current.version });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Windowed reads
|
|
46
|
+
|
|
47
|
+
For large files, read only the lines you need. This can save 55–95% of tokens sent to the model:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// 1 000-line file — read only lines 400–450
|
|
51
|
+
const window = await h.read("src/big.ts", { start_line: 400, end_line: 450 });
|
|
52
|
+
// window.lines.length === 51
|
|
53
|
+
// window.total_lines === 1000 ← tells the model the full file size
|
|
54
|
+
// window.version ← same digest as a full-file read; valid for edit()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The returned `version` is a full-file blake2s digest, so it can be passed directly to `edit()` without re-reading the whole file.
|
|
58
|
+
|
|
59
|
+
## Error classes
|
|
60
|
+
|
|
61
|
+
| Error | When |
|
|
62
|
+
|-------|------|
|
|
63
|
+
| `VersionConflictError` | File changed since last read |
|
|
64
|
+
| `AnchorMismatchError` | Hash doesn't match current line; includes updated rendered context |
|
|
65
|
+
| `InvalidOperationError` | Bad op payload (e.g. missing `hash`, no-op replace, out-of-range line) |
|
|
66
|
+
| `PathEscapeError` | Path escapes the configured root directory |
|
|
67
|
+
| `MixedNewlineError` | File uses more than one newline style |
|
|
68
|
+
| `FileEncodingError` | File cannot be decoded with the configured encoding (UTF-8 by default) |
|
|
69
|
+
|
|
70
|
+
## Conditional exports
|
|
71
|
+
|
|
72
|
+
Bun users get direct TypeScript source via the `bun` conditional export — no build step needed. Node.js consumers get pre-built ESM (`dist/index.js`) and CJS (`dist/index.cjs`) bundles.
|
|
73
|
+
|
|
74
|
+
## See also
|
|
75
|
+
|
|
76
|
+
[Root README](https://github.com/jansiml/hash-edit#readme) — design rationale, Python package, shared fixture corpus, and benchmark results.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
AnchorMismatchError: () => AnchorMismatchError,
|
|
24
|
+
FileEncodingError: () => FileEncodingError,
|
|
25
|
+
HashEditError: () => HashEditError,
|
|
26
|
+
HashEditHarness: () => HashEditHarness,
|
|
27
|
+
InvalidOperationError: () => InvalidOperationError,
|
|
28
|
+
MixedNewlineError: () => MixedNewlineError,
|
|
29
|
+
PathEscapeError: () => PathEscapeError,
|
|
30
|
+
VersionConflictError: () => VersionConflictError,
|
|
31
|
+
computeLineHash: () => computeLineHash,
|
|
32
|
+
renderLine: () => renderLine,
|
|
33
|
+
renderLines: () => renderLines,
|
|
34
|
+
stripRenderPrefixes: () => stripRenderPrefixes
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(index_exports);
|
|
37
|
+
var import_node_crypto = require("crypto");
|
|
38
|
+
var import_node_fs = require("fs");
|
|
39
|
+
var import_promises = require("fs/promises");
|
|
40
|
+
var import_node_path = require("path");
|
|
41
|
+
var DEFAULT_ENCODING = "utf-8";
|
|
42
|
+
var DEFAULT_HASH_LENGTH = 2;
|
|
43
|
+
var UTF8_BOM = Buffer.from([239, 187, 191]);
|
|
44
|
+
var PREFIX_RE = /^\s*\d+:[0-9a-f]{2,}\|/;
|
|
45
|
+
var HashEditError = class extends Error {
|
|
46
|
+
};
|
|
47
|
+
var PathEscapeError = class extends HashEditError {
|
|
48
|
+
};
|
|
49
|
+
var MixedNewlineError = class extends HashEditError {
|
|
50
|
+
};
|
|
51
|
+
var FileEncodingError = class extends HashEditError {
|
|
52
|
+
};
|
|
53
|
+
var InvalidOperationError = class extends HashEditError {
|
|
54
|
+
};
|
|
55
|
+
var VersionConflictError = class extends HashEditError {
|
|
56
|
+
path;
|
|
57
|
+
expected;
|
|
58
|
+
actual;
|
|
59
|
+
constructor(path, expected, actual) {
|
|
60
|
+
const message = expected === null ? `${path} already exists. read() first and pass expected_version to overwrite safely.` : `Version conflict for ${path}: expected ${expected}, actual ${actual}. Re-read the file first.`;
|
|
61
|
+
super(message);
|
|
62
|
+
this.path = path;
|
|
63
|
+
this.expected = expected;
|
|
64
|
+
this.actual = actual;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
var AnchorMismatchError = class extends HashEditError {
|
|
68
|
+
mismatches;
|
|
69
|
+
constructor(message, mismatches) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.mismatches = mismatches;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
function newlineSeparator(style) {
|
|
75
|
+
if (style === "lf") return "\n";
|
|
76
|
+
if (style === "crlf") return "\r\n";
|
|
77
|
+
if (style === "cr") return "\r";
|
|
78
|
+
return "\n";
|
|
79
|
+
}
|
|
80
|
+
function detectNewlineStyle(text) {
|
|
81
|
+
const matches = text.match(/\r\n|\n|\r/g) ?? [];
|
|
82
|
+
const unique = new Set(matches);
|
|
83
|
+
if (unique.size > 1) {
|
|
84
|
+
throw new MixedNewlineError(`Mixed newline styles detected: ${Array.from(unique).join(", ")}`);
|
|
85
|
+
}
|
|
86
|
+
if (matches.length === 0) {
|
|
87
|
+
return { newline: "none", has_final_newline: false, lines: text === "" ? [] : [text] };
|
|
88
|
+
}
|
|
89
|
+
const separator = matches[0] ?? "\n";
|
|
90
|
+
const newline = separator === "\n" ? "lf" : separator === "\r\n" ? "crlf" : "cr";
|
|
91
|
+
const has_final_newline = text.endsWith(separator);
|
|
92
|
+
const split = text.split(separator);
|
|
93
|
+
const lines = has_final_newline ? split.slice(0, -1) : split;
|
|
94
|
+
return { newline, has_final_newline, lines };
|
|
95
|
+
}
|
|
96
|
+
function joinText(lines, newline, hasFinalNewline) {
|
|
97
|
+
if (lines.length === 0) return "";
|
|
98
|
+
const separator = newlineSeparator(newline);
|
|
99
|
+
let text = lines.join(separator);
|
|
100
|
+
if (hasFinalNewline) text += separator;
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
function versionForBytes(data) {
|
|
104
|
+
return (0, import_node_crypto.createHash)("blake2s256").update(data).digest("hex").slice(0, 32);
|
|
105
|
+
}
|
|
106
|
+
async function pathExists(path) {
|
|
107
|
+
try {
|
|
108
|
+
await (0, import_promises.stat)(path);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function computeLineHash(lineNumber, text, hashLength = DEFAULT_HASH_LENGTH) {
|
|
115
|
+
if (hashLength < 2) throw new Error("hashLength must be >= 2");
|
|
116
|
+
const normalized = text.replace(/\r/g, "").replace(/[ \t]+$/u, "");
|
|
117
|
+
const material = /[\p{L}\p{N}]/u.test(normalized) ? normalized : `${lineNumber}\0${normalized}`;
|
|
118
|
+
return (0, import_node_crypto.createHash)("blake2s256").update(material, DEFAULT_ENCODING).digest("hex").slice(0, hashLength);
|
|
119
|
+
}
|
|
120
|
+
function renderLine(lineNumber, text, hashLength = DEFAULT_HASH_LENGTH) {
|
|
121
|
+
return `${lineNumber}:${computeLineHash(lineNumber, text, hashLength)}|${text}`;
|
|
122
|
+
}
|
|
123
|
+
function renderLines(lines, startLine = 1, hashLength = DEFAULT_HASH_LENGTH) {
|
|
124
|
+
return lines.map((line, index) => renderLine(startLine + index, line, hashLength));
|
|
125
|
+
}
|
|
126
|
+
function stripRenderPrefixes(lines) {
|
|
127
|
+
const nonEmpty = lines.filter((line) => line !== "");
|
|
128
|
+
if (nonEmpty.length > 0 && nonEmpty.every((line) => PREFIX_RE.test(line))) {
|
|
129
|
+
return lines.map((line) => line.replace(PREFIX_RE, ""));
|
|
130
|
+
}
|
|
131
|
+
return [...lines];
|
|
132
|
+
}
|
|
133
|
+
function mismatchMessage(path, lines, hashLength, mismatches) {
|
|
134
|
+
const displayLines = /* @__PURE__ */ new Set();
|
|
135
|
+
for (const mismatch of mismatches) {
|
|
136
|
+
for (let line = Math.max(1, mismatch.line - 2); line <= Math.min(lines.length, mismatch.line + 2); line += 1) {
|
|
137
|
+
displayLines.add(line);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const rendered = [
|
|
141
|
+
`Anchors are stale in ${path}. Re-read the file and use the updated rendered lines below.`
|
|
142
|
+
];
|
|
143
|
+
if (displayLines.size === 0) return rendered.join("\n");
|
|
144
|
+
rendered.push("");
|
|
145
|
+
const mismatchSet = new Set(mismatches.map((mismatch) => mismatch.line));
|
|
146
|
+
let previous = -1;
|
|
147
|
+
for (const lineNumber of [...displayLines].sort((left, right) => left - right)) {
|
|
148
|
+
if (previous !== -1 && lineNumber > previous + 1) {
|
|
149
|
+
rendered.push(" ...");
|
|
150
|
+
}
|
|
151
|
+
const prefix = mismatchSet.has(lineNumber) ? ">>> " : " ";
|
|
152
|
+
rendered.push(prefix + renderLine(lineNumber, lines[lineNumber - 1] ?? "", hashLength));
|
|
153
|
+
previous = lineNumber;
|
|
154
|
+
}
|
|
155
|
+
return rendered.join("\n");
|
|
156
|
+
}
|
|
157
|
+
var HashEditHarness = class {
|
|
158
|
+
#root;
|
|
159
|
+
#resolvedRoot;
|
|
160
|
+
#encoding;
|
|
161
|
+
#hashLength;
|
|
162
|
+
constructor(root = ".", options = {}) {
|
|
163
|
+
this.#root = (0, import_node_path.resolve)(root);
|
|
164
|
+
this.#resolvedRoot = resolveExistingPath(this.#root);
|
|
165
|
+
this.#encoding = options.default_encoding ?? DEFAULT_ENCODING;
|
|
166
|
+
this.#hashLength = options.hash_length ?? DEFAULT_HASH_LENGTH;
|
|
167
|
+
}
|
|
168
|
+
resolvePath(path) {
|
|
169
|
+
const absolute = (0, import_node_path.resolve)(this.#root, path);
|
|
170
|
+
const resolved = resolveExistingPath(absolute);
|
|
171
|
+
const rel = (0, import_node_path.relative)(this.#resolvedRoot, resolved);
|
|
172
|
+
if (rel.startsWith("..") || (0, import_node_path.isAbsolute)(rel)) {
|
|
173
|
+
throw new PathEscapeError(`${path} escapes root ${this.#root}`);
|
|
174
|
+
}
|
|
175
|
+
return resolved;
|
|
176
|
+
}
|
|
177
|
+
async read(path, options = {}) {
|
|
178
|
+
const resolved = this.resolvePath(path);
|
|
179
|
+
const snapshot = await this.readSnapshot(resolved);
|
|
180
|
+
if (snapshot.lines.length === 0) {
|
|
181
|
+
return {
|
|
182
|
+
path: snapshot.path,
|
|
183
|
+
version: snapshot.version,
|
|
184
|
+
encoding: snapshot.encoding,
|
|
185
|
+
bom: snapshot.bom,
|
|
186
|
+
newline: snapshot.newline,
|
|
187
|
+
has_final_newline: snapshot.has_final_newline,
|
|
188
|
+
hash_length: this.#hashLength,
|
|
189
|
+
total_lines: 0,
|
|
190
|
+
start_line: 0,
|
|
191
|
+
end_line: 0,
|
|
192
|
+
lines: []
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const startLine = options.start_line ?? 1;
|
|
196
|
+
const endLine = options.end_line ?? snapshot.lines.length;
|
|
197
|
+
if (startLine < 1 || endLine < startLine || endLine > snapshot.lines.length) {
|
|
198
|
+
throw new InvalidOperationError(
|
|
199
|
+
`Invalid read window for ${snapshot.path}: start_line=${startLine}, end_line=${endLine}, total_lines=${snapshot.lines.length}`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
path: snapshot.path,
|
|
204
|
+
version: snapshot.version,
|
|
205
|
+
encoding: snapshot.encoding,
|
|
206
|
+
bom: snapshot.bom,
|
|
207
|
+
newline: snapshot.newline,
|
|
208
|
+
has_final_newline: snapshot.has_final_newline,
|
|
209
|
+
hash_length: this.#hashLength,
|
|
210
|
+
total_lines: snapshot.lines.length,
|
|
211
|
+
start_line: startLine,
|
|
212
|
+
end_line: endLine,
|
|
213
|
+
lines: renderLines(snapshot.lines.slice(startLine - 1, endLine), startLine, this.#hashLength)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async edit(path, ops, options) {
|
|
217
|
+
const resolved = this.resolvePath(path);
|
|
218
|
+
const snapshot = await this.readSnapshot(resolved);
|
|
219
|
+
if (options.expected_version !== snapshot.version) {
|
|
220
|
+
throw new VersionConflictError(snapshot.path, options.expected_version, snapshot.version);
|
|
221
|
+
}
|
|
222
|
+
const operations = Array.isArray(ops) ? [...ops] : [ops];
|
|
223
|
+
if (operations.length === 0) {
|
|
224
|
+
throw new InvalidOperationError("edit() requires at least one operation");
|
|
225
|
+
}
|
|
226
|
+
const lines = [...snapshot.lines];
|
|
227
|
+
const mismatches = [];
|
|
228
|
+
const validateAnchor = (line, expectedHash) => {
|
|
229
|
+
if (line < 1 || line > lines.length) {
|
|
230
|
+
throw new InvalidOperationError(
|
|
231
|
+
`Line ${line} is out of range for ${snapshot.path} (total_lines=${lines.length})`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const actual = computeLineHash(line, lines[line - 1] ?? "", this.#hashLength);
|
|
235
|
+
if (actual !== expectedHash) {
|
|
236
|
+
mismatches.push({ line, expected: expectedHash, actual });
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
for (const operation of operations) {
|
|
240
|
+
validateAnchor(operation.line, operation.hash);
|
|
241
|
+
if ("end_line" in operation && operation.end_line !== void 0) {
|
|
242
|
+
if (!operation.end_hash) {
|
|
243
|
+
throw new InvalidOperationError("Range edits require end_hash");
|
|
244
|
+
}
|
|
245
|
+
validateAnchor(operation.end_line, operation.end_hash);
|
|
246
|
+
if (operation.end_line < operation.line) {
|
|
247
|
+
throw new InvalidOperationError("end_line must be >= line");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (mismatches.length > 0) {
|
|
252
|
+
throw new AnchorMismatchError(
|
|
253
|
+
mismatchMessage(snapshot.path, lines, this.#hashLength, mismatches),
|
|
254
|
+
mismatches
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const sortKey = (operation) => {
|
|
258
|
+
const lineNumber = "end_line" in operation && operation.end_line !== void 0 ? operation.end_line : operation.line;
|
|
259
|
+
const precedence = { replace: 0, delete: 0, insert_after: 1, insert_before: 2 }[operation.op];
|
|
260
|
+
return [-lineNumber, precedence];
|
|
261
|
+
};
|
|
262
|
+
let firstChangedLine = null;
|
|
263
|
+
for (const operation of [...operations].sort((left, right) => {
|
|
264
|
+
const [leftLine, leftPrec] = sortKey(left);
|
|
265
|
+
const [rightLine, rightPrec] = sortKey(right);
|
|
266
|
+
return leftLine - rightLine || leftPrec - rightPrec;
|
|
267
|
+
})) {
|
|
268
|
+
if (operation.op === "replace") {
|
|
269
|
+
const replacement = stripRenderPrefixes([...operation.lines]);
|
|
270
|
+
const endLine = operation.end_line ?? operation.line;
|
|
271
|
+
const current = lines.slice(operation.line - 1, endLine);
|
|
272
|
+
if (current.length === replacement.length && current.every((line, index) => line === replacement[index])) {
|
|
273
|
+
throw new InvalidOperationError(
|
|
274
|
+
`No changes made to ${snapshot.path}. Replacement for ${operation.line}:${operation.hash} is identical.`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
lines.splice(operation.line - 1, endLine - operation.line + 1, ...replacement);
|
|
278
|
+
firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
|
|
279
|
+
} else if (operation.op === "delete") {
|
|
280
|
+
const endLine = operation.end_line ?? operation.line;
|
|
281
|
+
lines.splice(operation.line - 1, endLine - operation.line + 1);
|
|
282
|
+
firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
|
|
283
|
+
} else if (operation.op === "insert_before") {
|
|
284
|
+
const inserted = stripRenderPrefixes([...operation.lines]);
|
|
285
|
+
const payload = inserted.length === 0 ? [""] : inserted;
|
|
286
|
+
lines.splice(operation.line - 1, 0, ...payload);
|
|
287
|
+
firstChangedLine = firstChangedLine === null ? operation.line : Math.min(firstChangedLine, operation.line);
|
|
288
|
+
} else {
|
|
289
|
+
const inserted = stripRenderPrefixes([...operation.lines]);
|
|
290
|
+
const payload = inserted.length === 0 ? [""] : inserted;
|
|
291
|
+
lines.splice(operation.line, 0, ...payload);
|
|
292
|
+
const insertionLine = operation.line + 1;
|
|
293
|
+
firstChangedLine = firstChangedLine === null ? insertionLine : Math.min(firstChangedLine, insertionLine);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const versionAfter = await this.writeSnapshot(resolved, {
|
|
297
|
+
lines,
|
|
298
|
+
bom: snapshot.bom,
|
|
299
|
+
newline: snapshot.newline,
|
|
300
|
+
has_final_newline: snapshot.has_final_newline
|
|
301
|
+
});
|
|
302
|
+
return {
|
|
303
|
+
path: snapshot.path,
|
|
304
|
+
version_before: snapshot.version,
|
|
305
|
+
version_after: versionAfter,
|
|
306
|
+
applied: operations.length,
|
|
307
|
+
first_changed_line: firstChangedLine,
|
|
308
|
+
encoding: snapshot.encoding,
|
|
309
|
+
bom: snapshot.bom,
|
|
310
|
+
newline: snapshot.newline,
|
|
311
|
+
has_final_newline: snapshot.has_final_newline,
|
|
312
|
+
total_lines: lines.length
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
async write(path, text, options = {}) {
|
|
316
|
+
const resolved = this.resolvePath(path);
|
|
317
|
+
const created = !await pathExists(resolved);
|
|
318
|
+
const input = detectNewlineStyle(text);
|
|
319
|
+
let bom = false;
|
|
320
|
+
let newline = input.newline;
|
|
321
|
+
let versionAfter;
|
|
322
|
+
if (created) {
|
|
323
|
+
versionAfter = await this.writeSnapshot(resolved, {
|
|
324
|
+
lines: input.lines,
|
|
325
|
+
bom: false,
|
|
326
|
+
newline: input.newline,
|
|
327
|
+
has_final_newline: input.has_final_newline
|
|
328
|
+
});
|
|
329
|
+
} else {
|
|
330
|
+
const snapshot = await this.readSnapshot(resolved);
|
|
331
|
+
if ((options.expected_version ?? null) !== snapshot.version) {
|
|
332
|
+
throw new VersionConflictError(
|
|
333
|
+
snapshot.path,
|
|
334
|
+
options.expected_version ?? null,
|
|
335
|
+
snapshot.version
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
bom = snapshot.bom;
|
|
339
|
+
newline = snapshot.newline === "none" ? input.newline : snapshot.newline;
|
|
340
|
+
versionAfter = await this.writeSnapshot(resolved, {
|
|
341
|
+
lines: input.lines,
|
|
342
|
+
bom,
|
|
343
|
+
newline,
|
|
344
|
+
has_final_newline: input.has_final_newline
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
path: (0, import_node_path.relative)(this.#root, resolved),
|
|
349
|
+
version_after: versionAfter,
|
|
350
|
+
created,
|
|
351
|
+
encoding: this.#encoding,
|
|
352
|
+
bom,
|
|
353
|
+
newline,
|
|
354
|
+
has_final_newline: input.has_final_newline,
|
|
355
|
+
total_lines: input.lines.length
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
async readSnapshot(path) {
|
|
359
|
+
const raw = Buffer.from(await (0, import_promises.readFile)(path));
|
|
360
|
+
const version = versionForBytes(raw);
|
|
361
|
+
const bom = raw.subarray(0, UTF8_BOM.length).equals(UTF8_BOM);
|
|
362
|
+
const payload = bom ? raw.subarray(UTF8_BOM.length) : raw;
|
|
363
|
+
let text;
|
|
364
|
+
try {
|
|
365
|
+
text = new TextDecoder(this.#encoding, { fatal: true }).decode(payload);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
throw new FileEncodingError(`${(0, import_node_path.relative)(this.#root, path)} is not valid ${this.#encoding}`, {
|
|
368
|
+
cause: error
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const parsed = detectNewlineStyle(text);
|
|
372
|
+
return {
|
|
373
|
+
path: (0, import_node_path.relative)(this.#root, path),
|
|
374
|
+
version,
|
|
375
|
+
encoding: this.#encoding,
|
|
376
|
+
bom,
|
|
377
|
+
newline: parsed.newline,
|
|
378
|
+
has_final_newline: parsed.has_final_newline,
|
|
379
|
+
lines: parsed.lines
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
async writeSnapshot(path, options) {
|
|
383
|
+
await (0, import_promises.mkdir)((0, import_node_path.dirname)(path), { recursive: true });
|
|
384
|
+
const text = joinText(options.lines, options.newline, options.has_final_newline);
|
|
385
|
+
let payload = Buffer.from(text, this.#encoding);
|
|
386
|
+
if (options.bom) {
|
|
387
|
+
payload = Buffer.concat([UTF8_BOM, payload]);
|
|
388
|
+
}
|
|
389
|
+
const tempPath = `${path}.${(0, import_node_crypto.randomUUID)()}.tmp`;
|
|
390
|
+
let mode;
|
|
391
|
+
try {
|
|
392
|
+
mode = (await (0, import_promises.stat)(path)).mode;
|
|
393
|
+
} catch {
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
await (0, import_promises.writeFile)(tempPath, payload);
|
|
397
|
+
if (mode !== void 0) {
|
|
398
|
+
await (0, import_promises.chmod)(tempPath, mode);
|
|
399
|
+
}
|
|
400
|
+
await (0, import_promises.rename)(tempPath, path);
|
|
401
|
+
} finally {
|
|
402
|
+
await (0, import_promises.rm)(tempPath, { force: true });
|
|
403
|
+
}
|
|
404
|
+
return versionForBytes(payload);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
function resolveExistingPath(path) {
|
|
408
|
+
const missingSegments = [];
|
|
409
|
+
let current = path;
|
|
410
|
+
while (true) {
|
|
411
|
+
try {
|
|
412
|
+
const resolved = import_node_fs.realpathSync.native(current);
|
|
413
|
+
const suffix = [...missingSegments].reverse();
|
|
414
|
+
return missingSegments.length === 0 ? resolved : (0, import_node_path.join)(resolved, ...suffix);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const candidate = error;
|
|
417
|
+
if (candidate.code !== "ENOENT") {
|
|
418
|
+
throw candidate;
|
|
419
|
+
}
|
|
420
|
+
const parent = (0, import_node_path.dirname)(current);
|
|
421
|
+
if (parent === current) {
|
|
422
|
+
return (0, import_node_path.join)(current, ...[...missingSegments].reverse());
|
|
423
|
+
}
|
|
424
|
+
missingSegments.push((0, import_node_path.basename)(current));
|
|
425
|
+
current = parent;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
430
|
+
0 && (module.exports = {
|
|
431
|
+
AnchorMismatchError,
|
|
432
|
+
FileEncodingError,
|
|
433
|
+
HashEditError,
|
|
434
|
+
HashEditHarness,
|
|
435
|
+
InvalidOperationError,
|
|
436
|
+
MixedNewlineError,
|
|
437
|
+
PathEscapeError,
|
|
438
|
+
VersionConflictError,
|
|
439
|
+
computeLineHash,
|
|
440
|
+
renderLine,
|
|
441
|
+
renderLines,
|
|
442
|
+
stripRenderPrefixes
|
|
443
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
type NewlineStyle = "lf" | "crlf" | "cr" | "none";
|
|
2
|
+
type ReplaceOp = {
|
|
3
|
+
op: "replace";
|
|
4
|
+
line: number;
|
|
5
|
+
hash: string;
|
|
6
|
+
lines: string[];
|
|
7
|
+
end_line?: number;
|
|
8
|
+
end_hash?: string;
|
|
9
|
+
};
|
|
10
|
+
type InsertBeforeOp = {
|
|
11
|
+
op: "insert_before";
|
|
12
|
+
line: number;
|
|
13
|
+
hash: string;
|
|
14
|
+
lines: string[];
|
|
15
|
+
};
|
|
16
|
+
type InsertAfterOp = {
|
|
17
|
+
op: "insert_after";
|
|
18
|
+
line: number;
|
|
19
|
+
hash: string;
|
|
20
|
+
lines: string[];
|
|
21
|
+
};
|
|
22
|
+
type DeleteOp = {
|
|
23
|
+
op: "delete";
|
|
24
|
+
line: number;
|
|
25
|
+
hash: string;
|
|
26
|
+
end_line?: number;
|
|
27
|
+
end_hash?: string;
|
|
28
|
+
};
|
|
29
|
+
type EditOp = ReplaceOp | InsertBeforeOp | InsertAfterOp | DeleteOp;
|
|
30
|
+
type ReadResult = {
|
|
31
|
+
path: string;
|
|
32
|
+
version: string;
|
|
33
|
+
encoding: string;
|
|
34
|
+
bom: boolean;
|
|
35
|
+
newline: NewlineStyle;
|
|
36
|
+
has_final_newline: boolean;
|
|
37
|
+
hash_length: number;
|
|
38
|
+
total_lines: number;
|
|
39
|
+
start_line: number;
|
|
40
|
+
end_line: number;
|
|
41
|
+
lines: string[];
|
|
42
|
+
};
|
|
43
|
+
type EditResult = {
|
|
44
|
+
path: string;
|
|
45
|
+
version_before: string;
|
|
46
|
+
version_after: string;
|
|
47
|
+
applied: number;
|
|
48
|
+
first_changed_line: number | null;
|
|
49
|
+
encoding: string;
|
|
50
|
+
bom: boolean;
|
|
51
|
+
newline: NewlineStyle;
|
|
52
|
+
has_final_newline: boolean;
|
|
53
|
+
total_lines: number;
|
|
54
|
+
};
|
|
55
|
+
type WriteResult = {
|
|
56
|
+
path: string;
|
|
57
|
+
version_after: string;
|
|
58
|
+
created: boolean;
|
|
59
|
+
encoding: string;
|
|
60
|
+
bom: boolean;
|
|
61
|
+
newline: NewlineStyle;
|
|
62
|
+
has_final_newline: boolean;
|
|
63
|
+
total_lines: number;
|
|
64
|
+
};
|
|
65
|
+
declare class HashEditError extends Error {
|
|
66
|
+
}
|
|
67
|
+
declare class PathEscapeError extends HashEditError {
|
|
68
|
+
}
|
|
69
|
+
declare class MixedNewlineError extends HashEditError {
|
|
70
|
+
}
|
|
71
|
+
declare class FileEncodingError extends HashEditError {
|
|
72
|
+
}
|
|
73
|
+
declare class InvalidOperationError extends HashEditError {
|
|
74
|
+
}
|
|
75
|
+
declare class VersionConflictError extends HashEditError {
|
|
76
|
+
path: string;
|
|
77
|
+
expected: string | null;
|
|
78
|
+
actual: string;
|
|
79
|
+
constructor(path: string, expected: string | null, actual: string);
|
|
80
|
+
}
|
|
81
|
+
declare class AnchorMismatchError extends HashEditError {
|
|
82
|
+
mismatches: Array<{
|
|
83
|
+
line: number;
|
|
84
|
+
expected: string;
|
|
85
|
+
actual: string;
|
|
86
|
+
}>;
|
|
87
|
+
constructor(message: string, mismatches: Array<{
|
|
88
|
+
line: number;
|
|
89
|
+
expected: string;
|
|
90
|
+
actual: string;
|
|
91
|
+
}>);
|
|
92
|
+
}
|
|
93
|
+
declare function computeLineHash(lineNumber: number, text: string, hashLength?: number): string;
|
|
94
|
+
declare function renderLine(lineNumber: number, text: string, hashLength?: number): string;
|
|
95
|
+
declare function renderLines(lines: string[], startLine?: number, hashLength?: number): string[];
|
|
96
|
+
declare function stripRenderPrefixes(lines: string[]): string[];
|
|
97
|
+
declare class HashEditHarness {
|
|
98
|
+
#private;
|
|
99
|
+
constructor(root?: string, options?: {
|
|
100
|
+
default_encoding?: string;
|
|
101
|
+
hash_length?: number;
|
|
102
|
+
});
|
|
103
|
+
resolvePath(path: string): string;
|
|
104
|
+
read(path: string, options?: {
|
|
105
|
+
start_line?: number;
|
|
106
|
+
end_line?: number;
|
|
107
|
+
}): Promise<ReadResult>;
|
|
108
|
+
edit(path: string, ops: EditOp | EditOp[], options: {
|
|
109
|
+
expected_version: string;
|
|
110
|
+
}): Promise<EditResult>;
|
|
111
|
+
write(path: string, text: string, options?: {
|
|
112
|
+
expected_version?: string;
|
|
113
|
+
}): Promise<WriteResult>;
|
|
114
|
+
private readSnapshot;
|
|
115
|
+
private writeSnapshot;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { AnchorMismatchError, type DeleteOp, type EditOp, type EditResult, FileEncodingError, HashEditError, HashEditHarness, type InsertAfterOp, type InsertBeforeOp, InvalidOperationError, MixedNewlineError, type NewlineStyle, PathEscapeError, type ReadResult, type ReplaceOp, VersionConflictError, type WriteResult, computeLineHash, renderLine, renderLines, stripRenderPrefixes };
|