@oh-my-pi/hashline 15.5.4

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Re-number a unified diff that uses the `+<lineNum>|content` /
3
+ * `-<lineNum>|content` / ` <lineNum>|content` line format into a compact
4
+ * preview that anchors every line to its post-edit position. Added lines,
5
+ * removed lines, and context lines all end up with a hashline-style anchor
6
+ * so a follow-up edit can reuse them directly.
7
+ *
8
+ * This is intentionally decoupled from the diff producer: anything that
9
+ * emits the `<sign><lineNum>|<content>` shape works.
10
+ */
11
+ import type { CompactDiffOptions, CompactDiffPreview } from "./types";
12
+
13
+ export function buildCompactDiffPreview(diff: string, _options: CompactDiffOptions = {}): CompactDiffPreview {
14
+ const lines = diff.length === 0 ? [] : diff.split("\n");
15
+ let addedLines = 0;
16
+ let removedLines = 0;
17
+
18
+ // External diff producers number `+` lines with the post-edit line number,
19
+ // `-` lines with the pre-edit line number, and context lines with the
20
+ // pre-edit line number. To emit fresh line numbers usable for follow-up
21
+ // edits, convert context-line numbers to post-edit positions by tracking
22
+ // the running offset (added so far - removed so far) as we walk the diff.
23
+ const formatted = lines.map(line => {
24
+ const kind = line[0];
25
+ if (kind !== "+" && kind !== "-" && kind !== " ") return line;
26
+
27
+ const body = line.slice(1);
28
+ const sep = body.indexOf("|");
29
+ if (sep === -1) return line;
30
+
31
+ const lineNumber = Number.parseInt(body.slice(0, sep), 10);
32
+ const content = body.slice(sep + 1);
33
+
34
+ switch (kind) {
35
+ case "+":
36
+ addedLines++;
37
+ return `+${lineNumber}:${content}`;
38
+ case "-":
39
+ removedLines++;
40
+ return `-${lineNumber}:${content}`;
41
+ default: {
42
+ const newLineNumber = lineNumber + addedLines - removedLines;
43
+ return ` ${newLineNumber}:${content}`;
44
+ }
45
+ }
46
+ });
47
+
48
+ return { preview: formatted.join("\n"), addedLines, removedLines };
49
+ }
package/src/format.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Hashline format primitives: sigils, separators, regex fragments, and the
3
+ * file-hash computation. These are the single source of truth for the
4
+ * parser, the tokenizer, the prompt, and the formal grammar.
5
+ */
6
+
7
+ /** Op sigil used immediately after a line-number anchor to insert before it. */
8
+ export const HL_OP_INSERT_BEFORE = "↑";
9
+ /** Op sigil used immediately after a line-number anchor to insert after it. */
10
+ export const HL_OP_INSERT_AFTER = "↓";
11
+ /** Op sigil used after a range (or single anchor) to replace its lines. */
12
+ export const HL_OP_REPLACE = ":";
13
+ /** Op sigil used after a range (or single anchor) to delete its lines. */
14
+ export const HL_OP_DELETE = "!";
15
+
16
+ /** All hashline edit op sigils, concatenated for fast membership tests. */
17
+ export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`;
18
+
19
+ /** Prefix for payload continuation lines. The prefix itself is not written. */
20
+ export const HL_PAYLOAD_PREFIX = "+";
21
+
22
+ /** Hashline edit file-section header marker. */
23
+ export const HL_FILE_PREFIX = "¶";
24
+
25
+ /** Separator between a hashline file path and its file hash. */
26
+ export const HL_FILE_HASH_SEP = "#";
27
+
28
+ /** Separator between a line number and displayed line content in hashline mode. */
29
+ export const HL_LINE_BODY_SEP = ":";
30
+
31
+ function regexEscape(str: string): string {
32
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+
35
+ /**
36
+ * Decoration prefix that may precede a line number in tool output:
37
+ * `>` (context line in grep), `-` (removed line), `*` (match line).
38
+ * Any combination, in any order, surrounded by optional whitespace. Output
39
+ * formatters emit at most one decoration per line; the parser stays liberal
40
+ * because it accepts whatever the model echoes back.
41
+ */
42
+ export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>\\-*]*\\s*`;
43
+
44
+ /** Capture-group regex source for a decorated bare line-number anchor. */
45
+ export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
46
+
47
+ /** Bare positive line-number Lid (no decorations, no captures, no anchors). */
48
+ export const HL_LINE_RE_RAW = `[1-9]\\d*`;
49
+
50
+ /** Capture-group form of {@link HL_LINE_RE_RAW}. */
51
+ export const HL_LINE_CAPTURE_RE_RAW = `([1-9]\\d*)`;
52
+
53
+ /** Four-hex-character file hash carried by a hashline section header. */
54
+ export const HL_FILE_HASH_RE_RAW = `[0-9a-f]{4}`;
55
+
56
+ /** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
57
+ export const HL_FILE_HASH_CAPTURE_RE_RAW = `(${HL_FILE_HASH_RE_RAW})`;
58
+
59
+ /** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */
60
+ export const HL_LINE_BODY_SEP_RE_RAW = regexEscape(HL_LINE_BODY_SEP);
61
+
62
+ /**
63
+ * Representative file hashes for use in user-facing error messages and prompt
64
+ * examples.
65
+ */
66
+ export const HL_FILE_HASH_EXAMPLES = ["1a2b", "3c4d", "9f3e"] as const;
67
+
68
+ /**
69
+ * Format a comma-separated list of example anchors with an optional line-number
70
+ * prefix, quoted for inclusion in error messages: `"160", "42", "7"`.
71
+ */
72
+ export function describeAnchorExamples(linePrefix = ""): string {
73
+ const examples = linePrefix ? [linePrefix, `${linePrefix.slice(0, -1) || "4"}2`, "7"] : ["160", "42", "7"];
74
+ return examples.map(e => `"${e}"`).join(", ");
75
+ }
76
+
77
+ function normalizeFileHashText(text: string): string {
78
+ return text
79
+ .replace(/\r/g, "")
80
+ .split("\n")
81
+ .map(line => line.trimEnd())
82
+ .join("\n");
83
+ }
84
+
85
+ /**
86
+ * Compute the 4-hex-character hash carried by a hashline section header. The
87
+ * hash normalizes CR characters and trailing whitespace before hashing so
88
+ * platform line endings and display-trimmed lines do not invalidate anchors.
89
+ */
90
+ export function computeFileHash(text: string): string {
91
+ const normalized = normalizeFileHashText(text);
92
+ const low16 = Bun.hash.xxHash32(normalized, 0) & 0xffff;
93
+ return low16.toString(16).padStart(4, "0");
94
+ }
95
+
96
+ /** Format a hashline section header for a file path and file hash. */
97
+ export function formatHashlineHeader(filePath: string, fileHash: string): string {
98
+ return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`;
99
+ }
100
+
101
+ /** Formats a single numbered line as `LINE:TEXT`. */
102
+ export function formatNumberedLine(lineNumber: number, line: string): string {
103
+ return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
104
+ }
105
+
106
+ /** Format file text with hashline-mode line-number prefixes for display. */
107
+ export function formatNumberedLines(text: string, startLine = 1): string {
108
+ const lines = text.split("\n");
109
+ return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n");
110
+ }
package/src/fs.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Storage seam for the hashline patcher. {@link Filesystem} is intentionally
3
+ * minimal — `readText`, `writeText`, `exists` — so any backing store can be
4
+ * adapted: disk, memory, S3, an LSP text-document protocol, a Git tree, a
5
+ * VFS, etc.
6
+ *
7
+ * The patcher does its own BOM stripping and LF normalization between
8
+ * {@link Filesystem.readText} and {@link Filesystem.writeText}; the FS deals
9
+ * only in raw text strings.
10
+ */
11
+ import * as pathModule from "node:path";
12
+
13
+ /**
14
+ * Result returned by {@link Filesystem.writeText}. The patcher echoes back
15
+ * `text` so adapters that transform on serialization (e.g. notebooks) can
16
+ * report what actually landed on disk.
17
+ */
18
+ export interface WriteResult {
19
+ /** Final text that was persisted. May differ from the input if the FS transformed it. */
20
+ text: string;
21
+ }
22
+
23
+ /**
24
+ * ENOENT-like error thrown by {@link Filesystem.readText} when a path is
25
+ * missing. Carrying a `code` property keeps the contract compatible with
26
+ * `node:fs` callers that already check `err.code === "ENOENT"`.
27
+ */
28
+ export class NotFoundError extends Error {
29
+ readonly code = "ENOENT";
30
+
31
+ constructor(path: string, cause?: unknown) {
32
+ super(`File not found: ${path}`);
33
+ this.name = "NotFoundError";
34
+ if (cause !== undefined) (this as Error & { cause?: unknown }).cause = cause;
35
+ }
36
+ }
37
+
38
+ /** Type guard for {@link NotFoundError} and structurally-compatible errors. */
39
+ export function isNotFound(error: unknown): boolean {
40
+ if (error instanceof NotFoundError) return true;
41
+ if (error instanceof Error && (error as Error & { code?: string }).code === "ENOENT") return true;
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * Abstract storage backend the {@link Patcher} reads from and writes to.
47
+ * Subclass for new backends; the package ships {@link InMemoryFilesystem} and
48
+ * {@link NodeFilesystem} for the most common cases.
49
+ *
50
+ * Implementations work with raw text — the patcher handles BOM stripping and
51
+ * line-ending normalization itself. `readText` MUST throw {@link
52
+ * NotFoundError} (or any error for which {@link isNotFound} returns true)
53
+ * when the path doesn't exist; that's how the patcher detects a create-vs-
54
+ * update.
55
+ */
56
+ export abstract class Filesystem {
57
+ /** Read the file's full text content. Throw on missing file. */
58
+ abstract readText(path: string): Promise<string>;
59
+
60
+ /** Validate that `path` is writable before a prepared batch starts committing. */
61
+ async preflightWrite(_path: string): Promise<void> {}
62
+
63
+ /** Persist `content` at `path`. Returns the actual final text that was written. */
64
+ abstract writeText(path: string, content: string): Promise<WriteResult>;
65
+
66
+ /** Return true when the path exists and can be read. Default: probe via {@link readText}. */
67
+ async exists(path: string): Promise<boolean> {
68
+ try {
69
+ await this.readText(path);
70
+ return true;
71
+ } catch (error) {
72
+ if (isNotFound(error)) return false;
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Canonical path used as a key by external caches (e.g. snapshot
79
+ * stores). The default is identity; override to return an absolute or
80
+ * otherwise canonicalised path so producers and consumers of cached
81
+ * snapshots agree on the key without each having to redo the resolution.
82
+ */
83
+ canonicalPath(path: string): string {
84
+ return path;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * In-memory {@link Filesystem}. Useful for tests, sandboxes, dry-runs, and as
90
+ * a building block for stacked adapters (e.g. an LRU layer on top).
91
+ */
92
+ export class InMemoryFilesystem extends Filesystem {
93
+ #files = new Map<string, string>();
94
+
95
+ constructor(initial?: Iterable<readonly [string, string]>) {
96
+ super();
97
+ if (initial) {
98
+ for (const [path, content] of initial) this.#files.set(path, content);
99
+ }
100
+ }
101
+
102
+ async readText(path: string): Promise<string> {
103
+ const text = this.#files.get(path);
104
+ if (text === undefined) throw new NotFoundError(path);
105
+ return text;
106
+ }
107
+
108
+ async writeText(path: string, content: string): Promise<WriteResult> {
109
+ this.#files.set(path, content);
110
+ return { text: content };
111
+ }
112
+
113
+ async exists(path: string): Promise<boolean> {
114
+ return this.#files.has(path);
115
+ }
116
+
117
+ /** Synchronous helper for setting up fixtures without awaiting. */
118
+ set(path: string, content: string): void {
119
+ this.#files.set(path, content);
120
+ }
121
+
122
+ /** Synchronous helper for inspecting state without awaiting. */
123
+ get(path: string): string | undefined {
124
+ return this.#files.get(path);
125
+ }
126
+
127
+ /** Remove a single entry. Returns true when something was removed. */
128
+ delete(path: string): boolean {
129
+ return this.#files.delete(path);
130
+ }
131
+
132
+ /** Wipe all entries. */
133
+ clear(): void {
134
+ this.#files.clear();
135
+ }
136
+
137
+ /** Iterate `[path, content]` pairs. */
138
+ entries(): IterableIterator<[string, string]> {
139
+ return this.#files.entries();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Disk-backed {@link Filesystem} using Bun's file APIs. The default for CLI
145
+ * use. Paths are accepted as-is; callers responsible for any cwd or
146
+ * jail/sandbox resolution should wrap this with their own subclass.
147
+ */
148
+ export class NodeFilesystem extends Filesystem {
149
+ async readText(path: string): Promise<string> {
150
+ const file = Bun.file(path);
151
+ if (!(await file.exists())) throw new NotFoundError(path);
152
+ return file.text();
153
+ }
154
+
155
+ async writeText(path: string, content: string): Promise<WriteResult> {
156
+ await Bun.write(path, content);
157
+ return { text: content };
158
+ }
159
+
160
+ canonicalPath(path: string): string {
161
+ return pathModule.resolve(path);
162
+ }
163
+
164
+ async exists(path: string): Promise<boolean> {
165
+ return Bun.file(path).exists();
166
+ }
167
+ }
@@ -0,0 +1,22 @@
1
+ start: begin_patch hunk+ end_patch
2
+ begin_patch: "*** Begin Patch" LF
3
+ end_patch: "*** End Patch" LF?
4
+
5
+ hunk: update_hunk
6
+ update_hunk: "¶" filename ("#" file_hash)? LF line_op*
7
+
8
+ filename: /([^\s#]+)/
9
+ file_hash: /[0-9a-f]{4}/
10
+
11
+ line_op: insert_before | insert_after | replace | delete
12
+ insert_before: anchor "↑" LF payload*
13
+ insert_after: anchor "↓" LF payload*
14
+ replace: range ":" LF payload*
15
+ delete: range "!" LF
16
+ payload: "+" /[^\n]*/ LF
17
+
18
+ anchor: LID | "EOF" | "BOF"
19
+ range: LID ("-" LID)?
20
+ LID: /[1-9]\d*/
21
+
22
+ %import common.LF
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export * from "./apply";
2
+ export * from "./diff-preview";
3
+ export * from "./format";
4
+ export * from "./fs";
5
+ export * from "./input";
6
+ export * from "./messages";
7
+ export * from "./mismatch";
8
+ export * from "./normalize";
9
+ export * from "./parser";
10
+ export * from "./patcher";
11
+ export * from "./prefixes";
12
+ export * from "./recovery";
13
+ export * from "./snapshots";
14
+ export * from "./stream";
15
+ export * from "./tokenizer";
16
+ export * from "./types";
package/src/input.ts ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Top-level patch parser. Splits an authored hashline input into a list of
3
+ * {@link PatchSection}s, each rooted at a `¶PATH#HASH` header, then exposes
4
+ * a {@link Patch} class that gives lazy access to the parsed edits per
5
+ * section.
6
+ *
7
+ * The splitter is purely lexical — it doesn't know whether a section's path
8
+ * actually exists. That's the patcher's job.
9
+ */
10
+ import * as path from "node:path";
11
+ import { applyEdits } from "./apply";
12
+ import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
13
+ import { parsePatch } from "./parser";
14
+ import { Tokenizer } from "./tokenizer";
15
+ import type { ApplyOptions, ApplyResult, Edit, SplitOptions } from "./types";
16
+
17
+ // Pure classification — single shared tokenizer is safe.
18
+ const TOKENIZER = new Tokenizer();
19
+
20
+ function unquoteHashlinePath(pathText: string): string {
21
+ if (pathText.length < 2) return pathText;
22
+ const first = pathText[0];
23
+ const last = pathText[pathText.length - 1];
24
+ if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
25
+ return pathText;
26
+ }
27
+
28
+ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
29
+ const unquoted = unquoteHashlinePath(rawPath.trim());
30
+ if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
31
+ const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
32
+ const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
33
+ return isWithinCwd ? relative || "." : unquoted;
34
+ }
35
+
36
+ interface RawSection {
37
+ path: string;
38
+ fileHash?: string;
39
+ diff: string;
40
+ }
41
+
42
+ /**
43
+ * Parse a `¶PATH[#hash]` header line. Returns `null` for lines that do not
44
+ * begin with the `¶` prefix; throws the existing "Input header must be …"
45
+ * error when a `¶`-prefixed line fails the strict shape (so malformed paths
46
+ * surface immediately instead of being silently re-classified as payload).
47
+ */
48
+ function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null {
49
+ const trimmed = line.trimEnd();
50
+ if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
51
+
52
+ const token = TOKENIZER.tokenize(trimmed);
53
+ if (token.kind !== "header") {
54
+ throw new Error(
55
+ `Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH with a 4-hex file hash; got ${JSON.stringify(trimmed)}.`,
56
+ );
57
+ }
58
+
59
+ const parsedPath = normalizeHashlinePath(token.path, cwd);
60
+ if (parsedPath.length === 0) {
61
+ throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
62
+ }
63
+ return token.fileHash !== undefined
64
+ ? { path: parsedPath, fileHash: token.fileHash, diff: "" }
65
+ : { path: parsedPath, diff: "" };
66
+ }
67
+
68
+ function stripLeadingBlankLines(input: string): string {
69
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
70
+ const lines = stripped.split("\n");
71
+ while (lines.length > 0) {
72
+ const head = lines[0].replace(/\r$/, "");
73
+ if (head.trim().length === 0 || TOKENIZER.tokenize(head).kind === "envelope-begin") {
74
+ lines.shift();
75
+ continue;
76
+ }
77
+ break;
78
+ }
79
+ return lines.join("\n");
80
+ }
81
+
82
+ /**
83
+ * Returns true when the input contains at least one line that the tokenizer
84
+ * recognizes as a hashline op. Used by streaming previews to decide whether
85
+ * the partial input is worth treating as a hashline patch yet.
86
+ */
87
+ export function containsRecognizableHashlineOperations(input: string): boolean {
88
+ for (const line of input.split(/\r?\n/)) {
89
+ if (TOKENIZER.isOp(line)) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function normalizeFallbackInput(input: string, options: SplitOptions): string {
95
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
96
+ const hasExplicitHeader = stripped
97
+ .split(/\r?\n/)
98
+ .some(rawLine => parseHashlineHeaderLine(rawLine, options.cwd) !== null);
99
+ if (hasExplicitHeader) return input;
100
+
101
+ if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
102
+ const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
103
+ if (fallbackPath.length === 0) return input;
104
+ return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
105
+ }
106
+
107
+ function splitRawSections(input: string, options: SplitOptions = {}): RawSection[] {
108
+ const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
109
+ const lines = stripped.split(/\r?\n/);
110
+ const firstLine = lines[0] ?? "";
111
+
112
+ if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
113
+ const preview = JSON.stringify(firstLine.slice(0, 120));
114
+ throw new Error(
115
+ `input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` +
116
+ `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b" then edit ops.`,
117
+ );
118
+ }
119
+
120
+ const sections: RawSection[] = [];
121
+ let current: RawSection | undefined;
122
+ let currentLines: string[] = [];
123
+
124
+ const flush = () => {
125
+ if (!current) return;
126
+ const hasOps = currentLines.some(line => line.trim().length > 0);
127
+ if (hasOps) sections.push({ ...current, diff: currentLines.join("\n") });
128
+ currentLines = [];
129
+ };
130
+
131
+ for (const line of lines) {
132
+ const trimmed = line.trimEnd();
133
+ const token = TOKENIZER.tokenize(line);
134
+ if (token.kind === "envelope-end" || token.kind === "abort") break;
135
+ if (token.kind === "envelope-begin") continue;
136
+
137
+ // Route every `¶`-prefixed line through parseHashlineHeaderLine so
138
+ // malformed headers still raise the strict "Input header must be …"
139
+ // diagnostic (the tokenizer alone would silently classify them as
140
+ // payload).
141
+ if (trimmed.startsWith(HL_FILE_PREFIX)) {
142
+ const header = parseHashlineHeaderLine(line, options.cwd);
143
+ if (header !== null) {
144
+ flush();
145
+ current = header;
146
+ currentLines = [];
147
+ continue;
148
+ }
149
+ }
150
+ currentLines.push(line);
151
+ }
152
+ flush();
153
+ return sections;
154
+ }
155
+
156
+ /**
157
+ * Snapshot of one section in a parsed {@link Patch}: a target file plus the
158
+ * lazily-parsed list of edits that should land on it. Constructed by
159
+ * {@link Patch.parse}; consumers usually iterate `patch.sections` rather
160
+ * than build these directly.
161
+ */
162
+ export class PatchSection {
163
+ readonly path: string;
164
+ readonly fileHash: string | undefined;
165
+ readonly diff: string;
166
+ #parsed: { edits: Edit[]; warnings: string[] } | undefined;
167
+
168
+ constructor(raw: RawSection) {
169
+ this.path = raw.path;
170
+ this.fileHash = raw.fileHash;
171
+ this.diff = raw.diff;
172
+ }
173
+
174
+ /**
175
+ * Parse this section's diff body. Cached: subsequent calls return the
176
+ * same `{ edits, warnings }` object so callers can safely call this from
177
+ * multiple paths (preflight, apply, diff-preview).
178
+ */
179
+ parse(): { edits: Edit[]; warnings: readonly string[] } {
180
+ this.#parsed ??= parsePatch(this.diff);
181
+ return this.#parsed;
182
+ }
183
+
184
+ /** Parsed edits for this section. */
185
+ get edits(): readonly Edit[] {
186
+ return this.parse().edits;
187
+ }
188
+
189
+ /** Warnings emitted during parsing of this section. */
190
+ get warnings(): readonly string[] {
191
+ return this.parse().warnings;
192
+ }
193
+
194
+ /**
195
+ * True when at least one edit anchors to a concrete file line (range or
196
+ * before/after_anchor insert). Pure BOF/EOF inserts do not count: those
197
+ * are safe to apply to files that don't yet exist.
198
+ */
199
+ get hasAnchorScopedEdit(): boolean {
200
+ return this.edits.some(edit => {
201
+ if (edit.kind === "delete") return true;
202
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
203
+ });
204
+ }
205
+
206
+ /** Anchor lines touched by this section, sorted ascending and deduplicated. */
207
+ collectAnchorLines(): readonly number[] {
208
+ const lines = new Set<number>();
209
+ for (const edit of this.edits) {
210
+ if (edit.kind === "delete") {
211
+ lines.add(edit.anchor.line);
212
+ continue;
213
+ }
214
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
215
+ lines.add(edit.cursor.anchor.line);
216
+ }
217
+ }
218
+ return [...lines].sort((a, b) => a - b);
219
+ }
220
+
221
+ /**
222
+ * Apply this section's edits to `text` and return the post-edit result.
223
+ * Pure: does no I/O, does not validate the section file hash. The
224
+ * {@link Patcher} owns hash validation and recovery; reach for this
225
+ * method directly when you've already validated the file content and
226
+ * just want the result.
227
+ */
228
+ applyTo(text: string, options: ApplyOptions = {}): ApplyResult {
229
+ const { edits, warnings } = this.parse();
230
+ const result = applyEdits(text, [...edits], options);
231
+ // Preserve parse warnings alongside applier warnings so consumers
232
+ // don't need to call `parse()` separately.
233
+ const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
234
+ return merged && merged.length > 0
235
+ ? { ...result, warnings: merged }
236
+ : { text: result.text, firstChangedLine: result.firstChangedLine };
237
+ }
238
+ }
239
+
240
+ /**
241
+ * A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
242
+ * at a `¶PATH#HASH` header. Construct via {@link Patch.parse}.
243
+ *
244
+ * `Patch` is pure data: parsing is line-anchored and does not look at the
245
+ * filesystem. To apply a patch, hand it to {@link Patcher.apply}.
246
+ */
247
+ export class Patch {
248
+ readonly sections: readonly PatchSection[];
249
+
250
+ private constructor(sections: PatchSection[]) {
251
+ this.sections = sections;
252
+ }
253
+
254
+ /**
255
+ * Parse `input` into a {@link Patch}. `options.cwd` resolves absolute
256
+ * paths inside headers to cwd-relative form; `options.path` provides a
257
+ * fallback when the input lacks a header but contains hashline ops
258
+ * (useful for streaming previews).
259
+ *
260
+ * Consecutive sections targeting the same path are merged into a single
261
+ * section with concatenated diff bodies. Anchors authored against the
262
+ * same file snapshot must be applied as one batch; otherwise the first
263
+ * sub-edit shifts line numbers out from under the second's anchors and
264
+ * validation fails.
265
+ */
266
+ static parse(input: string, options: SplitOptions = {}): Patch {
267
+ const raw = mergeSamePathSections(splitRawSections(input, options));
268
+ return new Patch(raw.map(section => new PatchSection(section)));
269
+ }
270
+
271
+ /**
272
+ * Parse `input` and return only the first section. Throws if the input
273
+ * has zero sections. Convenience for the single-section case where the
274
+ * caller already knows the patch is one hunk.
275
+ */
276
+ static parseSingle(input: string, options: SplitOptions = {}): PatchSection {
277
+ const patch = Patch.parse(input, options);
278
+ const first = patch.sections[0];
279
+ if (!first) throw new Error("Patch input did not produce any sections.");
280
+ return first;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Collapse consecutive or interleaved sections targeting the same path into a
286
+ * single section with concatenated diffs. Anchors authored against the same
287
+ * file snapshot must be applied as one batch; otherwise the first sub-edit
288
+ * shifts line numbers out from under the second's anchors and validation
289
+ * fails. Path order is preserved by first occurrence.
290
+ */
291
+ function mergeSamePathSections(sections: RawSection[]): RawSection[] {
292
+ const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
293
+ for (const section of sections) {
294
+ const existing = byPath.get(section.path);
295
+ if (existing) {
296
+ if (
297
+ existing.fileHash !== undefined &&
298
+ section.fileHash !== undefined &&
299
+ existing.fileHash !== section.fileHash
300
+ ) {
301
+ throw new Error(
302
+ `Conflicting hashline file hashes for ${section.path}: #${existing.fileHash} and #${section.fileHash}. Re-read the file and retry with one current header.`,
303
+ );
304
+ }
305
+ if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
306
+ existing.diffs.push(section.diff);
307
+ continue;
308
+ }
309
+ byPath.set(section.path, {
310
+ ...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
311
+ diffs: [section.diff],
312
+ });
313
+ }
314
+ return Array.from(byPath, ([sectionPath, entry]) => ({
315
+ path: sectionPath,
316
+ ...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
317
+ diff: entry.diffs.join("\n"),
318
+ }));
319
+ }