feat(hashline)!: required + prefix on payload continuation lines
- Changed multiline payload syntax so continuation lines must start with `+`; that prefix is stripped before writing. - Raw unprefixed lines after an op now throw an error instead of being silently accepted as payload. - Removed `PAYLOAD_LINE_PREFIX_DEMOTED_WARNING` and the nested-replace demotion path; inner `N:` ops inside a pending `A-B:` now raise an overlap error. - Raw blank lines between ops are ignored; use `+` alone for an empty payload line.
This commit is contained in:
+9
-7
@@ -9,7 +9,7 @@
|
||||
- `packages/coding-agent/src/utils/edit-mode.ts` — selects active edit mode
|
||||
- `packages/coding-agent/src/hashline/grammar.lark` — custom-tool grammar for hashline mode
|
||||
- `packages/coding-agent/src/hashline/input.ts` — splits `¶PATH` sections
|
||||
- `packages/coding-agent/src/hashline/parser.ts` — parses op-prefixed edits and verbatim payload lines
|
||||
- `packages/coding-agent/src/hashline/executor.ts` / `tokenizer.ts` — parses op-prefixed edits and `+`-prefixed payload continuation lines
|
||||
- `packages/coding-agent/src/hashline/apply.ts` — validates anchors and applies edits
|
||||
- `packages/coding-agent/src/hashline/anchors.ts` — stale-anchor mismatch formatting
|
||||
- `packages/coding-agent/src/hashline/recovery.ts` — cache-based stale-anchor recovery
|
||||
@@ -37,7 +37,7 @@ Patch language inside `input`:
|
||||
- Single-line replace sugar: `A:[payload]` means `A-A:[payload]`
|
||||
- Delete range: `A-B!`
|
||||
- Single-line delete sugar: `A!` means `A-A!`
|
||||
- **Payload semantics:** the first payload line is whatever follows the sigil on the op line itself; additional payload lines follow on subsequent lines and append after the inline first line. An empty inline (just the sigil followed by a newline) means the first payload line is empty. So bare `A↑` / `A↓` insert one blank line, and bare `A:` / `A-B:` replace the line/range with one blank line. But `A↓\nfoo` inserts blank-then-`foo`, not just `foo` — for a single-line insert, put `foo` inline as `A↓foo`.
|
||||
- **Payload semantics:** the first payload line may follow the sigil on the op line itself. Additional payload lines must be on subsequent lines prefixed with `+`; that delimiter is stripped before writing. Use `+` alone for an empty payload line, and `++text` to write a payload line that begins with `+text`. Bare `A↑` / `A↓` insert one blank line, and bare `A:` / `A-B:` replace the line/range with one blank line.
|
||||
- `!` deletes and forbids payload.
|
||||
- Read lines like `84:content` are already valid single-line replacements.
|
||||
- Special anchors: `BOF`, `EOF` (both support inline payload, e.g. `BOF↓export const done = true;`).
|
||||
@@ -75,11 +75,11 @@ Warnings:
|
||||
2. `executeHashlineSingle()` in `packages/coding-agent/src/hashline/execute.ts` splits the raw `input` into `¶PATH#HASH` / `¶PATH` sections with `splitHashlineInputs()`.
|
||||
3. If multiple sections target the same path, `mergeSamePathSections()` concatenates them before execution so every op still refers to the original file snapshot.
|
||||
4. Multi-section calls run a preflight pass (`preflightHashlineSection()`): parse ops, enforce plan-mode write rules, load the current file, reject anchor-scoped edits against missing files, reject auto-generated files, apply edits in memory, and fail if the result is a no-op. This prevents partial batches.
|
||||
5. `parseHashlineWithWarnings()` in `packages/coding-agent/src/hashline/parser.ts` tokenizes the diff body:
|
||||
- ignores blank lines and optional `*** Begin Patch`
|
||||
5. `parseHashline()` in `packages/coding-agent/src/hashline/executor.ts` tokenizes the diff body:
|
||||
- ignores raw blank lines and optional `*** Begin Patch`
|
||||
- stops at `*** End Patch`
|
||||
- stops at `*** Abort` and emits `ABORT_WARNING`
|
||||
- turns `↓` / `↑` payload runs (inline plus subsequent lines) into one `insert` edit per payload line
|
||||
- turns `↓` / `↑` payload runs (inline plus `+`-prefixed subsequent lines) into one `insert` edit per payload line
|
||||
- turns `A-B:` with payload into inserts before `A`, then deletes for `A-B`
|
||||
- turns `A-B!` into one `delete` edit per line in the range; payload is forbidden
|
||||
6. `executeHashlineSingle()` computes the current file hash before applying anchored edits. If it differs from the section `#HASH`, recovery tries the read/search snapshot cache before any write.
|
||||
@@ -103,7 +103,7 @@ Warnings:
|
||||
- `patch` — structured JSON diff-hunk mode (`packages/coding-agent/src/edit/modes/patch.ts`).
|
||||
- `apply_patch` — freeform Codex-style `*** Begin Patch` envelope, internally expanded into patch-mode entries (`packages/coding-agent/src/edit/modes/apply-patch.ts`).
|
||||
|
||||
Hashline op examples (single-line payloads are inline; multi-line payloads continue on subsequent lines):
|
||||
Hashline op examples (single-line payloads are inline; multi-line payloads continue on `+`-prefixed subsequent lines):
|
||||
|
||||
```text
|
||||
¶src/a.ts#1a2b
|
||||
@@ -123,7 +123,7 @@ Hashline op examples (single-line payloads are inline; multi-line payloads conti
|
||||
```text
|
||||
¶src/a.ts#1a2b
|
||||
4-5:const clean = (name || DEF).trim();
|
||||
return clean.length === 0 ? DEF : clean.toUpperCase();
|
||||
+return clean.length === 0 ? DEF : clean.toUpperCase();
|
||||
```
|
||||
|
||||
```text
|
||||
@@ -210,6 +210,8 @@ Multi-file example:
|
||||
- `line N: range A-B ends before it starts.`
|
||||
- Payload forbidden for `!`:
|
||||
- `line N: ! deletes only. Payload is forbidden after !; use : to replace.`
|
||||
- Missing `+` on a continuation line:
|
||||
- `line N: payload continuation lines must start with +.`
|
||||
- Stray payload line:
|
||||
- `line N: payload line has no preceding ↑, ↓, :, or ! operation.`
|
||||
- Unknown op:
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Breaking Changes
|
||||
|
||||
- Changed the hashline patch format so payload continuation lines now require a leading `+`, rejecting unprefixed multiline payload rows that were previously accepted as fallback payload text
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed hashline payload parsing so blank lines are only preserved when prefixed with `+`, so blank separator lines between operations are ignored unless explicitly marked
|
||||
- Changed payload escaping so a line beginning with `+` is now represented as `++...` while the leading marker is stripped before writing
|
||||
- Changed the default `task.simple` mode from `default` to `schema-free`, so task-call `schema` inputs are disabled by default while shared `context` and user prompt/session-defined output schemas remain available
|
||||
- Changed `tools.approvalMode: yolo` to auto-approve tool calls even when a tool marks `override: true`; user `tools.approval.<tool>` policies (`allow`/`prompt`/`deny`) now remain the only controls for yolo mode.
|
||||
- Changed the hashline edit executor to coalesce two consecutive `A-B:` ops on the identical range last-wins (the model painted a before/after pair) and append a warning, instead of throwing `anchor line X is already targeted by the :/! op on line Y`. Other overlap shapes (different ranges, `A-B:`+`!`, `!`+`!`) still throw.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed nested replace parsing so line-anchored `N:` rows inside a pending `A-B:` replacement now trigger overlap errors instead of being silently folded into the replacement payload
|
||||
|
||||
## [15.5.1] - 2026-05-26
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -388,6 +388,9 @@ function buildHashlineNaturalOrderPreviews(
|
||||
case "abort":
|
||||
case "op-delete":
|
||||
continue;
|
||||
case "blank":
|
||||
case "raw":
|
||||
continue;
|
||||
case "header":
|
||||
currentPath = token.path;
|
||||
if (currentPath) ensure(currentPath);
|
||||
@@ -404,10 +407,6 @@ function buildHashlineNaturalOrderPreviews(
|
||||
if (!currentPath || token.inlineBody === undefined) continue;
|
||||
ensure(currentPath).push(`+${token.inlineBody}`);
|
||||
continue;
|
||||
case "blank":
|
||||
if (!currentPath) continue;
|
||||
ensure(currentPath).push("+");
|
||||
continue;
|
||||
case "payload":
|
||||
if (!currentPath) continue;
|
||||
ensure(currentPath).push(`+${token.text}`);
|
||||
|
||||
@@ -28,14 +28,3 @@ export const ABORT_WARNING =
|
||||
*/
|
||||
export const REPLACE_PAIR_COALESCED_WARNING =
|
||||
"Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range — the payload is the final desired content, never both old and new.";
|
||||
|
||||
/**
|
||||
* Warning text appended when a single-line replace op like `83: content`
|
||||
* arrives while a multi-line replace `A-B:` is still pending and `83` is
|
||||
* inside `A-B`. The model used the read-output `LINE:TEXT` format as if it
|
||||
* were a payload-continuation line; we strip the `LINE:` prefix and treat
|
||||
* `content` as the next payload line, but warn so the model learns the
|
||||
* cleaner format on its own.
|
||||
*/
|
||||
export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
|
||||
"Detected one or more `LINE:TEXT` lines whose anchors fell inside the pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside a multi-line `A-B:` block, payload lines after the first do not need a line-number prefix.";
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ABORT_WARNING, PAYLOAD_LINE_PREFIX_DEMOTED_WARNING, REPLACE_PAIR_COALESCED_WARNING } from "./constants";
|
||||
import { HL_OP_CHARS, HL_OP_DELETE, HL_OP_INSERT_AFTER, HL_OP_INSERT_BEFORE, HL_OP_REPLACE } from "./hash";
|
||||
import { ABORT_WARNING, REPLACE_PAIR_COALESCED_WARNING } from "./constants";
|
||||
import {
|
||||
HL_OP_CHARS,
|
||||
HL_OP_DELETE,
|
||||
HL_OP_INSERT_AFTER,
|
||||
HL_OP_INSERT_BEFORE,
|
||||
HL_OP_REPLACE,
|
||||
HL_PAYLOAD_PREFIX,
|
||||
} from "./hash";
|
||||
import {
|
||||
cloneCursor,
|
||||
type HashlineToken,
|
||||
@@ -19,10 +26,6 @@ function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
|
||||
return a.start.line === b.start.line && a.end.line === b.end.line;
|
||||
}
|
||||
|
||||
function rangeContains(outer: ParsedRange, inner: ParsedRange): boolean {
|
||||
return outer.start.line <= inner.start.line && inner.end.line <= outer.end.line;
|
||||
}
|
||||
|
||||
function expandRange(range: ParsedRange): Anchor[] {
|
||||
const anchors: Anchor[] = [];
|
||||
for (let line = range.start.line; line <= range.end.line; line++) {
|
||||
@@ -91,11 +94,13 @@ export class HashlineExecutor {
|
||||
this.#flushPending();
|
||||
return;
|
||||
case "blank":
|
||||
if (this.#pending) this.#pending.payload.push("");
|
||||
return;
|
||||
case "payload":
|
||||
this.#handlePayload(token.text, token.lineNum);
|
||||
return;
|
||||
case "raw":
|
||||
this.#handleRaw(token.text, token.lineNum);
|
||||
return;
|
||||
case "op-delete":
|
||||
this.#flushPending();
|
||||
if (token.trailingPayload) {
|
||||
@@ -112,51 +117,38 @@ export class HashlineExecutor {
|
||||
this.#flushPending();
|
||||
this.#pending = {
|
||||
op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
|
||||
payload: [token.inlineBody ?? ""],
|
||||
payload: token.inlineBody === undefined ? [] : [token.inlineBody],
|
||||
};
|
||||
return;
|
||||
case "op-replace":
|
||||
validateRangeOrder(token.range, token.lineNum);
|
||||
if (this.#pending !== undefined && this.#pending.op.kind === "replace") {
|
||||
const outer = this.#pending.op.range;
|
||||
const inner = token.range;
|
||||
if (rangesEqual(outer, inner)) {
|
||||
// Identical-range before/after pair. Drop the "before" payload
|
||||
// silently; the second op proceeds as the lone winner. Other
|
||||
// overlap shapes (different ranges, replace+delete,
|
||||
// delete+delete) still hit the post-hoc validator.
|
||||
this.#pending = undefined;
|
||||
if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
|
||||
this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
|
||||
}
|
||||
} else if (rangeContains(outer, inner)) {
|
||||
// Model wrote a payload line in read-output `LINE:TEXT` format
|
||||
// (or `A-B:TEXT` for a sub-range) inside an outer `A-B:` block.
|
||||
// The tokenizer can't tell payload from op when the anchor and
|
||||
// sigil shape are identical, so demote: append the op's inline
|
||||
// body to the pending payload, strip the `LINE:` prefix, and
|
||||
// keep accumulating. Without this the inner anchors would each
|
||||
// register as their own delete and clash with the outer range.
|
||||
this.#pending.payload.push(token.inlineBody ?? "");
|
||||
if (!this.#warnings.includes(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING)) {
|
||||
this.#warnings.push(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING);
|
||||
}
|
||||
return;
|
||||
if (
|
||||
this.#pending !== undefined &&
|
||||
this.#pending.op.kind === "replace" &&
|
||||
rangesEqual(this.#pending.op.range, token.range)
|
||||
) {
|
||||
// Identical-range before/after pair. Drop the "before" payload
|
||||
// silently; the second op proceeds as the lone winner. Other
|
||||
// overlap shapes (different ranges, replace+delete, delete+delete)
|
||||
// still hit the post-hoc validator.
|
||||
this.#pending = undefined;
|
||||
if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
|
||||
this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
|
||||
}
|
||||
}
|
||||
this.#flushPending();
|
||||
this.#pending = {
|
||||
op: { kind: "replace", range: token.range, lineNum: token.lineNum },
|
||||
payload: [token.inlineBody ?? ""],
|
||||
payload: token.inlineBody === undefined ? [] : [token.inlineBody],
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any open pending op (with its full accumulated payload, blanks
|
||||
* included) and return the accumulated edits and warnings. The executor
|
||||
* is single-use; reset() is required for reuse.
|
||||
* Flush any open pending op (with its full accumulated payload, including
|
||||
* explicit `+` blank lines) and return the accumulated edits and warnings.
|
||||
* The executor is single-use; reset() is required for reuse.
|
||||
* Throws if two replace/delete ops target the same line with non-identical
|
||||
* shapes (different ranges, replace+delete, delete+delete). Identical-range
|
||||
* `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
|
||||
@@ -214,10 +206,25 @@ export class HashlineExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Whitespace-only payload outside any pending op is silently dropped;
|
||||
throw new Error(
|
||||
`line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
|
||||
`Got ${JSON.stringify(`${HL_PAYLOAD_PREFIX}${text}`)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
#handleRaw(text: string, lineNum: number): void {
|
||||
if (this.#pending) {
|
||||
if (text.trim().length === 0) return;
|
||||
throw new Error(
|
||||
`line ${lineNum}: payload continuation lines must start with ${HL_PAYLOAD_PREFIX}. ` +
|
||||
`Got ${JSON.stringify(text)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Whitespace-only raw lines outside any pending op are silently dropped;
|
||||
// fully empty lines arrive as `blank` tokens.
|
||||
if (text.trim().length === 0) return;
|
||||
// Orphan payload outside any pending op: pick the most specific
|
||||
// Orphan raw text outside any pending op: pick the most specific
|
||||
// diagnostic so the model sees the actionable hint.
|
||||
if (isDeleteOpWithPayload(text)) {
|
||||
throw new Error(
|
||||
@@ -245,7 +252,7 @@ export class HashlineExecutor {
|
||||
if (!pending) return;
|
||||
|
||||
const { op, payload } = pending;
|
||||
const linesToInsert = payload;
|
||||
const linesToInsert = payload.length === 0 ? [""] : payload;
|
||||
|
||||
if (op.kind === "insert") {
|
||||
for (const text of linesToInsert) {
|
||||
|
||||
@@ -14,7 +14,7 @@ insert_after: anchor "$HOP_INSERT_AFTER$" inline_body? LF payload*
|
||||
replace: range "$HOP_REPLACE$" inline_body? LF payload*
|
||||
delete: range "$HOP_DELETE$" LF
|
||||
inline_body: /[^\n]+/
|
||||
payload: /(.*)/ LF
|
||||
payload: "+" /[^\n]*/ LF
|
||||
|
||||
anchor: LID | "EOF" | "BOF"
|
||||
range: LID ("-" LID)?
|
||||
|
||||
@@ -7,12 +7,12 @@ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g,
|
||||
|
||||
/**
|
||||
* Decoration prefix that may precede a line number in tool output:
|
||||
* `>` (context line in grep), `+` (added line in diff), `-` (removed line),
|
||||
* `*` (match line). Any combination, in any order, surrounded by optional
|
||||
* `>` (context line in grep), `-` (removed line), `*` (match line).
|
||||
* Any combination, in any order, surrounded by optional
|
||||
* whitespace. Output formatters emit at most one decoration per line; the
|
||||
* parser stays liberal because it accepts whatever the model echoes back.
|
||||
*/
|
||||
export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>+\\-*]*\\s*`;
|
||||
export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>\\-*]*\\s*`;
|
||||
|
||||
/** Capture-group regex source for a decorated bare line-number anchor. */
|
||||
export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
|
||||
@@ -74,9 +74,9 @@ export function resolveHashlineGrammarPlaceholders(grammar: string): string {
|
||||
/**
|
||||
* op lines have an `ANCHOR<SIGIL>[INLINE_PAYLOAD]` shape, where SIGIL is one of
|
||||
* {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, {@link HL_OP_REPLACE},
|
||||
* or {@link HL_OP_DELETE}.
|
||||
* Multi-line payloads follow on subsequent lines as verbatim file content with no
|
||||
* per-line marker.
|
||||
* or {@link HL_OP_DELETE}. Multi-line payloads follow on subsequent lines
|
||||
* prefixed with {@link HL_PAYLOAD_PREFIX}; that prefix is stripped before the
|
||||
* payload is written.
|
||||
*
|
||||
* These constants are the single source of truth for the edit parser, grammar,
|
||||
* renderer, and prompt.
|
||||
@@ -86,6 +86,9 @@ export const HL_OP_INSERT_AFTER = "↓";
|
||||
export const HL_OP_REPLACE = ":";
|
||||
export const HL_OP_DELETE = "!";
|
||||
|
||||
/** Prefix for payload continuation lines. The prefix itself is not written. */
|
||||
export const HL_PAYLOAD_PREFIX = "+";
|
||||
|
||||
/** All hashline edit op sigils, concatenated for fast membership tests. */
|
||||
export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`;
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ function replaySessionChainOnCurrent(
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function buildSparseOverlayText(currentText: string, snapshotLines: ReadonlyMap<number, string>): string {
|
||||
const overlaid = currentText.split("\n");
|
||||
let maxCachedLine = 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
HL_OP_INSERT_AFTER,
|
||||
HL_OP_INSERT_BEFORE,
|
||||
HL_OP_REPLACE,
|
||||
HL_PAYLOAD_PREFIX,
|
||||
} from "./hash";
|
||||
import type { Anchor, HashlineCursor } from "./types";
|
||||
|
||||
@@ -20,6 +21,7 @@ const CHAR_SPACE = 32;
|
||||
const CHAR_LOWER_A = 97;
|
||||
const CHAR_LOWER_F = 102;
|
||||
const CHAR_PILCROW = HL_FILE_PREFIX.charCodeAt(0);
|
||||
const CHAR_PAYLOAD_PREFIX = HL_PAYLOAD_PREFIX.charCodeAt(0);
|
||||
const FILE_HASH_LENGTH = 4;
|
||||
|
||||
function isDigitCode(code: number): boolean {
|
||||
@@ -31,7 +33,7 @@ function isNonZeroDigitCode(code: number): boolean {
|
||||
}
|
||||
|
||||
function isDecorationCode(code: number): boolean {
|
||||
return code === 42 || code === 43 || code === 45 || code === 62;
|
||||
return code === 42 || code === 45 || code === 62;
|
||||
}
|
||||
|
||||
function isHexDigitCode(code: number): boolean {
|
||||
@@ -91,7 +93,7 @@ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
|
||||
}
|
||||
|
||||
// Leniently accept anchors copied from read/search output:
|
||||
// - optional leading line-marker decoration (`*`, `>`, `+`, `-`)
|
||||
// - optional leading line-marker decoration (`*`, `>`, `-`)
|
||||
// - the required bare line number
|
||||
function skipDecoratedAnchorPrefix(line: string, end = trimEndIndex(line)): number {
|
||||
let index = skipWhitespace(line, 0, end);
|
||||
@@ -339,7 +341,8 @@ export type HashlineToken =
|
||||
| (TokenBase & { kind: "op-insert"; cursor: HashlineCursor; inlineBody: string | undefined })
|
||||
| (TokenBase & { kind: "op-replace"; range: ParsedRange; inlineBody: string | undefined })
|
||||
| (TokenBase & { kind: "op-delete"; range: ParsedRange; trailingPayload: boolean })
|
||||
| (TokenBase & { kind: "payload"; text: string });
|
||||
| (TokenBase & { kind: "payload"; text: string })
|
||||
| (TokenBase & { kind: "raw"; text: string });
|
||||
|
||||
function classifyLine(line: string, lineNum: number): HashlineToken {
|
||||
if (isEmptyLine(line)) return { kind: "blank", lineNum };
|
||||
@@ -356,6 +359,9 @@ function classifyLine(line: string, lineNum: number): HashlineToken {
|
||||
}
|
||||
}
|
||||
|
||||
if (line.charCodeAt(0) === CHAR_PAYLOAD_PREFIX) {
|
||||
return { kind: "payload", lineNum, text: line.slice(HL_PAYLOAD_PREFIX.length) };
|
||||
}
|
||||
const op = tryParseOp(line);
|
||||
if (op !== null) {
|
||||
if (op.kind === "insert") {
|
||||
@@ -367,7 +373,7 @@ function classifyLine(line: string, lineNum: number): HashlineToken {
|
||||
return { kind: "op-delete", lineNum, range: op.range, trailingPayload: op.trailingPayload };
|
||||
}
|
||||
|
||||
return { kind: "payload", lineNum, text: line };
|
||||
return { kind: "raw", lineNum, text: line };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,9 +3,9 @@ Your patch language is a compact, line-anchored edit format.
|
||||
<payload>
|
||||
Patch payload is a series of hunks: `¶PATH#HASH` header followed by any number of operations. `HASH` should be copied as is from read/search. Missing? Re-`read`.
|
||||
- No context rows, no gutters.
|
||||
- NEVER prefix payload with diff syntax.
|
||||
- NEVER restate unchanged lines "for context".
|
||||
- Payload indentation is literal.
|
||||
- Inline payload after an op is literal. Additional payload lines MUST start with `+`; that delimiter is stripped.
|
||||
- Payload indentation after the op sigil or after `+` is literal.
|
||||
</payload>
|
||||
|
||||
<ops>
|
||||
@@ -13,10 +13,12 @@ LINE↑PAYLOAD insert before (or BOF↑)
|
||||
LINE↓PAYLOAD insert after (or EOF↓)
|
||||
A-B:PAYLOAD replace A..B (or A: == A..A)
|
||||
A-B! delete A..B (or A! == A..A)
|
||||
+PAYLOAD continuation payload line; leading `+` is not written
|
||||
</ops>
|
||||
|
||||
<rules>
|
||||
- **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors.
|
||||
- **Continuation lines require `+`.** Use `+` for a blank payload line; use `++text` to write a line starting with `+text`.
|
||||
- **Go small.** Add → `↑`/`↓`; replace → `:`; delete → `!`.
|
||||
- **Line numbers are frozen references to what you have seen.** Later ops still use original line numbers.
|
||||
</rules>
|
||||
@@ -33,11 +35,12 @@ A-B! delete A..B (or A! == A..A)
|
||||
2:export function f() { return X; }
|
||||
```
|
||||
|
||||
# replace, insert after, delete
|
||||
# replace with a continuation line, insert after, delete
|
||||
```
|
||||
¶a.ts#1a2b
|
||||
1:const X = "b";
|
||||
1↓const Y = "c";
|
||||
+export const Y = X;
|
||||
1↓const Z = Y;
|
||||
2!
|
||||
```
|
||||
</example>
|
||||
@@ -48,12 +51,13 @@ A-B! delete A..B (or A! == A..A)
|
||||
# WRONG — echoing read-style lines as context before the real op
|
||||
1:const X = "a";
|
||||
1-2:const X = "b";
|
||||
export const Y = X;
|
||||
export const Y = X; # raw continuation line missing required `+`
|
||||
</anti-pattern>
|
||||
|
||||
<critical>
|
||||
- One op per range, ever.
|
||||
- Pick op precisely. Update: `:`, add: `↑`/`↓`, remove: `!`.
|
||||
- Payload is only what's NEW; never repeat anchor lines or neighbors.
|
||||
- Continuation payload lines after the op line must start with `+`.
|
||||
- Anchor exactly; don't anchor neighbors.
|
||||
</critical>
|
||||
|
||||
@@ -27,6 +27,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
const pl = (text: string): string => text;
|
||||
const extra = (text: string): string => `+${text}`;
|
||||
const outputSep = ":";
|
||||
const outputSepRe = ":";
|
||||
|
||||
@@ -111,14 +112,18 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
const single = `${tag(2, "bbb")}:BBB`;
|
||||
expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc");
|
||||
|
||||
const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}:BBB`, pl("CCC")].join("\n");
|
||||
const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}:BBB`, extra("CCC")].join("\n");
|
||||
expect(applyDiff(content, range)).toBe("aaa\nBBB\nCCC");
|
||||
});
|
||||
|
||||
it("treats single-anchor replace sugar as equivalent to an explicit one-line range", () => {
|
||||
const anchor = tag(2, "bbb");
|
||||
expect(parseHashline(`${anchor}:\nBBB`).edits).toEqual(parseHashline(`${anchor}-${anchor}:\nBBB`).edits);
|
||||
expect(applyDiff(content, `${anchor}:\nBBB`)).toBe(applyDiff(content, `${anchor}-${anchor}:\nBBB`));
|
||||
expect(parseHashline(`${anchor}:BBB\n${extra("CCC")}`).edits).toEqual(
|
||||
parseHashline(`${anchor}-${anchor}:BBB\n${extra("CCC")}`).edits,
|
||||
);
|
||||
expect(applyDiff(content, `${anchor}:BBB\n${extra("CCC")}`)).toBe(
|
||||
applyDiff(content, `${anchor}-${anchor}:BBB\n${extra("CCC")}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts an inline payload on the op line as the first/only payload line", () => {
|
||||
@@ -130,16 +135,23 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
|
||||
it("combines an inline payload with subsequent payload lines on insert ops", () => {
|
||||
const anchor = tag(2, "bbb");
|
||||
const diff = [`${anchor}↓first inline`, pl("second from next line")].join("\n");
|
||||
const diff = [`${anchor}↓first inline`, extra("second from next line")].join("\n");
|
||||
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nfirst inline\nsecond from next line\nccc");
|
||||
});
|
||||
|
||||
it("combines an inline payload with subsequent payload lines on the replace op", () => {
|
||||
const anchor = tag(2, "bbb");
|
||||
const diff = [`${anchor}:FIRST`, pl("SECOND")].join("\n");
|
||||
const diff = [`${anchor}:FIRST`, extra("SECOND")].join("\n");
|
||||
expect(applyDiff(content, diff)).toBe("aaa\nFIRST\nSECOND\nccc");
|
||||
});
|
||||
|
||||
it("rejects unprefixed payload continuation lines", () => {
|
||||
const anchor = tag(2, "bbb");
|
||||
expect(() => parseHashline(`${anchor}:FIRST\nSECOND`).edits).toThrow(
|
||||
/payload continuation lines must start with \+/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves whitespace-bearing inline payload exactly", () => {
|
||||
const anchor = tag(2, "bbb");
|
||||
const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;";
|
||||
@@ -149,28 +161,28 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
|
||||
it("auto-absorbs duplicated multiline prefix boundaries during replacement", () => {
|
||||
const source = ["// one", "// two", "old();"].join("\n");
|
||||
const diff = [`${sameLineRange(tag(3, "old();"))}:// one`, pl("// two"), pl("new();")].join("\n");
|
||||
const diff = [`${sameLineRange(tag(3, "old();"))}:// one`, extra("// two"), extra("new();")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["// one", "// two", "new();"].join("\n"));
|
||||
});
|
||||
|
||||
it("auto-absorbs duplicated multiline suffix boundaries during replacement", () => {
|
||||
const source = ["old();", "// one", "// two"].join("\n");
|
||||
const diff = [`${sameLineRange(tag(1, "old();"))}:new();`, pl("// one"), pl("// two")].join("\n");
|
||||
const diff = [`${sameLineRange(tag(1, "old();"))}:new();`, extra("// one"), extra("// two")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["new();", "// one", "// two"].join("\n"));
|
||||
});
|
||||
|
||||
it("auto-absorbs a duplicated single structural suffix during replacement", () => {
|
||||
const source = ["old();", "};"].join("\n");
|
||||
const diff = [`${sameLineRange(tag(1, "old();"))}:new();`, pl("};")].join("\n");
|
||||
const diff = [`${sameLineRange(tag(1, "old();"))}:new();`, extra("};")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["new();", "};"].join("\n"));
|
||||
});
|
||||
|
||||
it("auto-absorbs a duplicated single structural prefix during replacement", () => {
|
||||
const source = ["};", "old();"].join("\n");
|
||||
const diff = [`${sameLineRange(tag(2, "old();"))}:};`, pl("new();")].join("\n");
|
||||
const diff = [`${sameLineRange(tag(2, "old();"))}:};`, extra("new();")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["};", "new();"].join("\n"));
|
||||
});
|
||||
@@ -180,14 +192,14 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
// `}` is a legitimate part of the new block, not a duplicate of the file's
|
||||
// existing `}`. The single-line structural absorb must NOT fire here.
|
||||
const source = ["old();", "}"].join("\n");
|
||||
const diff = [`${sameLineRange(tag(1, "old();"))}:if ok {`, pl("}")].join("\n");
|
||||
const diff = [`${sameLineRange(tag(1, "old();"))}:if ok {`, extra("}")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["if ok {", "}", "}"].join("\n"));
|
||||
});
|
||||
|
||||
it("does not auto-absorb a single duplicated boundary line", () => {
|
||||
const source = ["keep", "old();"].join("\n");
|
||||
const diff = [`${sameLineRange(tag(2, "old();"))}:keep`, pl("new();")].join("\n");
|
||||
const diff = [`${sameLineRange(tag(2, "old();"))}:keep`, extra("new();")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["keep", "keep", "new();"].join("\n"));
|
||||
});
|
||||
@@ -197,14 +209,14 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
// is also the anchor of a separate insert. Absorbing it would silently
|
||||
// steal that anchor and turn the insert into a replacement.
|
||||
const source = ["A", "B", "X", "Y", "Z"].join("\n");
|
||||
const diff = [`${tag(1, "A")}-${tag(2, "B")}:alpha`, pl("X"), pl("Y"), `${tag(4, "Y")}↑extra`].join("\n");
|
||||
const diff = [`${tag(1, "A")}-${tag(2, "B")}:alpha`, extra("X"), extra("Y"), `${tag(4, "Y")}↑extra`].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["alpha", "X", "Y", "X", "extra", "Y", "Z"].join("\n"));
|
||||
});
|
||||
|
||||
it("surfaces a warning when boundary duplicates are auto-absorbed", () => {
|
||||
const source = ["// one", "// two", "old();"].join("\n");
|
||||
const diff = [`${sameLineRange(tag(3, "old();"))}:// one`, pl("// two"), pl("new();")].join("\n");
|
||||
const diff = [`${sameLineRange(tag(3, "old();"))}:// one`, extra("// two"), extra("new();")].join("\n");
|
||||
|
||||
const result = applyHashlineEdits(source, parseHashline(diff).edits);
|
||||
expect(result.lines).toBe(["// one", "// two", "new();"].join("\n"));
|
||||
@@ -257,20 +269,20 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
// `autoDropPureInsertDuplicates` opt-in. Single-line pure-insert
|
||||
// duplicates stay literal because they are ambiguous.
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓aaa`, pl("bbb"), pl("NEW")].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓aaa`, extra("bbb"), extra("NEW")].join("\n");
|
||||
expect(applyDiff(source, diff)).toBe("aaa\nbbb\naaa\nbbb\nNEW\nccc");
|
||||
});
|
||||
|
||||
it("preserves a duplicated single structural suffix for pure insert by default", () => {
|
||||
const source = ["if ok {", " keep();", " }"].join("\n");
|
||||
const diff = [`${tag(3, " }")}↑ added();`, pl(" }")].join("\n");
|
||||
const diff = [`${tag(3, " }")}↑ added();`, extra(" }")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["if ok {", " keep();", " added();", " }", " }"].join("\n"));
|
||||
});
|
||||
|
||||
it("preserves a duplicated single structural prefix for pure insert even when duplicate absorption is enabled", () => {
|
||||
const source = [" });", "next();"].join("\n");
|
||||
const diff = [`${tag(1, " });")}↓ });`, pl("added();")].join("\n");
|
||||
const diff = [`${tag(1, " });")}↓ });`, extra("added();")].join("\n");
|
||||
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
|
||||
|
||||
expect(result.lines).toBe([" });", " });", "added();", "next();"].join("\n"));
|
||||
@@ -279,21 +291,21 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
|
||||
it("preserves an intentional non-structural anchor duplicate for `ANCHOR↓` by default", () => {
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓bbb`, pl("NEW")].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓bbb`, extra("NEW")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc");
|
||||
});
|
||||
|
||||
it("preserves an intentional non-structural anchor duplicate for `ANCHOR↑` by default", () => {
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↑NEW`, pl("bbb")].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↑NEW`, extra("bbb")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe("aaa\nNEW\nbbb\nbbb\nccc");
|
||||
});
|
||||
|
||||
it("does not drop a single structural pure-insert suffix when it preserves balance", () => {
|
||||
const source = ["if outer {", "}"].join("\n");
|
||||
const diff = [`${tag(2, "}")}↑if inner {`, pl("}")].join("\n");
|
||||
const diff = [`${tag(2, "}")}↑if inner {`, extra("}")].join("\n");
|
||||
|
||||
expect(applyDiff(source, diff)).toBe(["if outer {", "if inner {", "}", "}"].join("\n"));
|
||||
});
|
||||
@@ -302,7 +314,7 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
// Payload echoes the two file lines AT/ABOVE the insertion point
|
||||
// (aaa, bbb), then adds NEW. The leading echo is absorbed.
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓aaa`, pl("bbb"), pl("NEW")].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓aaa`, extra("bbb"), extra("NEW")].join("\n");
|
||||
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc");
|
||||
});
|
||||
|
||||
@@ -310,7 +322,7 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
// Payload wraps NEW with context above (aaa, bbb) AND below (ccc, ddd).
|
||||
// Both ends should be absorbed, leaving only NEW inserted after bbb.
|
||||
const source = ["aaa", "bbb", "ccc", "ddd"].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓aaa`, pl("bbb"), pl("NEW"), pl("ccc"), pl("ddd")].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓aaa`, extra("bbb"), extra("NEW"), extra("ccc"), extra("ddd")].join("\n");
|
||||
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd");
|
||||
});
|
||||
|
||||
@@ -318,34 +330,34 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
// Insert before line 3 ("ccc"). Trailing payload echoes the anchor and the
|
||||
// line after it. Drop the trailing duplicates.
|
||||
const source = ["aaa", "bbb", "ccc", "ddd"].join("\n");
|
||||
const diff = [`${tag(3, "ccc")}↑NEW`, pl("ccc"), pl("ddd")].join("\n");
|
||||
const diff = [`${tag(3, "ccc")}↑NEW`, extra("ccc"), extra("ddd")].join("\n");
|
||||
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd");
|
||||
});
|
||||
|
||||
it("auto-absorbs duplicated leading payload at EOF insert", () => {
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
// `EOF↓` payload echoes the last two file lines, then adds NEW.
|
||||
const diff = ["EOF↓bbb", pl("ccc"), pl("NEW")].join("\n");
|
||||
const diff = ["EOF↓bbb", extra("ccc"), extra("NEW")].join("\n");
|
||||
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nccc\nNEW");
|
||||
});
|
||||
|
||||
it("auto-absorbs duplicated trailing payload at BOF insert", () => {
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
// `BOF↑` payload prepends NEW but trails with the first two file lines.
|
||||
const diff = ["BOF↑NEW", pl("aaa"), pl("bbb")].join("\n");
|
||||
const diff = ["BOF↑NEW", extra("aaa"), extra("bbb")].join("\n");
|
||||
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("NEW\naaa\nbbb\nccc");
|
||||
});
|
||||
|
||||
it("preserves a single duplicated anchor line in a pure insert even when generic duplicate absorption is enabled", () => {
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓bbb`, pl("NEW")].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓bbb`, extra("NEW")].join("\n");
|
||||
|
||||
expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc");
|
||||
});
|
||||
|
||||
it("surfaces a warning when pure-insert duplicates are auto-dropped", () => {
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓aaa`, pl("bbb"), pl("NEW")].join("\n");
|
||||
const diff = [`${tag(2, "bbb")}↓aaa`, extra("bbb"), extra("NEW")].join("\n");
|
||||
const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true });
|
||||
expect(result.lines).toBe("aaa\nbbb\nNEW\nccc");
|
||||
expect(result.warnings).toBeDefined();
|
||||
@@ -355,28 +367,28 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
});
|
||||
|
||||
it("preserves payload text exactly", () => {
|
||||
const diff = [`${sameLineRange(tag(2, "bbb"))}:`, pl("# not a header"), pl("+ not an op"), pl(" spaced")].join(
|
||||
"\n",
|
||||
);
|
||||
const diff = [
|
||||
`${sameLineRange(tag(2, "bbb"))}:`,
|
||||
extra(""),
|
||||
extra("# not a header"),
|
||||
extra("+ not an op"),
|
||||
extra(" spaced"),
|
||||
].join("\n");
|
||||
expect(applyDiff(content, diff)).toBe("aaa\n\n# not a header\n+ not an op\n spaced\nccc");
|
||||
});
|
||||
|
||||
it("treats blank lines inside a payload run as empty payload lines", () => {
|
||||
// Truly blank lines inside an active payload run are verbatim empty
|
||||
// payload lines as long as more payload follows.
|
||||
const diff = [`${sameLineRange(tag(2, "bbb"))}:first`, "", "", pl("after")].join("\n");
|
||||
it("treats plus-only payload lines as empty payload lines", () => {
|
||||
const diff = [`${sameLineRange(tag(2, "bbb"))}:first`, extra(""), extra(""), extra("after")].join("\n");
|
||||
expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc");
|
||||
});
|
||||
|
||||
it("appends blank lines between ops to the previous payload", () => {
|
||||
// Blank lines while a payload run is open are payload, never silent
|
||||
// separators — every blank in `1:AAA\n\n\n3:CCC` ends up on disk.
|
||||
it("ignores raw blank separators between ops", () => {
|
||||
const diff = [`${sameLineRange(tag(1, "aaa"))}:AAA`, "", "", `${sameLineRange(tag(3, "ccc"))}:CCC`].join("\n");
|
||||
expect(applyDiff(content, diff)).toBe("AAA\n\n\nbbb\nCCC");
|
||||
expect(applyDiff(content, diff)).toBe("AAA\nbbb\nCCC");
|
||||
});
|
||||
|
||||
it("treats a bare insert op as inserting one empty line", () => {
|
||||
// `LINE↑` / `LINE↓` with no payload default to one empty line (same as `LINE↑\n\n`).
|
||||
// `LINE↑` / `LINE↓` with no payload default to one empty line.
|
||||
const upAnchor = { line: 1 };
|
||||
expect(parseHashline(`${tag(1, "aaa")}↑`).edits).toEqual([
|
||||
{ kind: "insert", cursor: { kind: "before_anchor", anchor: upAnchor }, text: "", lineNum: 1, index: 0 },
|
||||
@@ -387,7 +399,7 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
});
|
||||
|
||||
it("rejects orphan payload lines with no preceding op", () => {
|
||||
expect(() => parseHashline(pl("orphan")).edits).toThrow(/payload line has no preceding/);
|
||||
expect(() => parseHashline(extra("orphan")).edits).toThrow(/payload line has no preceding/);
|
||||
});
|
||||
|
||||
it("rejects op sigils written in prefix position", () => {
|
||||
@@ -399,7 +411,7 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
it("rejects ranges with `..` separator", () => {
|
||||
// `..` is no longer the range separator; the line is treated as orphan
|
||||
// payload because `2..3:` does not match the new range pattern.
|
||||
expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}:\nBBB`).edits).toThrow(
|
||||
expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}:\n${extra("BBB")}`).edits).toThrow(
|
||||
/payload line has no preceding/,
|
||||
);
|
||||
});
|
||||
@@ -430,7 +442,7 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
it("treats `LINE:TEXT` as replace syntax even when TEXT contains ↑ / ↓", () => {
|
||||
const anchor = tag(2, "bbb");
|
||||
expect(applyDiff(content, `${anchor}:bbb↓`)).toBe("aaa\nbbb↓\nccc");
|
||||
expect(applyDiff(content, `${anchor}:bbb↑\nX`)).toBe("aaa\nbbb↑\nX\nccc");
|
||||
expect(applyDiff(content, `${anchor}:bbb↑\n${extra("X")}`)).toBe("aaa\nbbb↑\nX\nccc");
|
||||
});
|
||||
|
||||
it("uses inline payload for BOF/EOF inserts", () => {
|
||||
@@ -451,7 +463,7 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
});
|
||||
|
||||
it("coalesces two replace ops covering the same range (before/after-block pattern, last wins)", () => {
|
||||
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:OLD\nOLD2\n${tag(2, "bbb")}-${tag(3, "ccc")}:NEW\nNEW2`;
|
||||
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:OLD\n${extra("OLD2")}\n${tag(2, "bbb")}-${tag(3, "ccc")}:NEW\n${extra("NEW2")}`;
|
||||
const { edits, warnings } = parseHashline(diff);
|
||||
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd", edits).lines).toBe("aaa\nNEW\nNEW2\nddd");
|
||||
expect(warnings).toEqual([
|
||||
@@ -467,29 +479,21 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the .+ op on line 1/);
|
||||
});
|
||||
|
||||
it("demotes a single-line `N:` op inside a pending `A-B:` to a payload line", () => {
|
||||
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:line one\n${tag(3, "ccc")}:line two\n${tag(4, "ddd")}:line three`;
|
||||
it("uses `+` payload lines inside a multi-line replacement", () => {
|
||||
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:line one\n${extra("line two")}\n${extra("line three")}`;
|
||||
const { edits, warnings } = parseHashline(diff);
|
||||
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee", edits).lines).toBe(
|
||||
"aaa\nline one\nline two\nline three\neee",
|
||||
);
|
||||
expect(warnings).toEqual([
|
||||
"Detected one or more `LINE:TEXT` lines whose anchors fell inside the pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside a multi-line `A-B:` block, payload lines after the first do not need a line-number prefix.",
|
||||
]);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("demotes a sub-range `A-B:` inside a pending outer `A-B:` to a payload line", () => {
|
||||
const diff = `${tag(2, "bbb")}-${tag(5, "eee")}:line one\n${tag(3, "ccc")}-${tag(4, "ddd")}:collapsed pair`;
|
||||
const { edits, warnings } = parseHashline(diff);
|
||||
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee\nfff", edits).lines).toBe(
|
||||
"aaa\nline one\ncollapsed pair\nfff",
|
||||
);
|
||||
expect(warnings).toEqual([
|
||||
"Detected one or more `LINE:TEXT` lines whose anchors fell inside the pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside a multi-line `A-B:` block, payload lines after the first do not need a line-number prefix.",
|
||||
]);
|
||||
it("rejects read-output `N:` lines inside a pending `A-B:` as overlapping ops", () => {
|
||||
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:line one\n${tag(3, "ccc")}:line two`;
|
||||
expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the .+ op on line 1/);
|
||||
});
|
||||
|
||||
it("treats `N:` outside the pending range as a separate op (no demote)", () => {
|
||||
it("treats `N:` outside the pending range as a separate op", () => {
|
||||
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:line one\n${tag(5, "eee")}:line five`;
|
||||
const { edits, warnings } = parseHashline(diff);
|
||||
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee\nfff", edits).lines).toBe(
|
||||
@@ -527,7 +531,7 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
|
||||
describe("hashline — file hash binding", () => {
|
||||
it("rejects line-hash anchors as unrecognized payload lines", () => {
|
||||
expect(() => parseHashline("2ab:\nBBB").edits).toThrow(/payload line has no preceding/);
|
||||
expect(() => parseHashline(`2ab:\n${extra("BBB")}`).edits).toThrow(/payload line has no preceding/);
|
||||
});
|
||||
|
||||
it("applies line-number edits without per-anchor hash validation", () => {
|
||||
@@ -538,59 +542,59 @@ describe("hashline — file hash binding", () => {
|
||||
|
||||
describe("splitHashlineInput — ¶ headers", () => {
|
||||
it("extracts path, file hash, and diff body from ¶path#hash header", () => {
|
||||
const input = [`¶src/foo.ts#1a2b`, `${sameLineRange(tag(2, "bbb"))}:`, pl("BBB")].join("\n");
|
||||
const input = [`¶src/foo.ts#1a2b`, `${sameLineRange(tag(2, "bbb"))}:`, extra("BBB")].join("\n");
|
||||
expect(splitHashlineInput(input)).toEqual({
|
||||
path: "src/foo.ts",
|
||||
fileHash: "1a2b",
|
||||
diff: `${sameLineRange(tag(2, "bbb"))}:\n${pl("BBB")}`,
|
||||
diff: `${sameLineRange(tag(2, "bbb"))}:\n${extra("BBB")}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips leading blank lines", () => {
|
||||
expect(splitHashlineInput(`\n¶foo.ts\nBOF↓\n${pl("x")}`)).toEqual({
|
||||
expect(splitHashlineInput(`\n¶foo.ts\nBOF↓\n${extra("x")}`)).toEqual({
|
||||
path: "foo.ts",
|
||||
diff: `BOF↓\n${pl("x")}`,
|
||||
diff: `BOF↓\n${extra("x")}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => {
|
||||
const cwd = process.cwd();
|
||||
const absolute = path.join(cwd, "src", "foo.ts");
|
||||
expect(splitHashlineInput(`¶${absolute}\nBOF↓\n${pl("x")}`, { cwd }).path).toBe("src/foo.ts");
|
||||
expect(splitHashlineInput(`¶${absolute}\nBOF↓\n${extra("x")}`, { cwd }).path).toBe("src/foo.ts");
|
||||
});
|
||||
|
||||
it("uses explicit fallback path only when input has recognizable operations", () => {
|
||||
expect(splitHashlineInput(`BOF↓\n${pl("x")}`, { path: "a.ts" })).toEqual({
|
||||
expect(splitHashlineInput(`BOF↓\n${extra("x")}`, { path: "a.ts" })).toEqual({
|
||||
path: "a.ts",
|
||||
diff: `BOF↓\n${pl("x")}`,
|
||||
diff: `BOF↓\n${extra("x")}`,
|
||||
});
|
||||
expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/);
|
||||
});
|
||||
|
||||
it("splits multiple edit sections", () => {
|
||||
const input = ["¶a.ts", "BOF↓", pl("a"), "¶b.ts", "EOF↓", pl("b")].join("\n");
|
||||
const input = ["¶a.ts", "BOF↓", extra("a"), "¶b.ts", "EOF↓", extra("b")].join("\n");
|
||||
expect(splitHashlineInputs(input)).toEqual([
|
||||
{ path: "a.ts", diff: `BOF↓\n${pl("a")}` },
|
||||
{ path: "b.ts", diff: `EOF↓\n${pl("b")}` },
|
||||
{ path: "a.ts", diff: `BOF↓\n${extra("a")}` },
|
||||
{ path: "b.ts", diff: `EOF↓\n${extra("b")}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tolerates extra ¶ chars on the section header", () => {
|
||||
const input = ["¶¶a.ts", "BOF↓", pl("a"), "¶¶¶b.ts", "EOF↓", pl("b")].join("\n");
|
||||
const input = ["¶¶a.ts", "BOF↓", extra("a"), "¶¶¶b.ts", "EOF↓", extra("b")].join("\n");
|
||||
expect(splitHashlineInputs(input)).toEqual([
|
||||
{ path: "a.ts", diff: `BOF↓\n${pl("a")}` },
|
||||
{ path: "b.ts", diff: `EOF↓\n${pl("b")}` },
|
||||
{ path: "a.ts", diff: `BOF↓\n${extra("a")}` },
|
||||
{ path: "b.ts", diff: `EOF↓\n${extra("b")}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it("silently drops a duplicate header with no operations between them", () => {
|
||||
const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", `BOF↓`, pl("x")].join("\n");
|
||||
expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF↓\n${pl("x")}` }]);
|
||||
const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", `BOF↓`, extra("x")].join("\n");
|
||||
expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF↓\n${extra("x")}` }]);
|
||||
});
|
||||
|
||||
it("silently drops a trailing header with no operations", () => {
|
||||
const input = ["¶¶a.ts", "BOF↓", pl("a"), "¶¶b.ts"].join("\n");
|
||||
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF↓\n${pl("a")}` }]);
|
||||
const input = ["¶¶a.ts", "BOF↓", extra("a"), "¶¶b.ts"].join("\n");
|
||||
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF↓\n${extra("a")}` }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -608,7 +612,7 @@ describe("hashline executor", () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const filePath = path.join(tempDir, "a.ts");
|
||||
const source = ["aaa", "bbb", "ccc"].join("\n");
|
||||
const input = `${header("a.ts", source)}\n${tag(2, "bbb")}↓${pl("aaa")}\n${pl("bbb")}\n${pl("NEW")}\n`;
|
||||
const input = `${header("a.ts", source)}\n${tag(2, "bbb")}↓${pl("aaa")}\n${extra("bbb")}\n${extra("NEW")}\n`;
|
||||
|
||||
await Bun.write(filePath, source);
|
||||
await executeHashlineSingle(hashlineExecuteOptions(tempDir, input));
|
||||
@@ -658,14 +662,14 @@ describe("hashline executor", () => {
|
||||
const input = [
|
||||
header("a.ts", `${original}\n`),
|
||||
`${sameLineRange(tag(2, "L2"))}:L2a`,
|
||||
pl("L2b"),
|
||||
pl("L2c"),
|
||||
pl("L2d"),
|
||||
pl("L2e"),
|
||||
pl("L2f"),
|
||||
pl("L2g"),
|
||||
pl("L2h"),
|
||||
pl("L2i"),
|
||||
extra("L2b"),
|
||||
extra("L2c"),
|
||||
extra("L2d"),
|
||||
extra("L2e"),
|
||||
extra("L2f"),
|
||||
extra("L2g"),
|
||||
extra("L2h"),
|
||||
extra("L2i"),
|
||||
header("a.ts", `${original}\n`),
|
||||
`${tag(8, "L8")}↓INSERTED`,
|
||||
].join("\n");
|
||||
@@ -702,7 +706,9 @@ describe("hashline executor", () => {
|
||||
|
||||
describe("hashlineEditParamsSchema — extra-field tolerance", () => {
|
||||
it("accepts extra `path` field alongside `input`", () => {
|
||||
expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF↓\n${pl("x")}` }).success).toBe(true);
|
||||
expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF↓\n${extra("x")}` }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("still requires `input`", () => {
|
||||
@@ -995,11 +1001,11 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
|
||||
const input = [
|
||||
`¶a.ts`,
|
||||
`${tag(1, "alpha")}↓`,
|
||||
pl("a-payload"),
|
||||
extra("a-payload"),
|
||||
sentinel,
|
||||
`¶b.ts`,
|
||||
`${tag(1, "beta")}↓`,
|
||||
pl("never-emitted"),
|
||||
extra("never-emitted"),
|
||||
].join("\n");
|
||||
const sections = splitHashlineInputs(input);
|
||||
expect(sections).toHaveLength(1);
|
||||
@@ -1071,34 +1077,31 @@ describe("hashline apply — brace-delete soft warning", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashline parser — blank line extends the open payload", () => {
|
||||
it("blank line between ops is appended to the previous payload", () => {
|
||||
describe("hashline parser — plus-prefixed blank payload lines", () => {
|
||||
it("raw blank lines between ops are ignored", () => {
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `${header("a.ts", text)}\n1:A\n\n3:C\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
// `1:A` payload becomes [A, ""]; line 3 is then replaced with C.
|
||||
expect(applyDiff(text, diff)).toBe("A\n\nb\nC\nd\ne\n");
|
||||
expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n");
|
||||
});
|
||||
|
||||
it("multiple blank lines between ops are all appended as payload", () => {
|
||||
it("plus-only continuation lines are appended as empty payload lines", () => {
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `${header("a.ts", text)}\n1:A\n\n\n\n3:C\n`;
|
||||
const ops = `${header("a.ts", text)}\n1:A\n${extra("")}\n${extra("")}\n3:C\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("A\n\n\n\nb\nC\nd\ne\n");
|
||||
expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n");
|
||||
});
|
||||
|
||||
it("bare A: followed by a blank line replaces the line with two blanks", () => {
|
||||
// `2:` seeds payload with `[""]`, the standalone blank line appends
|
||||
// another `""`, then `4:D` flushes the run.
|
||||
it("bare A: followed by two plus-only lines replaces the line with two blanks", () => {
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `${header("a.ts", text)}\n2:\n\n4:D\n`;
|
||||
const ops = `${header("a.ts", text)}\n2:\n${extra("")}\n${extra("")}\n4:D\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n");
|
||||
});
|
||||
|
||||
it("blank line inside payload between two content lines is preserved", () => {
|
||||
it("plus-only line inside payload between two content lines is preserved", () => {
|
||||
const text = "a\nb\nc\n";
|
||||
const ops = `${header("a.ts", text)}\n2:first\n\nsecond\n`;
|
||||
const ops = `${header("a.ts", text)}\n2:first\n${extra("")}\n${extra("second")}\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user