@oh-my-pi/hashline 17.2.0 → 17.2.2
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/CHANGELOG.md +20 -0
- package/README.md +7 -7
- package/dist/types/clipboard.d.ts +6 -4
- package/dist/types/format.d.ts +21 -27
- package/dist/types/messages.d.ts +48 -27
- package/dist/types/parser.d.ts +2 -1
- package/dist/types/prefixes.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +12 -8
- package/dist/types/types.d.ts +53 -25
- package/package.json +2 -2
- package/src/apply.ts +72 -1
- package/src/block.ts +27 -5
- package/src/clipboard.ts +141 -42
- package/src/format.ts +35 -34
- package/src/grammar.lark +12 -11
- package/src/input.ts +14 -10
- package/src/messages.ts +129 -65
- package/src/parser.ts +307 -103
- package/src/patcher.ts +8 -8
- package/src/prefixes.ts +12 -4
- package/src/prompt.md +37 -38
- package/src/recovery.ts +56 -15
- package/src/tokenizer.ts +160 -191
- package/src/types.ts +48 -25
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.2] - 2026-07-31
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Replaced legacy SWAP, INS, and PASTE syntax with unified PUT and CUT hunks
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added named register support (@reg) and span paste capabilities to clipboard operations
|
|
14
|
+
- Added conservative recovery for uniformly omitted replacement indents near brace openers, preserving intentional indentation-only edits
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Made .= the canonical inclusive range separator while retaining legacy separator variants as lenient input
|
|
19
|
+
- Unified replacement, insertion, register paste, block, head/tail, move, and removal headers under a composable PUT, CUT, MV, and REM grammar
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Improved resilience against common model output formatting errors, including numbered read rows, summarized ranges, diff-style old/new rows, empty PUT deletes, harmless CUT colons, and single-line span shorthand
|
|
24
|
+
|
|
5
25
|
## [17.2.0] - 2026-07-30
|
|
6
26
|
|
|
7
27
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ await fs.writeText("hello.ts", before);
|
|
|
26
26
|
const tag = snapshots.record("hello.ts", before);
|
|
27
27
|
const patcher = new Patcher({ fs, snapshots });
|
|
28
28
|
const patch = Patch.parse(String.raw`[hello.ts#${tag}]
|
|
29
|
-
|
|
29
|
+
PUT 1.=1:
|
|
30
30
|
+const greeting = "hello";`);
|
|
31
31
|
const result = await patcher.apply(patch);
|
|
32
32
|
|
|
@@ -47,12 +47,12 @@ still matches the recorded content hash, and refusing or attempting
|
|
|
47
47
|
session-aware recovery on mismatch.
|
|
48
48
|
|
|
49
49
|
Inside a section:
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
52
|
-
- `
|
|
53
|
-
- `
|
|
54
|
-
- `
|
|
55
|
-
- `
|
|
50
|
+
- `PUT A.=B:` — replace lines A through B (inclusive) with following `+TEXT` body rows.
|
|
51
|
+
- `PUT A*:` — replace the syntactic block beginning on line A.
|
|
52
|
+
- `PUT <A:` / `PUT >A:` — insert following body rows before/after line A (`<1` = head, `>$` = tail).
|
|
53
|
+
- `PUT >A*:` — insert following body rows after the resolved block's last line.
|
|
54
|
+
- `PUT <A` / `PUT >A` / `PUT A.=B @name` / `PUT A* @name` — paste a captured register at a gap, over a range, or over a resolved block (no `:` header or body rows; `@name` is optional only at gaps).
|
|
55
|
+
- `CUT A.=B` / `CUT A*` — delete concrete lines or a resolved block and capture them (anonymous, or `@name` when given).
|
|
56
56
|
- `REM` — delete the whole file named by the section header.
|
|
57
57
|
- `MV DEST` — move/rename the section file to `DEST` (optionally after line edits).
|
|
58
58
|
- `+TEXT` — literal body row (use `+` alone for a blank line).
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import type { Clipboard, Edit } from "./types.js";
|
|
2
|
-
/** True when at least one edit reads or writes
|
|
2
|
+
/** True when at least one edit reads or writes a clipboard register. */
|
|
3
3
|
export declare function hasClipboardEdit(edits: readonly Edit[]): boolean;
|
|
4
4
|
/** Optional knobs for {@link resolveClipboardEdits}. */
|
|
5
5
|
export interface ResolveClipboardEditsOptions {
|
|
6
|
-
/** `
|
|
6
|
+
/** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */
|
|
7
7
|
onEmptyPaste?: "throw" | "drop";
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
10
|
* Resolve clipboard edits against the original file lines in authored order.
|
|
11
|
-
* Cuts fill the register and emit nothing; pastes
|
|
11
|
+
* Cuts fill the register and emit nothing; pastes expand to inserts (+ deletes for span targets).
|
|
12
12
|
*/
|
|
13
13
|
export declare function resolveClipboardEdits(edits: readonly Edit[], fileLines: readonly string[], clipboard: Clipboard, options?: ResolveClipboardEditsOptions): readonly Edit[];
|
|
14
|
+
/** Start a batch with persisted named registers but no anonymous state. */
|
|
15
|
+
export declare function startClipboardBatch(source?: Clipboard): Clipboard;
|
|
14
16
|
/** Create a transactional working copy of a clipboard register. */
|
|
15
17
|
export declare function forkClipboard(source?: Clipboard): Clipboard;
|
|
16
|
-
/** Publish a clipboard fork back to its source register. */
|
|
18
|
+
/** Publish a clipboard fork back to its source register (only named registers persist across batches). */
|
|
17
19
|
export declare function commitClipboard(fork: Clipboard, target: Clipboard): void;
|
|
18
20
|
/**
|
|
19
21
|
* Validate that every paste has a preceding or persisted capture without
|
package/dist/types/format.d.ts
CHANGED
|
@@ -9,38 +9,28 @@ export declare const HL_FILE_PREFIX = "[";
|
|
|
9
9
|
export declare const HL_FILE_SUFFIX = "]";
|
|
10
10
|
/** Payload sigil for literal body rows. */
|
|
11
11
|
export declare const HL_PAYLOAD_REPLACE = "+";
|
|
12
|
-
/** Hunk-header keyword
|
|
13
|
-
export declare const
|
|
14
|
-
/** Hunk-header keyword
|
|
15
|
-
export declare const HL_INSERT_KEYWORD = "INS";
|
|
16
|
-
/** Insert position keyword for inserting before a concrete line. */
|
|
17
|
-
export declare const HL_INSERT_BEFORE = "PRE";
|
|
18
|
-
/** Insert position keyword for inserting after a concrete line. */
|
|
19
|
-
export declare const HL_INSERT_AFTER = "POST";
|
|
20
|
-
/** Insert position keyword for inserting at the start of the file. */
|
|
21
|
-
export declare const HL_INSERT_HEAD = "HEAD";
|
|
22
|
-
/** Insert position keyword for inserting at the end of the file. */
|
|
23
|
-
export declare const HL_INSERT_TAIL = "TAIL";
|
|
24
|
-
/** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
|
|
25
|
-
export declare const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
|
|
26
|
-
/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
|
|
27
|
-
export declare const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
|
|
28
|
-
/** Hunk-header keyword: `CUT N.=M` captures lines into the clipboard register and deletes them. */
|
|
12
|
+
/** Hunk-header keyword: `PUT` writes content (body rows) or a register at a span or gap. */
|
|
13
|
+
export declare const HL_PUT_KEYWORD = "PUT";
|
|
14
|
+
/** Hunk-header keyword: `CUT N.=M` / `CUT N*` deletes lines and captures them (anonymous register, or `@name` when given). */
|
|
29
15
|
export declare const HL_CUT_KEYWORD = "CUT";
|
|
30
|
-
/** Hunk-header keyword: `CUT.BLK N` captures the tree-sitter block at N and deletes its span. */
|
|
31
|
-
export declare const HL_CUT_BLOCK_KEYWORD = "CUT.BLK";
|
|
32
|
-
/** Hunk-header keyword prefix: `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` inserts the clipboard. */
|
|
33
|
-
export declare const HL_PASTE_KEYWORD = "PASTE";
|
|
34
|
-
/** Hunk-header keyword: `PASTE.BLK.POST N` inserts the clipboard after the tree-sitter block at N. */
|
|
35
|
-
export declare const HL_PASTE_AFTER_BLOCK_KEYWORD = "PASTE.BLK.POST";
|
|
36
16
|
/** File-level keyword: `REM` deletes the whole file named by the section header. */
|
|
37
17
|
export declare const HL_REM_KEYWORD = "REM";
|
|
38
18
|
/** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
|
|
39
19
|
export declare const HL_MOVE_KEYWORD = "MV";
|
|
40
20
|
export declare const HL_HEADER_COLON = ":";
|
|
21
|
+
/** Gap sigil: `<N` targets the gap before line N (`<1` = head). */
|
|
22
|
+
export declare const HL_GAP_BEFORE = "<";
|
|
23
|
+
/** Gap sigil: `>N` targets the gap after line N (`>$` = tail). */
|
|
24
|
+
export declare const HL_GAP_AFTER = ">";
|
|
25
|
+
/** Locator suffix: `N*` extends the anchor to the syntactic block opening at N. */
|
|
26
|
+
export declare const HL_BLOCK_SUFFIX = "*";
|
|
27
|
+
/** Gap anchor: `$` names the last line, so `>$` is end-of-file. */
|
|
28
|
+
export declare const HL_EOF_ANCHOR = "$";
|
|
29
|
+
/** Register sigil: `@name` selects a named clipboard register on `PUT`/`CUT`. */
|
|
30
|
+
export declare const HL_REGISTER_SIGIL = "@";
|
|
41
31
|
/** Separator between a hashline file path and its opaque snapshot tag. */
|
|
42
32
|
export declare const HL_FILE_HASH_SEP = "#";
|
|
43
|
-
/**
|
|
33
|
+
/** Canonical separator between inclusive range endpoints, e.g. `5.=10`. */
|
|
44
34
|
export declare const HL_RANGE_SEP = ".=";
|
|
45
35
|
/** Separator between a line number and displayed line content in hashline mode. */
|
|
46
36
|
export declare const HL_LINE_BODY_SEP = ":";
|
|
@@ -48,12 +38,16 @@ export declare const HL_LINE_BODY_SEP = ":";
|
|
|
48
38
|
export declare const HL_LINE_RE_RAW = "[1-9]\\d*";
|
|
49
39
|
/** Capture-group form of {@link HL_LINE_RE_RAW}. */
|
|
50
40
|
export declare const HL_LINE_CAPTURE_RE_RAW = "([1-9]\\d*)";
|
|
51
|
-
/** Format a concrete replacement hunk header. */
|
|
41
|
+
/** Format a concrete replacement hunk header (`PUT 5.=9:`). */
|
|
52
42
|
export declare function formatReplaceHeader(start: number, end: number): string;
|
|
53
|
-
/** Format a concrete cut hunk header. */
|
|
43
|
+
/** Format a concrete cut hunk header (`CUT 5.=9`). */
|
|
54
44
|
export declare function formatCutHeader(start: number, end?: number): string;
|
|
55
|
-
/** Format
|
|
45
|
+
/** Format a gap locator for a cursor position (`<5`, `>5`, `<1`, `>$`). */
|
|
46
|
+
export declare function formatGapLocator(cursor: Cursor): string;
|
|
47
|
+
/** Format an insertion hunk header for a cursor position (`PUT <5:`, `PUT >$:`). */
|
|
56
48
|
export declare function formatInsertHeader(cursor: Cursor): string;
|
|
49
|
+
/** Format a register reference (`@name`). */
|
|
50
|
+
export declare function formatRegister(name: string): string;
|
|
57
51
|
/** Number of hex characters in a content-derived file-hash tag. */
|
|
58
52
|
export declare const HL_FILE_HASH_LENGTH = 4;
|
|
59
53
|
/** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export declare function formatAnchoredContext(anchorLines: readonly number[], fi
|
|
|
11
11
|
/** Concrete range operation rejected because its absolute end precedes its start. */
|
|
12
12
|
export type AbsoluteRangeOp = "replace" | "cut";
|
|
13
13
|
/** Explain absolute range endpoints and provide safe, non-applying retry forms. */
|
|
14
|
-
export declare function invalidAbsoluteRangeMessage(patchLine: number, start: number, end: number, op: AbsoluteRangeOp, block?: BlockSpan): string;
|
|
14
|
+
export declare function invalidAbsoluteRangeMessage(patchLine: number, start: number, end: number, op: AbsoluteRangeOp, block?: BlockSpan, register?: string): string;
|
|
15
15
|
/** Optional patch envelope start marker; silently consumed. */
|
|
16
16
|
export declare const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
17
17
|
/** Optional patch envelope end marker; terminates parsing. */
|
|
@@ -21,22 +21,33 @@ export declare const END_PATCH_MARKER = "*** End Patch";
|
|
|
21
21
|
* {@link END_PATCH_MARKER}, without a warning.
|
|
22
22
|
*/
|
|
23
23
|
export declare const ABORT_MARKER = "*** Abort";
|
|
24
|
-
/**
|
|
25
|
-
export declare const REPLACE_PAIR_COALESCED_WARNING = "
|
|
24
|
+
/** Exact-range duplicate hunks were normalized to the final hunk. */
|
|
25
|
+
export declare const REPLACE_PAIR_COALESCED_WARNING = "Multiple hunks targeted the same exact range; kept only the last. Issue one `PUT` or `CUT` hunk per range.";
|
|
26
|
+
/** Replacement body indentation was aligned from unchanged structural rows. */
|
|
27
|
+
export declare const REPLACEMENT_INDENT_AUTO_SHIFT_WARNING = "Auto-indented a replacement body to match unchanged structural rows in its source range.";
|
|
26
28
|
/** Bare body rows auto-converted to literal `+` rows. */
|
|
27
29
|
export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
|
|
30
|
+
/** Top-level read-output rows recovered as single-line replacements. */
|
|
31
|
+
export declare const SNAPSHOT_ROWS_AUTO_PUT_WARNING = "Recovered top-level `N:TEXT` snapshot row(s) as single-line `PUT N.=N:` replacements. Use explicit `PUT` headers for reliable edits.";
|
|
32
|
+
/** Bare range header recovered as an implicit replacement hunk. */
|
|
33
|
+
export declare const BARE_RANGE_AUTO_PUT_WARNING = "Recovered a bare `N.=M:` header as `PUT N.=M:`. Prefix replacement ranges with `PUT`.";
|
|
34
|
+
/** Copied read-output elision rows were ignored rather than written as source. */
|
|
35
|
+
export declare const READ_METADATA_IGNORED_WARNING = "Ignored copied read-output elision row(s). Re-read elided ranges before editing them.";
|
|
36
|
+
/** Empty span/block PUT recovered as a delete-only edit. */
|
|
37
|
+
export declare const EMPTY_PUT_AUTO_CUT_WARNING = "Interpreted an empty `PUT` body as deletion. Use `CUT N.=M` or `CUT N*` for bodyless deletes.";
|
|
38
|
+
/** A bodyless CUT carried a harmless trailing colon. */
|
|
39
|
+
export declare const CUT_COLON_IGNORED_WARNING = "Ignored a trailing `:` on bodyless `CUT`. Prefer `CUT N.=M` / `CUT N*` without a colon.";
|
|
28
40
|
/**
|
|
29
41
|
* Bare `-` body rows accepted as literal Markdown bullets. Only emitted when
|
|
30
42
|
* the hunk is unambiguously a bullet list: every `-` row is bullet-shaped
|
|
43
|
+
|
|
31
44
|
* (`- item`) and the body has no unified-diff `+new` counterpart rows.
|
|
32
45
|
*/
|
|
33
46
|
export declare const MINUS_BULLET_AUTO_PIPED_WARNING = "Auto-prefixed bare `- ` bullet row(s) as literal content. `-` rows never remove lines \u2014 the range does that; always prefix literal body rows with `+`: `+- item`.";
|
|
47
|
+
/** Unified-diff old rows were discarded; explicit `+` rows are final content. */
|
|
48
|
+
export declare const DIFF_OLD_ROWS_IGNORED_WARNING = "Ignored unified-diff `-old` row(s); the range already removes old content, so only `+new` rows were kept.";
|
|
34
49
|
/** Unified-diff-style `-` row in a hunk body. */
|
|
35
50
|
export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; the range already names the lines being changed. For Markdown bullets or other literal `-` lines, prefix the literal row with `+`: `+- item`.";
|
|
36
|
-
/** Replace hunk with no body. */
|
|
37
|
-
export declare const EMPTY_REPLACE = "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `CUT N.=M`.";
|
|
38
|
-
/** `SWAP.BLK N:` hunk with no body. */
|
|
39
|
-
export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `CUT.BLK N`.";
|
|
40
51
|
/** Optional source-aware suggestions appended to block-anchor diagnostics. */
|
|
41
52
|
export interface BlockDiagnosticSuggestions {
|
|
42
53
|
/** Closest following multi-line block that begins after the authored anchor. */
|
|
@@ -47,18 +58,18 @@ export interface BlockDiagnosticSuggestions {
|
|
|
47
58
|
/**
|
|
48
59
|
* A block-anchored replace/cut could not resolve to a syntactic block.
|
|
49
60
|
* Appends a {@link formatAnchoredContext} preview when `fileLines` is given.
|
|
50
|
-
* `
|
|
61
|
+
* `PUT >N*` never reaches this path; it lowers to `PUT >N`.
|
|
51
62
|
*/
|
|
52
|
-
export declare function blockUnresolvedMessage(line: number, op?: AbsoluteRangeOp, fileLines?: readonly string[], suggestions?: BlockDiagnosticSuggestions): string;
|
|
63
|
+
export declare function blockUnresolvedMessage(line: number, op?: AbsoluteRangeOp, fileLines?: readonly string[], suggestions?: BlockDiagnosticSuggestions, register?: string): string;
|
|
53
64
|
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in. */
|
|
54
|
-
export declare const BLOCK_RESOLVER_UNAVAILABLE = "Block
|
|
55
|
-
/** `
|
|
65
|
+
export declare const BLOCK_RESOLVER_UNAVAILABLE = "Block locators (`N*` in `PUT N*:`, `PUT >N*`, `CUT N*`) are not available here (no block resolver configured). Use a concrete line range.";
|
|
66
|
+
/** `PUT >N*:` anchored on a closing-delimiter line; applied as `PUT >N:`. */
|
|
56
67
|
export declare function insertAfterBlockCloserLoweredWarning(line: number): string;
|
|
57
|
-
/** `
|
|
68
|
+
/** `PUT >N*:` anchor unresolvable; applied as `PUT >N:`. */
|
|
58
69
|
export declare function insertAfterBlockUnresolvedLoweredWarning(line: number): string;
|
|
59
|
-
/** `
|
|
70
|
+
/** Register `PUT >N*` anchored on a closing-delimiter line; applied as `PUT >N`. */
|
|
60
71
|
export declare function pasteAfterBlockCloserLoweredWarning(line: number): string;
|
|
61
|
-
/** `
|
|
72
|
+
/** Register `PUT >N*` anchor unresolvable; applied as `PUT >N`. */
|
|
62
73
|
export declare function pasteAfterBlockUnresolvedLoweredWarning(line: number): string;
|
|
63
74
|
/**
|
|
64
75
|
* A one-sided boundary echo whose payload is too short to be the widened
|
|
@@ -86,20 +97,30 @@ export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved blo
|
|
|
86
97
|
export declare const REM_TAKES_NO_BODY = "`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header.";
|
|
87
98
|
/** `MV` received a body row. */
|
|
88
99
|
export declare const MOVE_TAKES_NO_BODY = "`MV DEST` does not take body rows. Put line edits above the `MV` row; the destination path follows `MV` on the same line.";
|
|
89
|
-
/** `CUT
|
|
90
|
-
export declare const CUT_TAKES_NO_BODY = "`CUT
|
|
91
|
-
/** `
|
|
92
|
-
export declare const
|
|
93
|
-
/** `
|
|
94
|
-
export declare const
|
|
100
|
+
/** `CUT` hunk received a body row. */
|
|
101
|
+
export declare const CUT_TAKES_NO_BODY = "`CUT` deletes (and captures) the named lines and takes no body rows. To write new content, use `PUT N.=M:` with `+TEXT` rows.";
|
|
102
|
+
/** Register `PUT` header carried a `:`. */
|
|
103
|
+
export declare const COLON_ON_REGISTER_PUT = "`PUT \u2026 @name` pastes the register and never takes `:` \u2014 the colon promises body rows. Drop the colon (`PUT >40 @name`), or drop `@name` and write `+TEXT` body rows.";
|
|
104
|
+
/** Register `PUT` hunk received a body row. */
|
|
105
|
+
export declare const REGISTER_PUT_TAKES_NO_BODY = "A register `PUT` pastes captured lines and takes no `+` body rows. To write literal text, drop the `@name` and use `PUT \u2026:` with body rows.";
|
|
106
|
+
/** Colonless `PUT` hunk received a body row. */
|
|
107
|
+
export declare const COLONLESS_PUT_TAKES_NO_BODY = "`PUT` without `:` is clipboard-backed and takes no body rows. Add `:` after the locator to write literal content (`PUT >40:` then `+TEXT` rows).";
|
|
108
|
+
/** Colonless anonymous `PUT` on a span target. */
|
|
109
|
+
export declare const COLONLESS_SPAN_PUT = "Colonless `PUT` is clipboard-backed, and span targets need a named register (`PUT 5.=9 @name`); the anonymous register pastes only at gaps (`PUT >40`). To write literal content, add `:` and `+TEXT` body rows.";
|
|
110
|
+
/** Anonymous paste ran with an empty anonymous register. */
|
|
111
|
+
export declare const EMPTY_PASTE = "Nothing to paste: no unlabeled `CUT` precedes this `PUT` in this call, and the anonymous register never carries across calls. Put `CUT N.=M` / `CUT N*` above it, or use named registers (`CUT \u2026 @name` \u2192 `PUT \u2026 @name`) for cross-call moves.";
|
|
112
|
+
/** Named paste read a register that holds nothing. */
|
|
113
|
+
export declare function unknownRegisterMessage(name: string, known: readonly string[]): string;
|
|
114
|
+
/** Unlabeled paste with two or more unlabeled cuts pending. */
|
|
115
|
+
export declare function ambiguousAnonymousPasteMessage(pending: readonly string[]): string;
|
|
95
116
|
/**
|
|
96
117
|
* Clipboard ops inside a same-path section that was merged across another
|
|
97
118
|
* file's section. Same-path sections coalesce into their first occurrence, so
|
|
98
119
|
* an interleaved layout would silently reorder the register sequence.
|
|
99
120
|
*/
|
|
100
|
-
export declare const CLIPBOARD_INTERLEAVED_SECTIONS = "`CUT
|
|
101
|
-
/**
|
|
102
|
-
export declare const EMPTY_INSERT = "`
|
|
121
|
+
export declare const CLIPBOARD_INTERLEAVED_SECTIONS = "`CUT`/register-`PUT` ops cannot be used in a file whose sections are interleaved with another file's: same-path sections merge into the first occurrence, which would reorder the register sequence. Keep each file's ops under ONE `[path#TAG]` header.";
|
|
122
|
+
/** Gap `PUT` with `:` but no body. */
|
|
123
|
+
export declare const EMPTY_INSERT = "`PUT <N:` / `PUT >N:` promises body rows and got none. Write `+TEXT` rows, or drop the `:` to paste a register (`PUT >N` = anonymous, `PUT >N @name` = named).";
|
|
103
124
|
/**
|
|
104
125
|
* `insert after` body indented shallower than the anchor: the landing slid
|
|
105
126
|
* forward past trailing closer lines — the common "anchored on the last line
|
|
@@ -107,9 +128,9 @@ export declare const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row."
|
|
|
107
128
|
*/
|
|
108
129
|
export declare function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string;
|
|
109
130
|
/**
|
|
110
|
-
* `
|
|
111
|
-
*
|
|
112
|
-
*
|
|
131
|
+
* `PUT >N*:` body indented deeper than the block's closer: the landing was
|
|
132
|
+
* pulled inside the block — a deeper body almost always means "append inside
|
|
133
|
+
* the block's body".
|
|
113
134
|
*/
|
|
114
135
|
export declare function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string;
|
|
115
136
|
/** `Recovery`: an external write matched a cached snapshot. */
|
|
@@ -123,7 +144,7 @@ export declare const RECOVERY_LINE_REMAP_WARNING = "Recovered by remapping stale
|
|
|
123
144
|
* Head/tail position is content-independent, so drift is non-fatal: apply
|
|
124
145
|
* onto live content and warn instead of hard-failing.
|
|
125
146
|
*/
|
|
126
|
-
export declare const HEADTAIL_DRIFT_WARNING = "Applied the `
|
|
147
|
+
export declare const HEADTAIL_DRIFT_WARNING = "Applied the `PUT <1:`/`PUT >$:` edit despite a stale snapshot tag (file changed since your read) \u2014 head/tail position is content-independent. Re-read if the drift was unexpected.";
|
|
127
148
|
/**
|
|
128
149
|
* The `Filesystem` reported that what actually landed on disk differs from
|
|
129
150
|
* what was written (see `WriteResult.text`) — most commonly an ACP-connected
|
package/dist/types/parser.d.ts
CHANGED
|
@@ -11,7 +11,8 @@ export declare class InvalidAbsoluteRangeError extends Error {
|
|
|
11
11
|
readonly endLine: number;
|
|
12
12
|
/** Operation whose range was invalid. */
|
|
13
13
|
readonly op: AbsoluteRangeOp;
|
|
14
|
-
|
|
14
|
+
readonly register?: string;
|
|
15
|
+
constructor(patchLine: number, startLine: number, endLine: number, op: AbsoluteRangeOp, block?: BlockSpan, register?: string);
|
|
15
16
|
/** Rebuild this error with a proven syntactic-block endpoint suggestion. */
|
|
16
17
|
withBlock(block: BlockSpan): InvalidAbsoluteRangeError;
|
|
17
18
|
}
|
package/dist/types/prefixes.d.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* common case for echoed file content, and erroneously echoed prefixes will
|
|
14
14
|
* otherwise turn every content line into a (malformed) op.
|
|
15
15
|
*/
|
|
16
|
+
/** Whether a row is display-only metadata emitted by `read`, never source. */
|
|
17
|
+
export declare function isReadMetadataLine(line: string): boolean;
|
|
16
18
|
/**
|
|
17
19
|
* Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
|
|
18
20
|
* most one leading hashline prefix (`N:`, `>>>N:`, `+N:` etc.) and does NOT
|
|
@@ -6,39 +6,42 @@ export declare function parseLid(raw: string, lineNum: number): Anchor;
|
|
|
6
6
|
export type BlockTarget = {
|
|
7
7
|
kind: "replace";
|
|
8
8
|
range: ParsedRange;
|
|
9
|
+
register?: string;
|
|
9
10
|
} | {
|
|
10
11
|
kind: "block";
|
|
11
12
|
anchor: Anchor;
|
|
13
|
+
register?: string;
|
|
12
14
|
} | {
|
|
13
15
|
kind: "insert_before";
|
|
14
16
|
anchor: Anchor;
|
|
17
|
+
register?: string;
|
|
15
18
|
} | {
|
|
16
19
|
kind: "insert_after";
|
|
17
20
|
anchor: Anchor;
|
|
21
|
+
register?: string;
|
|
18
22
|
} | {
|
|
19
23
|
kind: "insert_after_block";
|
|
20
24
|
anchor: Anchor;
|
|
25
|
+
register?: string;
|
|
21
26
|
} | {
|
|
22
27
|
kind: "cut";
|
|
23
28
|
range: ParsedRange;
|
|
29
|
+
register?: string;
|
|
24
30
|
} | {
|
|
25
31
|
kind: "cut_block";
|
|
26
32
|
anchor: Anchor;
|
|
33
|
+
register?: string;
|
|
27
34
|
} | {
|
|
28
|
-
kind: "
|
|
29
|
-
|
|
35
|
+
kind: "bof";
|
|
36
|
+
register?: string;
|
|
30
37
|
} | {
|
|
31
|
-
kind: "
|
|
32
|
-
|
|
38
|
+
kind: "eof";
|
|
39
|
+
register?: string;
|
|
33
40
|
} | {
|
|
34
41
|
kind: "rem";
|
|
35
42
|
} | {
|
|
36
43
|
kind: "move";
|
|
37
44
|
dest: string;
|
|
38
|
-
} | {
|
|
39
|
-
kind: "bof";
|
|
40
|
-
} | {
|
|
41
|
-
kind: "eof";
|
|
42
45
|
};
|
|
43
46
|
interface TokenBase {
|
|
44
47
|
lineNum: number;
|
|
@@ -58,6 +61,7 @@ export type Token = (TokenBase & {
|
|
|
58
61
|
}) | (TokenBase & {
|
|
59
62
|
kind: "op-block";
|
|
60
63
|
target: BlockTarget;
|
|
64
|
+
hadColon: boolean;
|
|
61
65
|
}) | (TokenBase & {
|
|
62
66
|
kind: "payload-literal";
|
|
63
67
|
text: string;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -48,47 +48,63 @@ export type Edit = {
|
|
|
48
48
|
oldAssertion?: string;
|
|
49
49
|
} | {
|
|
50
50
|
/**
|
|
51
|
-
* Clipboard cut (`CUT N
|
|
52
|
-
* Captures the range's current lines into
|
|
51
|
+
* Clipboard cut (`CUT N-M @r`, or the resolved form of `CUT N* @r`).
|
|
52
|
+
* Captures the range's current lines into a {@link Clipboard}
|
|
53
53
|
* register during the applier's clipboard pre-pass and lowers to one
|
|
54
|
-
* `delete` per range line at parse/resolve time.
|
|
54
|
+
* `delete` per range line at parse/resolve time. `register` names the
|
|
55
|
+
* target slot; absent means the batch-local anonymous register.
|
|
55
56
|
*/
|
|
56
57
|
kind: "cut";
|
|
57
58
|
range: ParsedRange;
|
|
59
|
+
register?: string;
|
|
58
60
|
lineNum: number;
|
|
59
61
|
index: number;
|
|
60
62
|
} | {
|
|
61
63
|
/**
|
|
62
|
-
* Clipboard insertion (`
|
|
63
|
-
* `
|
|
64
|
-
* by the clipboard pre-pass into one
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* Clipboard insertion or replacement (`PUT <N @r` / `PUT >N @r` /
|
|
65
|
+
* `PUT N-M @r`, their `>N*` block-resolved forms, or the register-less
|
|
66
|
+
* anonymous equivalents). Expanded by the clipboard pre-pass into one
|
|
67
|
+
* plain insert per captured line; `span` targets additionally expand
|
|
68
|
+
* into per-line deletes there — only after the register read
|
|
69
|
+
* succeeds, so a dropped paste (streaming preview with an empty
|
|
70
|
+
* register) never leaves destructive orphan deletes. `blockStart`
|
|
71
|
+
* mirrors the insert variant's field for block-lowered gap pastes so
|
|
72
|
+
* landing correction can slide the body across trailing closer lines.
|
|
68
73
|
*/
|
|
69
74
|
kind: "paste";
|
|
70
|
-
|
|
75
|
+
at: PasteTarget;
|
|
76
|
+
register?: string;
|
|
71
77
|
lineNum: number;
|
|
72
78
|
index: number;
|
|
73
79
|
blockStart?: number;
|
|
74
80
|
} | {
|
|
75
81
|
/**
|
|
76
|
-
* Deferred block edit (`
|
|
77
|
-
* `
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* `
|
|
82
|
-
*
|
|
83
|
-
*
|
|
82
|
+
* Deferred block edit (`PUT N*:`, `PUT >N*:`, `CUT N*`, or their
|
|
83
|
+
* `@register` forms). The exact line span is unknown at parse time;
|
|
84
|
+
* {@link resolveBlockEdits} computes it once file text and language
|
|
85
|
+
* are available, then expands it into concrete edits.
|
|
86
|
+
* `mode: "insert_after"` becomes plain `after_anchor` inserts,
|
|
87
|
+
* `"cut"` becomes a clipboard cut plus per-line deletes, and
|
|
88
|
+
* `"paste_after"` becomes a gap paste after the resolved block. No
|
|
89
|
+
* mode denotes a block replacement — from body payloads, or from
|
|
90
|
+
* `register` when one is named (a span paste over the resolved block).
|
|
84
91
|
*/
|
|
85
92
|
kind: "block";
|
|
86
93
|
anchor: Anchor;
|
|
87
94
|
payloads: string[];
|
|
88
95
|
mode?: "insert_after" | "cut" | "paste_after";
|
|
96
|
+
register?: string;
|
|
89
97
|
lineNum: number;
|
|
90
98
|
index: number;
|
|
91
99
|
};
|
|
100
|
+
/** Where a `paste` edit lands: an insertion gap, or a span it replaces. */
|
|
101
|
+
export type PasteTarget = {
|
|
102
|
+
kind: "gap";
|
|
103
|
+
cursor: Cursor;
|
|
104
|
+
} | {
|
|
105
|
+
kind: "span";
|
|
106
|
+
range: ParsedRange;
|
|
107
|
+
};
|
|
92
108
|
/** File-level operation parsed from a section body (`REM` / `MV`). */
|
|
93
109
|
export type FileOp = {
|
|
94
110
|
kind: "rem";
|
|
@@ -111,7 +127,7 @@ export interface ApplyResult {
|
|
|
111
127
|
*/
|
|
112
128
|
blockResolutions?: BlockResolution[];
|
|
113
129
|
}
|
|
114
|
-
/** A parsed `
|
|
130
|
+
/** A parsed `A-B` inclusive line range. */
|
|
115
131
|
export interface ParsedRange {
|
|
116
132
|
start: Anchor;
|
|
117
133
|
end: Anchor;
|
|
@@ -187,13 +203,25 @@ export interface BlockResolverRequest {
|
|
|
187
203
|
*/
|
|
188
204
|
export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
|
|
189
205
|
/**
|
|
190
|
-
* Mutable clipboard
|
|
191
|
-
* by `CUT` edits and read by `
|
|
192
|
-
* sections, so content can move between files.
|
|
193
|
-
*
|
|
194
|
-
*
|
|
206
|
+
* Mutable clipboard registers threaded through one patch application. Filled
|
|
207
|
+
* by `CUT` edits and read by register `PUT`s in patch source order, across
|
|
208
|
+
* sections, so content can move between files.
|
|
209
|
+
*
|
|
210
|
+
* The anonymous register (`lines`) is batch-local: {@link forkClipboard}
|
|
211
|
+
* never copies it in, so it exists only between a `CUT` and a paste inside
|
|
212
|
+
* one patch. Named registers (`named`) persist across batches when the host
|
|
213
|
+
* owns the Clipboard (`PatcherOptions.clipboard`) — the sanctioned way to
|
|
214
|
+
* move content across separate edit calls.
|
|
195
215
|
*/
|
|
196
216
|
export interface Clipboard {
|
|
197
|
-
/**
|
|
217
|
+
/** Anonymous register: lines captured by the latest unlabeled `CUT` in this batch. */
|
|
198
218
|
lines?: readonly string[];
|
|
219
|
+
/** Named registers captured by `CUT … @name`. */
|
|
220
|
+
named?: Map<string, readonly string[]>;
|
|
221
|
+
/**
|
|
222
|
+
* Headers of unlabeled `CUT`s seen since the last unlabeled paste in this
|
|
223
|
+
* batch. Two or more make the next unlabeled paste ambiguous (which cut
|
|
224
|
+
* did the author mean?) and fail it with a labeling hint.
|
|
225
|
+
*/
|
|
226
|
+
pendingAnonCuts?: string[];
|
|
199
227
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "17.2.
|
|
4
|
+
"version": "17.2.2",
|
|
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"fmt": "biome format --write ."
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@oh-my-pi/pi-natives": "17.2.
|
|
36
|
+
"@oh-my-pi/pi-natives": "17.2.2",
|
|
37
37
|
"lru-cache": "11.5.2"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
package/src/apply.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ambiguousBoundaryEchoMessage,
|
|
15
15
|
ambiguousCloserSpareMessage,
|
|
16
16
|
blockInsertLandingShiftWarning,
|
|
17
|
+
REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
|
|
17
18
|
UNRESOLVED_BLOCK_INTERNAL,
|
|
18
19
|
} from "./messages";
|
|
19
20
|
import { cloneCursor } from "./tokenizer";
|
|
@@ -390,6 +391,66 @@ function findReplacementGroup(edits: readonly AppliedEdit[], start: number): Rep
|
|
|
390
391
|
};
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Restore a uniformly omitted base indent only when the payload would escape
|
|
396
|
+
* a surviving `{` opener immediately above the replacement. Matching unchanged
|
|
397
|
+
* rows then prove the uniform shift; ordinary indentation-only edits stay exact.
|
|
398
|
+
*/
|
|
399
|
+
function repairReplacementIndentation(edits: AppliedEdit[], fileLines: readonly string[]): string[] {
|
|
400
|
+
let repaired = false;
|
|
401
|
+
for (let start = 0; start < edits.length; ) {
|
|
402
|
+
const group = findReplacementGroup(edits, start);
|
|
403
|
+
if (group === undefined) {
|
|
404
|
+
start++;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const lastDeleteIndex = group.deleteIndices.at(-1);
|
|
408
|
+
if (lastDeleteIndex === undefined) continue;
|
|
409
|
+
start = lastDeleteIndex + 1;
|
|
410
|
+
if (group.payload.length !== group.deleteIndices.length) continue;
|
|
411
|
+
const preceding = fileLines[group.startLine - 2] ?? "";
|
|
412
|
+
const sourceFirst = fileLines[group.startLine - 1] ?? "";
|
|
413
|
+
const payloadFirst = group.payload[0] ?? "";
|
|
414
|
+
if (
|
|
415
|
+
!preceding.trimEnd().endsWith("{") ||
|
|
416
|
+
!isIndentDeeper(leadingIndent(sourceFirst), leadingIndent(preceding)) ||
|
|
417
|
+
isIndentDeeper(leadingIndent(payloadFirst), leadingIndent(preceding))
|
|
418
|
+
) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let shift: string | undefined;
|
|
423
|
+
let matches = 0;
|
|
424
|
+
let consistent = true;
|
|
425
|
+
for (let offset = 0; offset < group.payload.length; offset++) {
|
|
426
|
+
const source = fileLines[group.startLine - 1 + offset] ?? "";
|
|
427
|
+
const payload = group.payload[offset];
|
|
428
|
+
if (source.trim().length === 0 || source.trimStart() !== payload.trimStart()) continue;
|
|
429
|
+
const sourceIndent = leadingIndent(source);
|
|
430
|
+
const payloadIndent = leadingIndent(payload);
|
|
431
|
+
if (!sourceIndent.endsWith(payloadIndent)) {
|
|
432
|
+
consistent = false;
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
const candidate = sourceIndent.slice(0, sourceIndent.length - payloadIndent.length);
|
|
436
|
+
if (shift === undefined) shift = candidate;
|
|
437
|
+
else if (shift !== candidate) {
|
|
438
|
+
consistent = false;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
matches++;
|
|
442
|
+
}
|
|
443
|
+
if (!consistent || !shift || matches < 2 || matches * 2 <= group.payload.length) continue;
|
|
444
|
+
for (const index of group.insertIndices) {
|
|
445
|
+
const edit = edits[index];
|
|
446
|
+
if (edit.kind !== "insert" || edit.text.trim().length === 0) continue;
|
|
447
|
+
edits[index] = { ...edit, text: `${shift}${edit.text}` };
|
|
448
|
+
}
|
|
449
|
+
repaired = true;
|
|
450
|
+
}
|
|
451
|
+
return repaired ? [REPLACEMENT_INDENT_AUTO_SHIFT_WARNING] : [];
|
|
452
|
+
}
|
|
453
|
+
|
|
393
454
|
/**
|
|
394
455
|
* Largest `k` such that the payload's last `k` lines exactly equal the `k`
|
|
395
456
|
* surviving file lines just below the range AND dropping them zeroes `delta`.
|
|
@@ -927,6 +988,15 @@ function repairReplacementBoundaries(
|
|
|
927
988
|
after: new Map(),
|
|
928
989
|
};
|
|
929
990
|
for (const edit of projected) {
|
|
991
|
+
if (edit.kind === "insert" && edit.cursor.kind === "bof") {
|
|
992
|
+
const inserted = insertedByLine.get(1);
|
|
993
|
+
if (inserted) inserted.push(edit.text);
|
|
994
|
+
else insertedByLine.set(1, [edit.text]);
|
|
995
|
+
const before = insertedLineMaps.before.get(1);
|
|
996
|
+
if (before) before.push(edit.text);
|
|
997
|
+
else insertedLineMaps.before.set(1, [edit.text]);
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
930
1000
|
if (edit.kind !== "insert") continue;
|
|
931
1001
|
for (const anchor of getCursorAnchors(edit.cursor)) {
|
|
932
1002
|
const lines = insertedByLine.get(anchor.line);
|
|
@@ -1272,9 +1342,10 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1272
1342
|
fileLines,
|
|
1273
1343
|
);
|
|
1274
1344
|
validateLineBounds(targetEdits, fileLines);
|
|
1345
|
+
const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
|
|
1275
1346
|
const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
1276
1347
|
const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
|
|
1277
|
-
const warnings = [...boundaryWarnings, ...landingWarnings];
|
|
1348
|
+
const warnings = [...indentationWarnings, ...boundaryWarnings, ...landingWarnings];
|
|
1278
1349
|
|
|
1279
1350
|
// Partition edits into bof, eof, and anchor-targeted buckets.
|
|
1280
1351
|
const bofLines: string[] = [];
|