@oh-my-pi/hashline 15.5.4 → 15.5.7
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/README.md +8 -8
- package/dist/types/apply.d.ts +1 -2
- package/dist/types/format.d.ts +9 -11
- package/dist/types/input.d.ts +8 -0
- package/dist/types/messages.d.ts +16 -28
- package/dist/types/parser.d.ts +37 -11
- package/dist/types/recovery.d.ts +6 -2
- package/dist/types/tokenizer.d.ts +14 -18
- package/dist/types/types.d.ts +4 -1
- package/package.json +1 -1
- package/src/apply.ts +42 -82
- package/src/format.ts +9 -11
- package/src/grammar.lark +7 -8
- package/src/input.ts +17 -1
- package/src/messages.ts +17 -32
- package/src/parser.ts +225 -161
- package/src/prompt.md +112 -65
- package/src/recovery.ts +70 -10
- package/src/tokenizer.ts +54 -122
- package/src/types.ts +11 -2
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ await fs.writeText(
|
|
|
25
25
|
);
|
|
26
26
|
|
|
27
27
|
const patcher = new Patcher({ fs });
|
|
28
|
-
const patch = Patch.parse(`¶hello.ts
|
|
28
|
+
const patch = Patch.parse(String.raw`¶hello.ts
|
|
29
|
+
1:
|
|
30
|
+
|const greeting = "hello";`);
|
|
29
31
|
const result = await patcher.apply(patch);
|
|
30
32
|
|
|
31
33
|
console.log(result.sections[0].op); // "update"
|
|
@@ -45,13 +47,11 @@ session-aware recovery).
|
|
|
45
47
|
|
|
46
48
|
Inside a hunk:
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|`A-B!`|Delete lines A..B (single-anchor `A!` is sugar for `A-A!`)|
|
|
54
|
-
|`+TEXT`|Payload continuation. The `+` prefix is stripped|
|
|
50
|
+
- `A-B:` — anchor lines A..B (single-anchor `A:` is sugar for `A-A:`).
|
|
51
|
+
- `BOF:` / `EOF:` — virtual anchors at the beginning/end of file.
|
|
52
|
+
- `|TEXT` — replace-bucket payload. A non-empty replace bucket replaces A..B.
|
|
53
|
+
- `↑TEXT` — insert before A (`BOF:` treats `↑`/`↓` equivalently).
|
|
54
|
+
- `↓TEXT` — insert after B (`EOF:` treats `↑`/`↓` equivalently).
|
|
55
55
|
|
|
56
56
|
## Abstractions
|
|
57
57
|
|
package/dist/types/apply.d.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type { ApplyOptions, ApplyResult, Edit } from "./types";
|
|
|
4
4
|
*
|
|
5
5
|
* Returns the post-edit text, the first changed line number (1-indexed), and
|
|
6
6
|
* any diagnostic warnings produced by the auto-absorb heuristics or by the
|
|
7
|
-
* structural-boundary delete check. Throws if an anchor is out of bounds
|
|
8
|
-
* blank-target replace is detected.
|
|
7
|
+
* structural-boundary delete check. Throws if an anchor is out of bounds.
|
|
9
8
|
*/
|
|
10
9
|
export declare function applyEdits(text: string, edits: Edit[], options?: ApplyOptions): ApplyResult;
|
package/dist/types/format.d.ts
CHANGED
|
@@ -3,18 +3,16 @@
|
|
|
3
3
|
* file-hash computation. These are the single source of truth for the
|
|
4
4
|
* parser, the tokenizer, the prompt, and the formal grammar.
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
7
|
-
export declare const HL_OP_INSERT_BEFORE = "\u2191";
|
|
8
|
-
/** Op sigil used immediately after a line-number anchor to insert after it. */
|
|
9
|
-
export declare const HL_OP_INSERT_AFTER = "\u2193";
|
|
10
|
-
/** Op sigil used after a range (or single anchor) to replace its lines. */
|
|
6
|
+
/** Anchor terminator for every hashline operation block. */
|
|
11
7
|
export declare const HL_OP_REPLACE = ":";
|
|
12
|
-
/**
|
|
13
|
-
export declare const
|
|
14
|
-
/**
|
|
15
|
-
export declare const
|
|
16
|
-
/**
|
|
17
|
-
export declare const
|
|
8
|
+
/** Payload sigil for lines that replace the anchored range in place. */
|
|
9
|
+
export declare const HL_PAYLOAD_REPLACE = "|";
|
|
10
|
+
/** Payload sigil for lines inserted before the anchored range. */
|
|
11
|
+
export declare const HL_PAYLOAD_ABOVE = "\u2191";
|
|
12
|
+
/** Payload sigil for lines inserted after the anchored range. */
|
|
13
|
+
export declare const HL_PAYLOAD_BELOW = "\u2193";
|
|
14
|
+
/** All hashline payload sigils, concatenated for fast membership tests. */
|
|
15
|
+
export declare const HL_PAYLOAD_CHARS = "|\u2191\u2193";
|
|
18
16
|
/** Hashline edit file-section header marker. */
|
|
19
17
|
export declare const HL_FILE_PREFIX = "\u00B6";
|
|
20
18
|
/** Separator between a hashline file path and its file hash. */
|
package/dist/types/input.d.ts
CHANGED
|
@@ -51,6 +51,14 @@ export declare class PatchSection {
|
|
|
51
51
|
* just want the result.
|
|
52
52
|
*/
|
|
53
53
|
applyTo(text: string, options?: ApplyOptions): ApplyResult;
|
|
54
|
+
/**
|
|
55
|
+
* Streaming-tolerant counterpart to {@link applyTo}. Uses
|
|
56
|
+
* {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
|
|
57
|
+
* or a per-token parse error mid-stream) does not throw or emit a phantom
|
|
58
|
+
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
59
|
+
* path should always use {@link applyTo}.
|
|
60
|
+
*/
|
|
61
|
+
applyPartialTo(text: string, options?: ApplyOptions): ApplyResult;
|
|
54
62
|
}
|
|
55
63
|
/**
|
|
56
64
|
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -20,37 +20,25 @@ export declare const ABORT_MARKER = "*** Abort";
|
|
|
20
20
|
/** Warning text appended to the tool result when {@link ABORT_MARKER} terminates parsing. */
|
|
21
21
|
export declare const ABORT_WARNING = "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
|
|
22
22
|
/**
|
|
23
|
-
* Warning text appended when two consecutive
|
|
24
|
-
* range
|
|
25
|
-
* the first op's payload is silently discarded.
|
|
23
|
+
* Warning text appended when two consecutive blocks target the exact same
|
|
24
|
+
* concrete range. The second block wins; the first block is discarded.
|
|
26
25
|
*/
|
|
27
|
-
export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* not classify as ops/headers/payloads, but the canonical syntax requires `+`
|
|
33
|
-
* on every continuation line after the op.
|
|
34
|
-
*/
|
|
35
|
-
export declare const IMPLICIT_CONTINUATION_WARNING = "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
|
|
36
|
-
/**
|
|
37
|
-
* Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
|
|
38
|
-
* op arrives while an outer `A-B:` replace is still pending and the inner
|
|
39
|
-
* anchor falls inside the outer range. The author used the read-output
|
|
40
|
-
* `LINE:TEXT` format as if it were a payload-continuation line; we strip the
|
|
41
|
-
* `LINE:` prefix and append the body to the pending payload, but warn so the
|
|
42
|
-
* canonical `+`-continuation form remains preferred.
|
|
43
|
-
*/
|
|
44
|
-
export declare const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING = "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` \u2014 never reuse the read-output gutter format.";
|
|
45
|
-
/**
|
|
46
|
-
* Warning text appended when an op carries an inline payload (`LINE:TEXT`,
|
|
47
|
-
* `LINE↑CONTENT`, `LINE↓CONTENT`). Canonical syntax is the bare op followed
|
|
48
|
-
* by `+`-prefixed payload rows on the next line(s).
|
|
49
|
-
*/
|
|
50
|
-
export declare const INLINE_PAYLOAD_ACCEPTED_WARNING = "Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE\u2191CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
|
|
26
|
+
export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range \u2014 payload is the final desired content, never both old and new.";
|
|
27
|
+
/** Error text prefix emitted when an anchor line carries inline payload. */
|
|
28
|
+
export declare const INLINE_PAYLOAD_REJECTED_PREFIX = "Inline payload on the anchor line is rejected.";
|
|
29
|
+
/** Error text emitted when `|` replacement payload targets BOF/EOF. */
|
|
30
|
+
export declare const VIRTUAL_REPLACE_REJECTED_MESSAGE = "BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `\u2191` or `\u2193` payload lines.";
|
|
51
31
|
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
|
|
52
32
|
export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
53
33
|
/** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
|
|
54
34
|
export declare const RECOVERY_SESSION_CHAIN_WARNING = "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
|
|
55
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Warning text emitted by `Recovery` when the session-chain replay
|
|
37
|
+
* fast-path was taken. Distinct from {@link RECOVERY_SESSION_CHAIN_WARNING}
|
|
38
|
+
* because replay is the less-certain mode: the structured-patch 3-way
|
|
39
|
+
* merge refused, the anchor-content gate passed, but a coincidental
|
|
40
|
+
* insert+delete pair earlier in the chain could still leave an anchor's
|
|
41
|
+
* line number pointing at a duplicated row. Surface the hedge so the
|
|
42
|
+
* model verifies before continuing.
|
|
43
|
+
*/
|
|
56
44
|
export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying your edits onto the current file content \u2014 your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
|
package/dist/types/parser.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type { Edit } from "./types";
|
|
|
4
4
|
* Token-driven state machine that turns a stream of {@link Token}s into a
|
|
5
5
|
* flat list of {@link Edit}s.
|
|
6
6
|
*
|
|
7
|
-
* `feed()` accepts tokens one at a time;
|
|
8
|
-
*
|
|
7
|
+
* `feed()` accepts tokens one at a time; block payload rows accumulate until
|
|
8
|
+
* the next anchor block or {@link end} flushes them. After `terminated` flips
|
|
9
9
|
* true (on `envelope-end` or `abort`) subsequent feeds are silently ignored
|
|
10
10
|
* so callers can keep draining their tokenizer.
|
|
11
11
|
*/
|
|
@@ -15,25 +15,33 @@ export declare class Executor {
|
|
|
15
15
|
get terminated(): boolean;
|
|
16
16
|
/**
|
|
17
17
|
* Consume one token. After `terminated` flips true subsequent feeds are
|
|
18
|
-
* silently ignored so callers can keep draining
|
|
18
|
+
* silently ignored so callers can keep draining the tokenizer without
|
|
19
19
|
* explicit early-exit guards.
|
|
20
20
|
*/
|
|
21
21
|
feed(token: Token): void;
|
|
22
22
|
/**
|
|
23
|
-
* Flush any open pending
|
|
24
|
-
*
|
|
25
|
-
* warnings. The executor is single-use; {@link reset} is required for
|
|
26
|
-
* reuse.
|
|
23
|
+
* Flush any open pending block and return the accumulated edits and
|
|
24
|
+
* warnings. The executor is single-use; {@link reset} is required for reuse.
|
|
27
25
|
*
|
|
28
|
-
* Throws if two
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* Throws if two replacement/delete blocks target the same line with
|
|
27
|
+
* non-identical ranges. Identical-range blocks in the same hunk are
|
|
28
|
+
* coalesced last-wins by `feed()` with a warning, so they never reach the
|
|
29
|
+
* validator.
|
|
32
30
|
*/
|
|
33
31
|
end(): {
|
|
34
32
|
edits: Edit[];
|
|
35
33
|
warnings: string[];
|
|
36
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Streaming-tolerant variant of {@link end}. Identical, except a pending
|
|
37
|
+
* block whose payload has not yet accumulated any rows is treated as still
|
|
38
|
+
* in flight and dropped instead of flushed (which would otherwise preview a
|
|
39
|
+
* destructive bare delete while the model may still be typing payload).
|
|
40
|
+
*/
|
|
41
|
+
endStreaming(): {
|
|
42
|
+
edits: Edit[];
|
|
43
|
+
warnings: string[];
|
|
44
|
+
};
|
|
37
45
|
/** Reset to a fresh state so the same instance can drive another parse. */
|
|
38
46
|
reset(): void;
|
|
39
47
|
}
|
|
@@ -48,3 +56,21 @@ export declare function parsePatch(diff: string): {
|
|
|
48
56
|
edits: Edit[];
|
|
49
57
|
warnings: string[];
|
|
50
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Streaming-tolerant variant of {@link parsePatch}. Returns whatever edits
|
|
61
|
+
* parsed successfully when the diff is still being typed:
|
|
62
|
+
*
|
|
63
|
+
* - per-token feed errors stop the drain but preserve the edits already
|
|
64
|
+
* collected (the trailing block is malformed mid-stream — wait for the next
|
|
65
|
+
* chunk),
|
|
66
|
+
* - the trailing pending block is dropped if it has no payload yet (avoids a
|
|
67
|
+
* destructive bare-delete preview while payload may still be coming).
|
|
68
|
+
*
|
|
69
|
+
* Throws only on the cross-block overlap validator, which catches conflicting
|
|
70
|
+
* shapes (two replacements/deletes hitting the same anchor). Streaming preview
|
|
71
|
+
* callers should treat any throw here as "no preview this tick".
|
|
72
|
+
*/
|
|
73
|
+
export declare function parsePatchStreaming(diff: string): {
|
|
74
|
+
edits: Edit[];
|
|
75
|
+
warnings: string[];
|
|
76
|
+
};
|
package/dist/types/recovery.d.ts
CHANGED
|
@@ -22,8 +22,12 @@ export interface RecoveryResult {
|
|
|
22
22
|
*
|
|
23
23
|
* 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
|
|
24
24
|
* 2. (Session chain) If the snapshot wasn't the head, retry on current text
|
|
25
|
-
* when line counts match
|
|
26
|
-
*
|
|
25
|
+
* when line counts match AND every edit's anchor line content is unchanged
|
|
26
|
+
* between snapshot and current — the previous in-session edit advanced
|
|
27
|
+
* the hash and the model's anchors still name the same logical rows. Emits
|
|
28
|
+
* a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
|
|
29
|
+
* both guards a coincidental insert+delete pair on duplicate rows can
|
|
30
|
+
* still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
|
|
27
31
|
* 3. Reconstruct from a sparse snapshot (lines map only), verify the rebuilt
|
|
28
32
|
* text hashes to the expected value, then 3-way-merge.
|
|
29
33
|
*/
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* The tokenizer is intentionally permissive about decorations and prefixes
|
|
11
11
|
* the model may echo back from `read`/`search` output — leading `*`/`>`/`-`
|
|
12
12
|
* markers, CR-terminated lines, leading whitespace before line numbers, and
|
|
13
|
-
* so on are all stripped before classification.
|
|
13
|
+
* so on are all stripped before anchor classification.
|
|
14
14
|
*/
|
|
15
15
|
import type { Anchor, Cursor, ParsedRange } from "./types";
|
|
16
16
|
/**
|
|
@@ -24,14 +24,17 @@ import type { Anchor, Cursor, ParsedRange } from "./types";
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function splitHashlineLines(text: string): string[];
|
|
26
26
|
export declare function cloneCursor(cursor: Cursor): Cursor;
|
|
27
|
-
/** Parse a bare line-number anchor
|
|
27
|
+
/** Parse a bare line-number anchor. Throws on malformed input. */
|
|
28
28
|
export declare function parseLid(raw: string, lineNum: number): Anchor;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
export type BlockTarget = {
|
|
30
|
+
kind: "range";
|
|
31
|
+
range: ParsedRange;
|
|
32
|
+
} | {
|
|
33
|
+
kind: "bof";
|
|
34
|
+
} | {
|
|
35
|
+
kind: "eof";
|
|
36
|
+
};
|
|
37
|
+
export type PayloadBucket = "above" | "replace" | "below";
|
|
35
38
|
interface TokenBase {
|
|
36
39
|
/** 1-indexed line number in the original input stream. */
|
|
37
40
|
lineNum: number;
|
|
@@ -49,19 +52,12 @@ export type Token = (TokenBase & {
|
|
|
49
52
|
path: string;
|
|
50
53
|
fileHash?: string;
|
|
51
54
|
}) | (TokenBase & {
|
|
52
|
-
kind: "op-
|
|
53
|
-
|
|
54
|
-
inlineBody: string | undefined;
|
|
55
|
-
}) | (TokenBase & {
|
|
56
|
-
kind: "op-replace";
|
|
57
|
-
range: ParsedRange;
|
|
55
|
+
kind: "op-block";
|
|
56
|
+
target: BlockTarget;
|
|
58
57
|
inlineBody: string | undefined;
|
|
59
|
-
}) | (TokenBase & {
|
|
60
|
-
kind: "op-delete";
|
|
61
|
-
range: ParsedRange;
|
|
62
|
-
trailingPayload: boolean;
|
|
63
58
|
}) | (TokenBase & {
|
|
64
59
|
kind: "payload";
|
|
60
|
+
bucket: PayloadBucket;
|
|
65
61
|
text: string;
|
|
66
62
|
}) | (TokenBase & {
|
|
67
63
|
kind: "raw";
|
package/dist/types/types.d.ts
CHANGED
|
@@ -22,7 +22,9 @@ export type Cursor = {
|
|
|
22
22
|
/**
|
|
23
23
|
* A single low-level edit produced by the parser and consumed by the applier.
|
|
24
24
|
* Multi-line replacements decompose to one `insert` per replacement line plus
|
|
25
|
-
* one `delete` per consumed line.
|
|
25
|
+
* one `delete` per consumed line. Replacement inserts are tagged so the applier
|
|
26
|
+
* can distinguish "insert above this deleted line" from "new content for this
|
|
27
|
+
* deleted line" when a source block carries both buckets.
|
|
26
28
|
*/
|
|
27
29
|
export type Edit = {
|
|
28
30
|
kind: "insert";
|
|
@@ -30,6 +32,7 @@ export type Edit = {
|
|
|
30
32
|
text: string;
|
|
31
33
|
lineNum: number;
|
|
32
34
|
index: number;
|
|
35
|
+
mode?: "replacement";
|
|
33
36
|
} | {
|
|
34
37
|
kind: "delete";
|
|
35
38
|
anchor: Anchor;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.5.
|
|
4
|
+
"version": "15.5.7",
|
|
5
5
|
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
package/src/apply.ts
CHANGED
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
* post-edit lines plus any diagnostic warnings. Pure function: no FS, no
|
|
4
4
|
* mutation of the input.
|
|
5
5
|
*
|
|
6
|
-
* The applier
|
|
6
|
+
* The applier normalizes common model boundary mistakes:
|
|
7
7
|
*
|
|
8
|
-
* - Replace ops on a blank line with non-empty payload are rejected outright
|
|
9
|
-
* (the model almost certainly miscounted; recommend `↑`/`↓` instead).
|
|
10
8
|
* - Multi-line replacement-boundary duplicates are auto-absorbed (model
|
|
11
9
|
* echoed surrounding context as if it were payload).
|
|
12
10
|
* - Single-line structural-boundary duplicates (`}`, `)`, `];`, …) are
|
|
@@ -35,11 +33,24 @@ interface ReplacementGroup {
|
|
|
35
33
|
deletes: DeleteEdit[];
|
|
36
34
|
}
|
|
37
35
|
|
|
36
|
+
function isReplacementInsert(edit: Edit): edit is Extract<Edit, { kind: "insert" }> & { mode: "replacement" } {
|
|
37
|
+
return edit.kind === "insert" && edit.mode === "replacement";
|
|
38
|
+
}
|
|
39
|
+
|
|
38
40
|
function getEditAnchors(edit: Edit): Anchor[] {
|
|
39
41
|
if (edit.kind === "delete") return [edit.anchor];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
switch (edit.cursor.kind) {
|
|
43
|
+
case "before_anchor":
|
|
44
|
+
case "after_anchor":
|
|
45
|
+
return [edit.cursor.anchor];
|
|
46
|
+
case "bof":
|
|
47
|
+
case "eof":
|
|
48
|
+
return [];
|
|
49
|
+
default: {
|
|
50
|
+
const _exhaustive: never = edit.cursor;
|
|
51
|
+
return _exhaustive;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
/**
|
|
@@ -56,57 +67,6 @@ function validateLineBounds(edits: Edit[], fileLines: string[]): void {
|
|
|
56
67
|
}
|
|
57
68
|
}
|
|
58
69
|
|
|
59
|
-
/**
|
|
60
|
-
* Refuse a single-line replace whose target line is blank and whose payload is
|
|
61
|
-
* non-empty. The author is almost certainly miscounting: `A:CONTENT` overwrites
|
|
62
|
-
* the existing line, so applying it to a blank target deletes the blank cadence
|
|
63
|
-
* and inserts content in its place. To insert content at a blank line, use
|
|
64
|
-
* `A↑` (insert before) or `A↓` (insert after) instead.
|
|
65
|
-
*
|
|
66
|
-
* Only fires for the simple shape: exactly one `insert(before_anchor A)` + one
|
|
67
|
-
* `delete(A)` sharing the same source op line, no other inserts/deletes from
|
|
68
|
-
* that op.
|
|
69
|
-
*/
|
|
70
|
-
function detectReplaceOnBlankTarget(edits: Edit[], fileLines: string[]): string | null {
|
|
71
|
-
interface Pair {
|
|
72
|
-
insert?: Extract<Edit, { kind: "insert" }>;
|
|
73
|
-
delete?: Extract<Edit, { kind: "delete" }>;
|
|
74
|
-
multi?: boolean;
|
|
75
|
-
}
|
|
76
|
-
const byOpLine = new Map<number, Pair>();
|
|
77
|
-
for (const edit of edits) {
|
|
78
|
-
const pair = byOpLine.get(edit.lineNum) ?? {};
|
|
79
|
-
if (pair.multi) continue;
|
|
80
|
-
if (edit.kind === "insert") {
|
|
81
|
-
if (pair.insert) pair.multi = true;
|
|
82
|
-
else pair.insert = edit;
|
|
83
|
-
} else {
|
|
84
|
-
if (pair.delete) pair.multi = true;
|
|
85
|
-
else pair.delete = edit;
|
|
86
|
-
}
|
|
87
|
-
byOpLine.set(edit.lineNum, pair);
|
|
88
|
-
}
|
|
89
|
-
for (const pair of byOpLine.values()) {
|
|
90
|
-
if (pair.multi || !pair.insert || !pair.delete) continue;
|
|
91
|
-
const insert = pair.insert;
|
|
92
|
-
const del = pair.delete;
|
|
93
|
-
if (insert.cursor.kind !== "before_anchor") continue;
|
|
94
|
-
if (insert.cursor.anchor.line !== del.anchor.line) continue;
|
|
95
|
-
if (insert.text.includes("\n")) continue;
|
|
96
|
-
if (insert.text.trim().length === 0) continue;
|
|
97
|
-
const targetLine = del.anchor.line;
|
|
98
|
-
const oldLine = fileLines[targetLine - 1];
|
|
99
|
-
if (oldLine === undefined || oldLine.trim().length !== 0) continue;
|
|
100
|
-
return (
|
|
101
|
-
`Edit rejected: replace at line ${targetLine} targets a blank line but the payload is non-empty. ` +
|
|
102
|
-
`'A:CONTENT' overwrites the line at A; to insert content next to a blank line, use 'A${"\u2191"}' (insert before) ` +
|
|
103
|
-
`or 'A${"\u2193"}' (insert after) instead. If you really meant to replace this blank with content, ` +
|
|
104
|
-
`widen the range to include surrounding non-blank lines so the intent is explicit.`
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
70
|
function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
|
|
111
71
|
if (lines.length === 0) return;
|
|
112
72
|
const origins = lines.map((): LineOrigin => "insert");
|
|
@@ -152,14 +112,14 @@ function collectAnchorTargetLines(edits: Edit[]): Set<number> {
|
|
|
152
112
|
|
|
153
113
|
function findReplacementGroup(edits: Edit[], startIndex: number): ReplacementGroup | undefined {
|
|
154
114
|
const first = edits[startIndex];
|
|
155
|
-
if (first
|
|
115
|
+
if (!isReplacementInsert(first) || first.cursor.kind !== "before_anchor") return undefined;
|
|
156
116
|
|
|
157
117
|
const sourceLineNum = first.lineNum;
|
|
158
118
|
const replacement: string[] = [];
|
|
159
119
|
let index = startIndex;
|
|
160
120
|
while (index < edits.length) {
|
|
161
121
|
const edit = edits[index];
|
|
162
|
-
if (edit
|
|
122
|
+
if (!isReplacementInsert(edit) || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
|
|
163
123
|
replacement.push(edit.text);
|
|
164
124
|
index++;
|
|
165
125
|
}
|
|
@@ -318,10 +278,9 @@ function countMatchingSingleStructuralSuffixBoundary(
|
|
|
318
278
|
/**
|
|
319
279
|
* Single-line non-structural boundary duplicate detector for replacement
|
|
320
280
|
* groups. Mirrors the same boundary check the pure-insert absorber uses for
|
|
321
|
-
* `
|
|
322
|
-
* top/bottom edges of an `A-B
|
|
323
|
-
* `103-138
|
|
324
|
-
* user really meant `103-138!` (delete only).
|
|
281
|
+
* `A:` + `↓` (leading) / `A:` + `↑` (trailing) inserts, but applied to the
|
|
282
|
+
* top/bottom edges of an `A-B:` replacement payload. Catches mistakes like
|
|
283
|
+
* `103-138:` + `|const X = …` where line 102 already reads `const X = …`.
|
|
325
284
|
*
|
|
326
285
|
* Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block
|
|
327
286
|
* absorb already runs unconditionally, and the structural single-line
|
|
@@ -400,7 +359,7 @@ function cursorMatches(a: Cursor, b: Cursor): boolean {
|
|
|
400
359
|
*/
|
|
401
360
|
function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup | undefined {
|
|
402
361
|
const first = edits[startIndex];
|
|
403
|
-
if (first?.kind !== "insert") return undefined;
|
|
362
|
+
if (first?.kind !== "insert" || isReplacementInsert(first)) return undefined;
|
|
404
363
|
|
|
405
364
|
const sourceLineNum = first.lineNum;
|
|
406
365
|
const cursor = first.cursor;
|
|
@@ -408,7 +367,7 @@ function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup
|
|
|
408
367
|
let index = startIndex;
|
|
409
368
|
while (index < edits.length) {
|
|
410
369
|
const edit = edits[index];
|
|
411
|
-
if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
|
|
370
|
+
if (edit.kind !== "insert" || isReplacementInsert(edit) || edit.lineNum !== sourceLineNum) break;
|
|
412
371
|
if (!cursorMatches(edit.cursor, cursor)) break;
|
|
413
372
|
payload.push(edit.text);
|
|
414
373
|
index++;
|
|
@@ -676,8 +635,7 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
676
635
|
*
|
|
677
636
|
* Returns the post-edit text, the first changed line number (1-indexed), and
|
|
678
637
|
* any diagnostic warnings produced by the auto-absorb heuristics or by the
|
|
679
|
-
* structural-boundary delete check. Throws if an anchor is out of bounds
|
|
680
|
-
* blank-target replace is detected.
|
|
638
|
+
* structural-boundary delete check. Throws if an anchor is out of bounds.
|
|
681
639
|
*/
|
|
682
640
|
export function applyEdits(text: string, edits: Edit[], options: ApplyOptions = {}): ApplyResult {
|
|
683
641
|
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
|
@@ -693,9 +651,6 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
|
|
|
693
651
|
|
|
694
652
|
validateLineBounds(edits, fileLines);
|
|
695
653
|
|
|
696
|
-
const blankTargetError = detectReplaceOnBlankTarget(edits, fileLines);
|
|
697
|
-
if (blankTargetError !== null) throw new Error(blankTargetError);
|
|
698
|
-
|
|
699
654
|
const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
|
|
700
655
|
const targetEdits: Edit[] = [];
|
|
701
656
|
|
|
@@ -744,20 +699,23 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
|
|
|
744
699
|
|
|
745
700
|
const idx = line - 1;
|
|
746
701
|
const currentLine = fileLines[idx] ?? "";
|
|
747
|
-
const
|
|
702
|
+
const insertLines: string[] = [];
|
|
703
|
+
const replacementLines: string[] = [];
|
|
748
704
|
let deleteLine = false;
|
|
749
705
|
|
|
750
706
|
for (const { edit } of bucket) {
|
|
751
|
-
if (edit
|
|
752
|
-
|
|
707
|
+
if (isReplacementInsert(edit)) {
|
|
708
|
+
replacementLines.push(edit.text);
|
|
709
|
+
} else if (edit.kind === "insert") {
|
|
710
|
+
insertLines.push(edit.text);
|
|
753
711
|
} else if (edit.kind === "delete") {
|
|
754
712
|
deleteLine = true;
|
|
755
713
|
}
|
|
756
714
|
}
|
|
757
|
-
if (
|
|
715
|
+
if (insertLines.length === 0 && replacementLines.length === 0 && !deleteLine) continue;
|
|
758
716
|
|
|
759
|
-
const
|
|
760
|
-
if (deleteLine && !
|
|
717
|
+
const hasReplacementPayload = replacementLines.length > 0;
|
|
718
|
+
if (deleteLine && !hasReplacementPayload) {
|
|
761
719
|
const balance = computeDelimiterBalance([currentLine]);
|
|
762
720
|
const trimmedCurrentLine = currentLine.trim();
|
|
763
721
|
const touchesStructuralBoundary =
|
|
@@ -769,15 +727,17 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
|
|
|
769
727
|
trimmedCurrentLine.endsWith("{");
|
|
770
728
|
if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
|
|
771
729
|
warnings.push(
|
|
772
|
-
`Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use '
|
|
730
|
+
`Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use '|replacement' payload to keep the boundary intact.`,
|
|
773
731
|
);
|
|
774
732
|
}
|
|
775
733
|
}
|
|
776
|
-
const replacement = deleteLine
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
734
|
+
const replacement = deleteLine
|
|
735
|
+
? [...insertLines, ...replacementLines]
|
|
736
|
+
: [...insertLines, ...replacementLines, currentLine];
|
|
737
|
+
const origins: LineOrigin[] = [];
|
|
738
|
+
for (let i = 0; i < insertLines.length; i++) origins.push("insert");
|
|
739
|
+
for (let i = 0; i < replacementLines.length; i++) origins.push(deleteLine ? "replacement" : "insert");
|
|
740
|
+
if (!deleteLine) origins.push(lineOrigins[idx] ?? "original");
|
|
781
741
|
|
|
782
742
|
fileLines.splice(idx, 1, ...replacement);
|
|
783
743
|
lineOrigins.splice(idx, 1, ...origins);
|
package/src/format.ts
CHANGED
|
@@ -4,20 +4,18 @@
|
|
|
4
4
|
* parser, the tokenizer, the prompt, and the formal grammar.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
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. */
|
|
7
|
+
/** Anchor terminator for every hashline operation block. */
|
|
12
8
|
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
9
|
|
|
16
|
-
/**
|
|
17
|
-
export const
|
|
10
|
+
/** Payload sigil for lines that replace the anchored range in place. */
|
|
11
|
+
export const HL_PAYLOAD_REPLACE = "|";
|
|
12
|
+
/** Payload sigil for lines inserted before the anchored range. */
|
|
13
|
+
export const HL_PAYLOAD_ABOVE = "↑";
|
|
14
|
+
/** Payload sigil for lines inserted after the anchored range. */
|
|
15
|
+
export const HL_PAYLOAD_BELOW = "↓";
|
|
18
16
|
|
|
19
|
-
/**
|
|
20
|
-
export const
|
|
17
|
+
/** All hashline payload sigils, concatenated for fast membership tests. */
|
|
18
|
+
export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_ABOVE}${HL_PAYLOAD_BELOW}`;
|
|
21
19
|
|
|
22
20
|
/** Hashline edit file-section header marker. */
|
|
23
21
|
export const HL_FILE_PREFIX = "¶";
|
package/src/grammar.lark
CHANGED
|
@@ -3,19 +3,18 @@ begin_patch: "*** Begin Patch" LF
|
|
|
3
3
|
end_patch: "*** End Patch" LF?
|
|
4
4
|
|
|
5
5
|
hunk: update_hunk
|
|
6
|
-
update_hunk: "¶" filename ("#" file_hash)? LF
|
|
6
|
+
update_hunk: "¶" filename ("#" file_hash)? LF block*
|
|
7
7
|
|
|
8
8
|
filename: /([^\s#]+)/
|
|
9
9
|
file_hash: /[0-9a-f]{4}/
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
payload: "+" /[^\n]*/ LF
|
|
11
|
+
block: anchor ":" LF payload*
|
|
12
|
+
payload: above_payload | replace_payload | below_payload
|
|
13
|
+
above_payload: "↑" /[^\n]*/ LF
|
|
14
|
+
replace_payload: "|" /[^\n]*/ LF
|
|
15
|
+
below_payload: "↓" /[^\n]*/ LF
|
|
17
16
|
|
|
18
|
-
anchor:
|
|
17
|
+
anchor: range | "BOF" | "EOF"
|
|
19
18
|
range: LID ("-" LID)?
|
|
20
19
|
LID: /[1-9]\d*/
|
|
21
20
|
|
package/src/input.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { applyEdits } from "./apply";
|
|
12
12
|
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
|
13
|
-
import { parsePatch } from "./parser";
|
|
13
|
+
import { parsePatch, parsePatchStreaming } from "./parser";
|
|
14
14
|
import { Tokenizer } from "./tokenizer";
|
|
15
15
|
import type { ApplyOptions, ApplyResult, Edit, SplitOptions } from "./types";
|
|
16
16
|
|
|
@@ -235,6 +235,22 @@ export class PatchSection {
|
|
|
235
235
|
? { ...result, warnings: merged }
|
|
236
236
|
: { text: result.text, firstChangedLine: result.firstChangedLine };
|
|
237
237
|
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Streaming-tolerant counterpart to {@link applyTo}. Uses
|
|
241
|
+
* {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
|
|
242
|
+
* or a per-token parse error mid-stream) does not throw or emit a phantom
|
|
243
|
+
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
244
|
+
* path should always use {@link applyTo}.
|
|
245
|
+
*/
|
|
246
|
+
applyPartialTo(text: string, options: ApplyOptions = {}): ApplyResult {
|
|
247
|
+
const { edits, warnings } = parsePatchStreaming(this.diff);
|
|
248
|
+
const result = applyEdits(text, [...edits], options);
|
|
249
|
+
const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
|
|
250
|
+
return merged && merged.length > 0
|
|
251
|
+
? { ...result, warnings: merged }
|
|
252
|
+
: { text: result.text, firstChangedLine: result.firstChangedLine };
|
|
253
|
+
}
|
|
238
254
|
}
|
|
239
255
|
|
|
240
256
|
/**
|