@mammothb/pi-hashline 0.2.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 +18 -0
- package/index.ts +36 -0
- package/package.json +27 -0
- package/src/apply.ts +255 -0
- package/src/edit.ts +313 -0
- package/src/format.ts +132 -0
- package/src/grep.ts +451 -0
- package/src/input.ts +232 -0
- package/src/messages.ts +127 -0
- package/src/normalize.ts +44 -0
- package/src/parser.ts +415 -0
- package/src/prompt.md +110 -0
- package/src/prompt.ts +59 -0
- package/src/read.ts +239 -0
- package/src/recovery.ts +141 -0
- package/src/snapshots.ts +166 -0
- package/src/tokenizer.ts +394 -0
- package/src/types.ts +109 -0
- package/src/write.ts +120 -0
package/src/input.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level patch parser.
|
|
3
|
+
*
|
|
4
|
+
* Splits authored hashline input into {@link PatchSection}s, each rooted at a
|
|
5
|
+
* `¶PATH#HASH` header, and exposes a {@link Patch} class that gives lazy
|
|
6
|
+
* access to the parsed edits per section.
|
|
7
|
+
*
|
|
8
|
+
* The splitter is purely lexical — it doesn't know whether a section's path
|
|
9
|
+
* actually exists. That's the patcher's job.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { parsePatch } from "./parser";
|
|
15
|
+
import { Tokenizer } from "./tokenizer";
|
|
16
|
+
import type { Edit, SplitOptions } from "./types";
|
|
17
|
+
|
|
18
|
+
// ─── Header parsing ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const TOKENIZER = new Tokenizer();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Split raw input text into per-section structures keyed by `¶PATH#HASH` headers.
|
|
24
|
+
* Each section collects all non-header lines until the next header.
|
|
25
|
+
*/
|
|
26
|
+
function splitIntoSections(
|
|
27
|
+
input: string,
|
|
28
|
+
options: SplitOptions = {},
|
|
29
|
+
): RawSection[] {
|
|
30
|
+
const lines = input.split("\n");
|
|
31
|
+
const sections: RawSection[] = [];
|
|
32
|
+
let current: RawSection | undefined;
|
|
33
|
+
|
|
34
|
+
for (const rawLine of lines) {
|
|
35
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
36
|
+
const trimmed = line.trimEnd();
|
|
37
|
+
|
|
38
|
+
// Check for section header
|
|
39
|
+
if (trimmed.startsWith("¶")) {
|
|
40
|
+
const token = TOKENIZER.tokenize(line);
|
|
41
|
+
if (token.kind === "header") {
|
|
42
|
+
// Flush previous section
|
|
43
|
+
if (
|
|
44
|
+
current &&
|
|
45
|
+
current.diffLines.length > 0 &&
|
|
46
|
+
current.diffLines.some((l) => l.trim().length > 0)
|
|
47
|
+
) {
|
|
48
|
+
sections.push({
|
|
49
|
+
path: current.path,
|
|
50
|
+
fileHash: current.fileHash,
|
|
51
|
+
diffLines: current.diffLines,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Normalize path: make absolute paths relative to cwd
|
|
56
|
+
let sectionPath = token.path;
|
|
57
|
+
if (options.cwd && path.isAbsolute(sectionPath)) {
|
|
58
|
+
const rel = path.relative(
|
|
59
|
+
path.resolve(options.cwd),
|
|
60
|
+
path.resolve(sectionPath),
|
|
61
|
+
);
|
|
62
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
63
|
+
sectionPath = rel || ".";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
current = {
|
|
68
|
+
path: sectionPath,
|
|
69
|
+
fileHash: token.fileHash,
|
|
70
|
+
diffLines: [],
|
|
71
|
+
};
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Accumulate line in current section
|
|
77
|
+
if (current) {
|
|
78
|
+
current.diffLines.push(line);
|
|
79
|
+
}
|
|
80
|
+
// Lines before the first header are silently dropped
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Flush final section
|
|
84
|
+
if (
|
|
85
|
+
current &&
|
|
86
|
+
current.diffLines.length > 0 &&
|
|
87
|
+
current.diffLines.some((l) => l.trim().length > 0)
|
|
88
|
+
) {
|
|
89
|
+
sections.push({
|
|
90
|
+
path: current.path,
|
|
91
|
+
fileHash: current.fileHash,
|
|
92
|
+
diffLines: current.diffLines,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return sections;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface RawSection {
|
|
100
|
+
path: string;
|
|
101
|
+
fileHash?: string;
|
|
102
|
+
diffLines: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── PatchSection ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One section in a parsed {@link Patch}: a target file plus the lazily-
|
|
109
|
+
* parsed list of edits that should land on it.
|
|
110
|
+
*/
|
|
111
|
+
export class PatchSection {
|
|
112
|
+
readonly path: string;
|
|
113
|
+
readonly fileHash: string | undefined;
|
|
114
|
+
readonly diff: string;
|
|
115
|
+
|
|
116
|
+
#parsed: { edits: Edit[]; warnings: string[] } | undefined;
|
|
117
|
+
|
|
118
|
+
constructor(raw: RawSection) {
|
|
119
|
+
this.path = raw.path;
|
|
120
|
+
this.fileHash = raw.fileHash;
|
|
121
|
+
this.diff = raw.diffLines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse this section's diff body. Cached: subsequent calls return the
|
|
126
|
+
* same `{ edits, warnings }` object.
|
|
127
|
+
*/
|
|
128
|
+
parse(): { edits: Edit[]; warnings: string[] } {
|
|
129
|
+
this.#parsed ??= parsePatch(this.diff);
|
|
130
|
+
return this.#parsed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parsed edits for this section (lazy). */
|
|
134
|
+
get edits(): Edit[] {
|
|
135
|
+
return this.parse().edits;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Warnings emitted during parsing of this section (lazy). */
|
|
139
|
+
get warnings(): string[] {
|
|
140
|
+
return this.parse().warnings;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* True when at least one edit anchors to concrete file content.
|
|
145
|
+
* Pure `insert head:` / `insert tail:` literal inserts do not count.
|
|
146
|
+
*/
|
|
147
|
+
get hasAnchoredEdit(): boolean {
|
|
148
|
+
return this.edits.some((edit) => {
|
|
149
|
+
if (edit.kind === "delete") {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (edit.kind === "block") {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return (
|
|
156
|
+
edit.cursor.kind === "before_anchor" ||
|
|
157
|
+
edit.cursor.kind === "after_anchor"
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Anchor lines touched by this section, sorted ascending and deduplicated. */
|
|
163
|
+
collectAnchorLines(): number[] {
|
|
164
|
+
const lines = new Set<number>();
|
|
165
|
+
for (const edit of this.edits) {
|
|
166
|
+
if (edit.kind === "delete") {
|
|
167
|
+
lines.add(edit.anchor.line);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (edit.kind === "block") {
|
|
171
|
+
lines.add(edit.anchor.line);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (
|
|
175
|
+
edit.cursor.kind === "before_anchor" ||
|
|
176
|
+
edit.cursor.kind === "after_anchor"
|
|
177
|
+
) {
|
|
178
|
+
lines.add(edit.cursor.anchor.line);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [...lines].sort((a, b) => a - b);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Patch ───────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
|
189
|
+
* at a `¶PATH#HASH` header.
|
|
190
|
+
*
|
|
191
|
+
* `Patch` is pure data: parsing is line-anchored and does not look at the
|
|
192
|
+
* filesystem. To apply a patch, hand it to the patcher.
|
|
193
|
+
*/
|
|
194
|
+
export class Patch {
|
|
195
|
+
readonly sections: readonly PatchSection[];
|
|
196
|
+
|
|
197
|
+
private constructor(sections: PatchSection[]) {
|
|
198
|
+
this.sections = sections;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse `input` into a {@link Patch}.
|
|
203
|
+
*
|
|
204
|
+
* `options.cwd` resolves absolute paths inside headers to cwd-relative form.
|
|
205
|
+
*/
|
|
206
|
+
static parse(input: string, options: SplitOptions = {}): Patch {
|
|
207
|
+
const rawSections = splitIntoSections(input, options);
|
|
208
|
+
|
|
209
|
+
if (rawSections.length === 0) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Patch input must begin with "¶PATH#HASH" on the first non-blank line for anchored edits. ` +
|
|
212
|
+
'Example: "¶src/foo.ts#0A3" then edit ops.',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const sections = rawSections.map((raw) => new PatchSection(raw));
|
|
217
|
+
return new Patch(sections);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse `input` and return only the first section. Throws if the input
|
|
222
|
+
* has zero sections.
|
|
223
|
+
*/
|
|
224
|
+
static parseSingle(input: string, options: SplitOptions = {}): PatchSection {
|
|
225
|
+
const patch = Patch.parse(input, options);
|
|
226
|
+
const first = patch.sections[0];
|
|
227
|
+
if (!first) {
|
|
228
|
+
throw new Error("Patch input did not produce any sections.");
|
|
229
|
+
}
|
|
230
|
+
return first;
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized error and warning text emitted by the hashline edit tool.
|
|
3
|
+
* Consolidating these as named constants makes them easy to audit and
|
|
4
|
+
* keeps wording stable across the rendering paths that surface them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
|
8
|
+
|
|
9
|
+
// ─── Error messages ──────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a mismatch error message when the live file's hash doesn't match
|
|
13
|
+
* the tag the edit was authored against.
|
|
14
|
+
*/
|
|
15
|
+
export function mismatchMessage(
|
|
16
|
+
sectionPath: string,
|
|
17
|
+
expectedTag: string,
|
|
18
|
+
actualTag: string,
|
|
19
|
+
): string {
|
|
20
|
+
return (
|
|
21
|
+
`File "${sectionPath}" changed between read and edit (expected tag #${expectedTag}, ` +
|
|
22
|
+
`live file has tag #${actualTag}). Re-read the file with the read tool to get ` +
|
|
23
|
+
`the current tag and anchors, then retry the edit.`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Error when a section has no snapshot tag at all.
|
|
29
|
+
*/
|
|
30
|
+
export function missingTagMessage(sectionPath: string): string {
|
|
31
|
+
return (
|
|
32
|
+
`Missing hashline snapshot tag for edit to ${sectionPath}. Use ` +
|
|
33
|
+
`\`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}TAG\` from your latest read output. ` +
|
|
34
|
+
`To create a new file, use the write tool.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Error when the target file doesn't exist.
|
|
40
|
+
*/
|
|
41
|
+
export function nonExistentFileMessage(filePath: string): string {
|
|
42
|
+
return (
|
|
43
|
+
`File does not exist: "${filePath}". Use the write tool to create new files, ` +
|
|
44
|
+
`then edit them with the tag returned by write.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Error when the edit input doesn't start with a `¶PATH#HASH` header.
|
|
50
|
+
*/
|
|
51
|
+
export function noHeaderMessage(): string {
|
|
52
|
+
return (
|
|
53
|
+
`Edit input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG" copied ` +
|
|
54
|
+
`from the read output. Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}0A3" ` +
|
|
55
|
+
`followed by edit operations.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Warning messages ────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Warning when head/tail inserts are applied to a file whose tag is stale.
|
|
63
|
+
* Head/tail position is content-independent so the insert applies safely.
|
|
64
|
+
*/
|
|
65
|
+
export const HEADTAIL_DRIFT_WARNING =
|
|
66
|
+
"Applied insert head/tail onto the current file content even though the " +
|
|
67
|
+
"snapshot tag was stale (file changed since your read). Head/tail position " +
|
|
68
|
+
"is content-independent so the insert was not rejected — re-read if the " +
|
|
69
|
+
"drift was unexpected.";
|
|
70
|
+
|
|
71
|
+
/** Warning when recovery succeeds via 3-way merge on an external change. */
|
|
72
|
+
export const RECOVERY_EXTERNAL_WARNING =
|
|
73
|
+
"Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
74
|
+
|
|
75
|
+
/** Warning when recovery succeeds via replay against a newer in-session snapshot. */
|
|
76
|
+
export const RECOVERY_SESSION_CHAIN_WARNING =
|
|
77
|
+
"Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
|
|
78
|
+
|
|
79
|
+
/** Warning when structured-patch merge refused but anchor-content gate passed. */
|
|
80
|
+
export const RECOVERY_SESSION_REPLAY_WARNING =
|
|
81
|
+
"Recovered by replaying your edits onto the current file content — your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
|
|
82
|
+
|
|
83
|
+
/** Error when the hash was never recorded in the snapshot store. */
|
|
84
|
+
export function unrecognizedHashMessage(expectedTag: string): string {
|
|
85
|
+
return (
|
|
86
|
+
`Snapshot tag #${expectedTag} was not recorded in this session — it may be from a different session or fabricated. ` +
|
|
87
|
+
`Re-read the file to get a current tag.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── MismatchError ───────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** Lines of context shown around each anchor in mismatch diagnostics. */
|
|
94
|
+
export const MISMATCH_CONTEXT = 2;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Custom error thrown when tag validation fails.
|
|
98
|
+
* Carries anchor context so renderers can show rich diagnostics.
|
|
99
|
+
*/
|
|
100
|
+
export class MismatchError extends Error {
|
|
101
|
+
/** The file path (display-relative). */
|
|
102
|
+
readonly filePath: string;
|
|
103
|
+
/** The tag the edit was authored against. */
|
|
104
|
+
readonly expectedTag: string;
|
|
105
|
+
/** The tag of the current live file. */
|
|
106
|
+
readonly actualTag: string;
|
|
107
|
+
/** Full text of the live file (for diagnostic rendering). */
|
|
108
|
+
readonly liveText: string;
|
|
109
|
+
/** Anchor lines targeted by the edit (1-indexed). */
|
|
110
|
+
readonly anchorLines: readonly number[];
|
|
111
|
+
|
|
112
|
+
constructor(
|
|
113
|
+
filePath: string,
|
|
114
|
+
expectedTag: string,
|
|
115
|
+
actualTag: string,
|
|
116
|
+
liveText: string,
|
|
117
|
+
anchorLines: readonly number[],
|
|
118
|
+
) {
|
|
119
|
+
super(mismatchMessage(filePath, expectedTag, actualTag));
|
|
120
|
+
this.name = "MismatchError";
|
|
121
|
+
this.filePath = filePath;
|
|
122
|
+
this.expectedTag = expectedTag;
|
|
123
|
+
this.actualTag = actualTag;
|
|
124
|
+
this.liveText = liveText;
|
|
125
|
+
this.anchorLines = anchorLines;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal text-shape normalization: line-ending detection / round-trip and
|
|
3
|
+
* BOM stripping. The patcher uses these to canonicalize text to LF before
|
|
4
|
+
* applying edits and to restore the original shape on write-back.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type LineEnding = "\r\n" | "\n";
|
|
8
|
+
|
|
9
|
+
/** Detect the first line-ending style in `content`. Defaults to LF. */
|
|
10
|
+
export function detectLineEnding(content: string): LineEnding {
|
|
11
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
12
|
+
const lfIdx = content.indexOf("\n");
|
|
13
|
+
if (lfIdx === -1) {
|
|
14
|
+
return "\n";
|
|
15
|
+
}
|
|
16
|
+
if (crlfIdx === -1) {
|
|
17
|
+
return "\n";
|
|
18
|
+
}
|
|
19
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Normalize every line ending to LF. */
|
|
23
|
+
export function normalizeToLF(text: string): string {
|
|
24
|
+
return text.replace(/\r\n?/g, "\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Re-encode LF text with the requested line ending. */
|
|
28
|
+
export function restoreLineEndings(text: string, ending: LineEnding): string {
|
|
29
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BomResult {
|
|
33
|
+
/** Either the empty string or the BOM sequence. */
|
|
34
|
+
bom: string;
|
|
35
|
+
/** Text with any leading BOM removed. */
|
|
36
|
+
text: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Strip a UTF-8 BOM if present. Returns both BOM and trailing text. */
|
|
40
|
+
export function stripBom(content: string): BomResult {
|
|
41
|
+
return content.startsWith("\uFEFF")
|
|
42
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
43
|
+
: { bom: "", text: content };
|
|
44
|
+
}
|