feat(coding-agent): added hashline entrypoint and redirected callers
- Added `./hashline` package exports and redirected callers to the new hashline entrypoint. - Moved hashline logic out of `edit/` to `src/hashline` and removed `edit/modes/hashline`/`edit/line-hash` paths. - Added hashline parsers, anchors, types, and diff helpers with stricter input and mismatch validation. - Implemented preflight and cache-recovery execution flows to reapply edits and handle stale anchor mismatches. - Documented the hashline API relocation as breaking changes in `CHANGELOG.md`.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Moved hashline APIs to the dedicated `@oh-my-pi/pi-coding-agent/hashline` module, moved hash helpers to `@oh-my-pi/pi-coding-agent/hashline/hash`, and removed the legacy `edit/modes/hashline` and `edit/line-hash` source subpaths.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed hashline auto-rebase. Anchor mismatches now reject immediately so the model re-reads instead of silently relocating an edit to a hash-collision within ±5 lines, which could otherwise apply the change to the wrong region. Stale-anchor recovery via the cached read snapshot is unaffected.
|
||||
|
||||
@@ -227,6 +227,14 @@
|
||||
"types": "./src/edit/modes/*.ts",
|
||||
"import": "./src/edit/modes/*.ts"
|
||||
},
|
||||
"./hashline": {
|
||||
"types": "./src/hashline/index.ts",
|
||||
"import": "./src/hashline/index.ts"
|
||||
},
|
||||
"./hashline/*": {
|
||||
"types": "./src/hashline/*.ts",
|
||||
"import": "./src/hashline/*.ts"
|
||||
},
|
||||
"./exa": {
|
||||
"types": "./src/exa/index.ts",
|
||||
"import": "./src/exa/index.ts"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
parseFrontmatter,
|
||||
prompt,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { computeLineHash, HL_BODY_SEP, HL_EDIT_SEP } from "../edit/line-hash";
|
||||
import { computeLineHash, HL_BODY_SEP, HL_EDIT_SEP } from "../hashline/hash";
|
||||
import { jtdToTypeScript } from "../tools/jtd-to-typescript";
|
||||
import { parseCommandArgs, substituteArgs } from "../utils/command-args";
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import type { Static } from "@sinclair/typebox";
|
||||
import {
|
||||
executeHashlineSingle,
|
||||
HashlineMismatchError,
|
||||
type HashlineParams,
|
||||
hashlineEditParamsSchema,
|
||||
} from "../hashline";
|
||||
import hashlineGrammarTemplate from "../hashline/grammar.lark" with { type: "text" };
|
||||
import { resolveHashlineGrammarPlaceholders } from "../hashline/hash";
|
||||
import {
|
||||
createLspWritethrough,
|
||||
type FileDiagnosticsResult,
|
||||
@@ -16,16 +24,8 @@ import type { ToolSession } from "../tools";
|
||||
import { VimTool, vimSchema } from "../tools/vim";
|
||||
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
|
||||
import type { VimToolDetails } from "../vim/types";
|
||||
import { resolveHashlineGrammarPlaceholders } from "./line-hash";
|
||||
import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
|
||||
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
|
||||
import {
|
||||
executeHashlineSingle,
|
||||
HashlineMismatchError,
|
||||
type HashlineParams,
|
||||
hashlineEditParamsSchema,
|
||||
} from "./modes/hashline";
|
||||
import hashlineGrammarTemplate from "./modes/hashline.lark" with { type: "text" };
|
||||
import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
|
||||
import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
|
||||
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
|
||||
@@ -34,13 +34,12 @@ export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/ed
|
||||
export * from "./apply-patch";
|
||||
export * from "./diff";
|
||||
export * from "./file-read-cache";
|
||||
export * from "./line-hash";
|
||||
|
||||
// Resolve the `$HFMT$` and `$HSEP$` placeholders in the hashline Lark grammar.
|
||||
const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
|
||||
|
||||
export * from "../hashline";
|
||||
export * from "./modes/apply-patch";
|
||||
export * from "./modes/hashline";
|
||||
export * from "./modes/patch";
|
||||
export * from "./modes/replace";
|
||||
export * from "./normalize";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,17 +12,18 @@
|
||||
* The shared renderer / `ToolExecutionComponent` consult the strategy via
|
||||
* the injected `editMode` rather than probing argument shape.
|
||||
*/
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
||||
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
|
||||
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
|
||||
|
||||
import {
|
||||
computeHashlineDiff,
|
||||
computeHashlineSectionDiff,
|
||||
containsRecognizableHashlineOperations,
|
||||
type HashlineInputSection,
|
||||
splitHashlineInputs,
|
||||
} from "./modes/hashline";
|
||||
} from "../hashline";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
||||
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
|
||||
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
|
||||
import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
|
||||
import type { ReplaceEditEntry } from "./modes/replace";
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { formatCodeFrameLine } from "../tools/render-utils";
|
||||
import { MISMATCH_CONTEXT } from "./constants";
|
||||
import { computeLineHash, describeAnchorExamples, HL_ANCHOR_RE_RAW, HL_BODY_SEP } from "./hash";
|
||||
import type { HashMismatch } from "./types";
|
||||
|
||||
const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
|
||||
const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
|
||||
const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`);
|
||||
|
||||
export function formatFullAnchorRequirement(raw?: string): string {
|
||||
const suffix = typeof raw === "string" ? raw.trim() : "";
|
||||
const hashOnlyHint = HL_HASH_HINT_RE.test(suffix)
|
||||
? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
|
||||
`Copy the full anchor exactly as shown (for example, "160${suffix}").`
|
||||
: "";
|
||||
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
|
||||
return (
|
||||
`the full anchor exactly as shown by read/search output ` +
|
||||
`(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
|
||||
);
|
||||
}
|
||||
|
||||
export function parseTag(ref: string): { line: number; hash: string } {
|
||||
const match = ref.match(PARSE_TAG_RE);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
|
||||
}
|
||||
const line = Number.parseInt(match[1], 10);
|
||||
if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
|
||||
return { line, hash: match[2] };
|
||||
}
|
||||
|
||||
function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] {
|
||||
const displayLines = new Set<number>();
|
||||
for (const mismatch of mismatches) {
|
||||
const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT);
|
||||
const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT);
|
||||
for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
|
||||
}
|
||||
return [...displayLines].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export class HashlineMismatchError extends Error {
|
||||
readonly remaps: ReadonlyMap<string, string>;
|
||||
|
||||
constructor(
|
||||
public readonly mismatches: HashMismatch[],
|
||||
public readonly fileLines: string[],
|
||||
) {
|
||||
super(HashlineMismatchError.formatMessage(mismatches, fileLines));
|
||||
this.name = "HashlineMismatchError";
|
||||
|
||||
const remaps = new Map<string, string>();
|
||||
for (const mismatch of mismatches) {
|
||||
const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "");
|
||||
remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`);
|
||||
}
|
||||
this.remaps = remaps;
|
||||
}
|
||||
|
||||
get displayMessage(): string {
|
||||
return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
|
||||
}
|
||||
|
||||
private static rejectionHeader(mismatches: HashMismatch[]): string[] {
|
||||
const noun = mismatches.length > 1 ? "lines have" : "line has";
|
||||
return [
|
||||
`Edit rejected: ${mismatches.length} ${noun} changed since the last read (marked *).`,
|
||||
"The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
|
||||
];
|
||||
}
|
||||
|
||||
static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
||||
const mismatchSet = new Set<number>(mismatches.map(m => m.line));
|
||||
const displayLines = getMismatchDisplayLines(mismatches, fileLines);
|
||||
const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0);
|
||||
|
||||
const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""];
|
||||
let previous = -1;
|
||||
for (const lineNum of displayLines) {
|
||||
if (previous !== -1 && lineNum > previous + 1) out.push("...");
|
||||
previous = lineNum;
|
||||
const marker = mismatchSet.has(lineNum) ? "*" : " ";
|
||||
out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width));
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
||||
const mismatchSet = new Set<number>(mismatches.map(m => m.line));
|
||||
const lines = HashlineMismatchError.rejectionHeader(mismatches);
|
||||
let previous = -1;
|
||||
for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) {
|
||||
if (previous !== -1 && lineNum > previous + 1) lines.push("...");
|
||||
previous = lineNum;
|
||||
const text = fileLines[lineNum - 1] ?? "";
|
||||
const hash = computeLineHash(lineNum, text);
|
||||
const marker = mismatchSet.has(lineNum) ? "*" : " ";
|
||||
lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
|
||||
if (ref.line < 1 || ref.line > fileLines.length) {
|
||||
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
||||
}
|
||||
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? "");
|
||||
if (actualHash !== ref.hash) {
|
||||
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
import { HashlineMismatchError } from "./anchors";
|
||||
import { RANGE_INTERIOR_HASH } from "./constants";
|
||||
import { computeLineHash, HL_EDIT_SEP } from "./hash";
|
||||
import { cloneCursor } from "./parser";
|
||||
import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit, HashMismatch } from "./types";
|
||||
|
||||
export interface HashlineApplyResult {
|
||||
lines: string;
|
||||
firstChangedLine?: number;
|
||||
warnings?: string[];
|
||||
noopEdits?: HashlineNoopEdit[];
|
||||
}
|
||||
|
||||
export interface HashlineNoopEdit {
|
||||
editIndex: number;
|
||||
loc: string;
|
||||
reason: string;
|
||||
current: string;
|
||||
}
|
||||
|
||||
type HashlineLineOrigin = "original" | "insert" | "replacement";
|
||||
|
||||
interface IndexedEdit {
|
||||
edit: HashlineEdit;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
type HashlineDeleteEdit = Extract<HashlineEdit, { kind: "delete" }>;
|
||||
|
||||
interface HashlineReplacementGroup {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
sourceLineNum: number;
|
||||
replacement: string[];
|
||||
deletes: HashlineDeleteEdit[];
|
||||
}
|
||||
|
||||
function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
|
||||
if (edit.kind === "delete") return [edit.anchor];
|
||||
if (edit.kind === "modify") return [edit.anchor];
|
||||
if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
|
||||
if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify every anchor's hash. Any mismatch is reported as a `HashMismatch`;
|
||||
* there is no auto-rebase. Callers are expected to surface mismatches as
|
||||
* `HashlineMismatchError` so the model re-reads and re-anchors.
|
||||
*/
|
||||
function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
|
||||
const mismatches: HashMismatch[] = [];
|
||||
for (const edit of edits) {
|
||||
for (const anchor of getHashlineEditAnchors(edit)) {
|
||||
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
||||
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
||||
}
|
||||
if (anchor.hash === RANGE_INTERIOR_HASH) continue;
|
||||
|
||||
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? "");
|
||||
if (actualHash === anchor.hash) continue;
|
||||
|
||||
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
|
||||
}
|
||||
}
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
|
||||
if (lines.length === 0) return;
|
||||
const origins = lines.map((): HashlineLineOrigin => "insert");
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...lines);
|
||||
lineOrigins.splice(0, 1, ...origins);
|
||||
return;
|
||||
}
|
||||
fileLines.splice(0, 0, ...lines);
|
||||
lineOrigins.splice(0, 0, ...origins);
|
||||
}
|
||||
|
||||
function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): number | undefined {
|
||||
if (lines.length === 0) return undefined;
|
||||
const origins = lines.map((): HashlineLineOrigin => "insert");
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...lines);
|
||||
lineOrigins.splice(0, 1, ...origins);
|
||||
return 1;
|
||||
}
|
||||
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
|
||||
const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
||||
fileLines.splice(insertIndex, 0, ...lines);
|
||||
lineOrigins.splice(insertIndex, 0, ...origins);
|
||||
return insertIndex + 1;
|
||||
}
|
||||
|
||||
/** Bucket edits by the line they target so we can apply each line's group in one splice. */
|
||||
|
||||
function getAnchorTargetLine(edit: HashlineEdit): number | undefined {
|
||||
if (edit.kind === "delete" || edit.kind === "modify") return edit.anchor.line;
|
||||
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectAnchorTargetLines(edits: HashlineEdit[]): Set<number> {
|
||||
const lines = new Set<number>();
|
||||
for (const edit of edits) {
|
||||
const line = getAnchorTargetLine(edit);
|
||||
if (line !== undefined) lines.add(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function findReplacementGroup(edits: HashlineEdit[], startIndex: number): HashlineReplacementGroup | undefined {
|
||||
const first = edits[startIndex];
|
||||
if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
|
||||
|
||||
const sourceLineNum = first.lineNum;
|
||||
const replacement: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < edits.length) {
|
||||
const edit = edits[index];
|
||||
if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
|
||||
replacement.push(edit.text);
|
||||
index++;
|
||||
}
|
||||
|
||||
const deletes: HashlineDeleteEdit[] = [];
|
||||
while (index < edits.length) {
|
||||
const edit = edits[index];
|
||||
if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break;
|
||||
deletes.push(edit);
|
||||
index++;
|
||||
}
|
||||
if (deletes.length === 0) return undefined;
|
||||
|
||||
const startLine = deletes[0].anchor.line;
|
||||
for (let offset = 0; offset < deletes.length; offset++) {
|
||||
if (deletes[offset].anchor.line !== startLine + offset) return undefined;
|
||||
}
|
||||
const cursorLine = first.cursor.anchor.line;
|
||||
if (cursorLine !== startLine) return undefined;
|
||||
|
||||
return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes };
|
||||
}
|
||||
|
||||
function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number {
|
||||
const max = Math.min(replacement.length, startLine - 1);
|
||||
for (let count = max; count >= 2; count--) {
|
||||
let matches = true;
|
||||
for (let offset = 0; offset < count; offset++) {
|
||||
if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number {
|
||||
const max = Math.min(replacement.length, fileLines.length - endLine);
|
||||
for (let count = max; count >= 2; count--) {
|
||||
let matches = true;
|
||||
for (let offset = 0; offset < count; offset++) {
|
||||
if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Single-line duplicate absorption is limited to structural closing delimiters.
|
||||
// General one-line context is too easy to delete incorrectly, but duplicated
|
||||
// `};` / `)` / `]` boundaries usually indicate a replacement range stopped one
|
||||
// line early and would otherwise produce a syntax error.
|
||||
const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/;
|
||||
|
||||
function isStructuralClosingBoundaryLine(line: string): boolean {
|
||||
return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line);
|
||||
}
|
||||
|
||||
interface DelimiterBalance {
|
||||
paren: number;
|
||||
bracket: number;
|
||||
brace: number;
|
||||
}
|
||||
|
||||
const ZERO_DELIMITER_BALANCE: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
||||
|
||||
/**
|
||||
* Naive bracket counter — does NOT skip string/template/comment contents. The
|
||||
* single-line structural absorb relies on this being safe-by-asymmetry: the
|
||||
* candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE`
|
||||
* to be pure delimiters, so noise in deleted lines or non-boundary kept payload
|
||||
* tends to push `expected !== kept` and biases the heuristic toward NOT
|
||||
* absorbing (the safe direction). If we ever extend this to opening boundaries
|
||||
* or non-structural single lines, swap this for a real tokenizer.
|
||||
*/
|
||||
function computeDelimiterBalance(lines: string[]): DelimiterBalance {
|
||||
const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
||||
for (const line of lines) {
|
||||
for (const char of line) {
|
||||
switch (char) {
|
||||
case "(":
|
||||
balance.paren++;
|
||||
break;
|
||||
case ")":
|
||||
balance.paren--;
|
||||
break;
|
||||
case "[":
|
||||
balance.bracket++;
|
||||
break;
|
||||
case "]":
|
||||
balance.bracket--;
|
||||
break;
|
||||
case "{":
|
||||
balance.brace++;
|
||||
break;
|
||||
case "}":
|
||||
balance.brace--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
|
||||
return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether the structural-boundary candidate should be dropped: the
|
||||
* `keptPayload` (full payload with the boundary line removed) must restore the
|
||||
* caller's `expectedBalance`, while the `fullPayload` (boundary line still
|
||||
* present) must NOT. For replacements `expectedBalance` is the deleted
|
||||
* region's net delimiter balance; for pure inserts it is zero.
|
||||
*/
|
||||
function shouldDropSingleStructuralBoundary(
|
||||
fullPayload: string[],
|
||||
keptPayload: string[],
|
||||
expectedBalance: DelimiterBalance,
|
||||
): boolean {
|
||||
return (
|
||||
delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) &&
|
||||
!delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance)
|
||||
);
|
||||
}
|
||||
|
||||
function countMatchingSingleStructuralPrefixBoundary(
|
||||
fileLines: string[],
|
||||
startLine: number,
|
||||
replacement: string[],
|
||||
expectedBalance: DelimiterBalance,
|
||||
): number {
|
||||
if (replacement.length === 0 || startLine <= 1) return 0;
|
||||
const line = replacement[0];
|
||||
if (!isStructuralClosingBoundaryLine(line)) return 0;
|
||||
if (fileLines[startLine - 2] !== line) return 0;
|
||||
return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0;
|
||||
}
|
||||
|
||||
function countMatchingSingleStructuralSuffixBoundary(
|
||||
fileLines: string[],
|
||||
endLine: number,
|
||||
replacement: string[],
|
||||
expectedBalance: DelimiterBalance,
|
||||
): number {
|
||||
if (replacement.length === 0 || endLine >= fileLines.length) return 0;
|
||||
const line = replacement[replacement.length - 1];
|
||||
if (!isStructuralClosingBoundaryLine(line)) return 0;
|
||||
if (fileLines[endLine] !== line) return 0;
|
||||
return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0;
|
||||
}
|
||||
|
||||
function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
|
||||
for (const line of lines) {
|
||||
if (externalTargetLines.has(line)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function contiguousRange(start: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, offset) => start + offset);
|
||||
}
|
||||
|
||||
function deleteEditForAutoAbsorbedLine(
|
||||
line: number,
|
||||
sourceLineNum: number,
|
||||
index: number,
|
||||
fileLines: string[],
|
||||
): HashlineEdit {
|
||||
return {
|
||||
kind: "delete",
|
||||
anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") },
|
||||
lineNum: sourceLineNum,
|
||||
index,
|
||||
};
|
||||
}
|
||||
|
||||
interface HashlinePureInsertGroup {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
sourceLineNum: number;
|
||||
cursor: HashlineCursor;
|
||||
payload: string[];
|
||||
}
|
||||
|
||||
function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
|
||||
if (a.kind !== b.kind) return false;
|
||||
if (a.kind === "bof" || a.kind === "eof") return true;
|
||||
const aAnchor = (a as { anchor: Anchor }).anchor;
|
||||
const bAnchor = (b as { anchor: Anchor }).anchor;
|
||||
return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects a run of consecutive `insert` edits that all share the same
|
||||
* `lineNum` and `cursor`, IFF that run is not immediately followed by a
|
||||
* `delete` at the same `lineNum` (which would make it a replacement group
|
||||
* instead). Returns the contiguous payload so we can check it for boundary
|
||||
* duplicates against the file.
|
||||
*/
|
||||
function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined {
|
||||
const first = edits[startIndex];
|
||||
if (first?.kind !== "insert") return undefined;
|
||||
|
||||
const sourceLineNum = first.lineNum;
|
||||
const cursor = first.cursor;
|
||||
const payload: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < edits.length) {
|
||||
const edit = edits[index];
|
||||
if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
|
||||
if (!cursorMatches(edit.cursor, cursor)) break;
|
||||
payload.push(edit.text);
|
||||
index++;
|
||||
}
|
||||
|
||||
// If the run is followed by a delete at the same source lineNum, this is a
|
||||
// replacement group (handled by absorbReplacement…). Decline.
|
||||
if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* For a pure-insert group, locate the file region adjacent to the insertion
|
||||
* point. Returns 0-indexed bounds:
|
||||
* - `aboveEndIdx`: index of the last file line strictly above the insertion
|
||||
* point (-1 if none).
|
||||
* - `belowStartIdx`: index of the first file line strictly below the
|
||||
* insertion point (`fileLines.length` if none).
|
||||
*/
|
||||
function pureInsertNeighborhood(
|
||||
cursor: HashlineCursor,
|
||||
fileLines: string[],
|
||||
): { aboveEndIdx: number; belowStartIdx: number } {
|
||||
if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
|
||||
if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
|
||||
if (cursor.kind === "before_anchor") {
|
||||
return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
|
||||
}
|
||||
// after_anchor
|
||||
return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
|
||||
}
|
||||
|
||||
interface PureInsertAbsorbResult {
|
||||
keptPayload: string[];
|
||||
absorbedLeading: number;
|
||||
absorbedTrailing: number;
|
||||
leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
|
||||
trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of replacement-absorb's prefix/suffix block check, but for pure
|
||||
* inserts: drop payload lines that exactly duplicate the file lines
|
||||
* immediately above (leading) or immediately below (trailing) the insertion
|
||||
* point. Generic context echo absorption requires a minimum run of 2, but a
|
||||
* single structural closing delimiter is absorbed because duplicated `}` /
|
||||
* `});`-style boundaries almost always mean the insert included adjacent
|
||||
* context.
|
||||
*/
|
||||
function tryAbsorbPureInsertGroup(
|
||||
group: HashlinePureInsertGroup,
|
||||
fileLines: string[],
|
||||
allowGenericBoundaryAbsorb: boolean,
|
||||
): PureInsertAbsorbResult {
|
||||
const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
|
||||
if (group.payload.length === 0) return empty;
|
||||
|
||||
const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
|
||||
|
||||
// Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
|
||||
let absorbedLeading = 0;
|
||||
if (allowGenericBoundaryAbsorb) {
|
||||
const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
|
||||
for (let count = maxLead; count >= 2; count--) {
|
||||
let ok = true;
|
||||
for (let offset = 0; offset < count; offset++) {
|
||||
if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
absorbedLeading = count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
absorbedLeading === 0 &&
|
||||
group.payload.length > 0 &&
|
||||
aboveEndIdx >= 0 &&
|
||||
isStructuralClosingBoundaryLine(group.payload[0]) &&
|
||||
group.payload[0] === fileLines[aboveEndIdx] &&
|
||||
shouldDropSingleStructuralBoundary(group.payload, group.payload.slice(1), ZERO_DELIMITER_BALANCE)
|
||||
) {
|
||||
absorbedLeading = 1;
|
||||
}
|
||||
|
||||
// Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
|
||||
// Don't double-count payload lines already absorbed as leading.
|
||||
let absorbedTrailing = 0;
|
||||
const remainingPayload = group.payload.slice(absorbedLeading);
|
||||
const remaining = remainingPayload.length;
|
||||
if (allowGenericBoundaryAbsorb) {
|
||||
const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
|
||||
for (let count = maxTrail; count >= 2; count--) {
|
||||
let ok = true;
|
||||
for (let offset = 0; offset < count; offset++) {
|
||||
if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
absorbedTrailing = count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
absorbedTrailing === 0 &&
|
||||
remaining > 0 &&
|
||||
belowStartIdx < fileLines.length &&
|
||||
isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) &&
|
||||
remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx] &&
|
||||
shouldDropSingleStructuralBoundary(remainingPayload, remainingPayload.slice(0, -1), ZERO_DELIMITER_BALANCE)
|
||||
) {
|
||||
absorbedTrailing = 1;
|
||||
}
|
||||
|
||||
if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
|
||||
|
||||
return {
|
||||
keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
|
||||
absorbedLeading,
|
||||
absorbedTrailing,
|
||||
leadingFileRange:
|
||||
absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
|
||||
trailingFileRange:
|
||||
absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function absorbReplacementBoundaryDuplicates(
|
||||
edits: HashlineEdit[],
|
||||
fileLines: string[],
|
||||
warnings: string[],
|
||||
options: HashlineApplyOptions,
|
||||
): HashlineEdit[] {
|
||||
let nextSyntheticIndex = edits.length;
|
||||
const absorbed: HashlineEdit[] = [];
|
||||
|
||||
// Anchor targets are stable across the loop because we only ever append
|
||||
// synthetic deletes (never mutate originals). A line in this set that
|
||||
// falls outside the current group's range is necessarily owned by another
|
||||
// op, so absorbing it would silently steal its target.
|
||||
const allTargetLines = collectAnchorTargetLines(edits);
|
||||
const emittedAbsorbKeys = new Set<string>();
|
||||
|
||||
for (let index = 0; index < edits.length; index++) {
|
||||
const group = findReplacementGroup(edits, index);
|
||||
if (!group) {
|
||||
const pureInsert = findPureInsertGroup(edits, index);
|
||||
if (pureInsert) {
|
||||
const result = tryAbsorbPureInsertGroup(
|
||||
pureInsert,
|
||||
fileLines,
|
||||
options.autoDropPureInsertDuplicates === true,
|
||||
);
|
||||
if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
|
||||
if (result.leadingFileRange) {
|
||||
const { start, end } = result.leadingFileRange;
|
||||
const key = `pure-insert-leading:${start}..${end}`;
|
||||
if (!emittedAbsorbKeys.has(key)) {
|
||||
emittedAbsorbKeys.add(key);
|
||||
warnings.push(
|
||||
`Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
|
||||
`(file lines ${start}..${end} already match the payload's leading lines).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result.trailingFileRange) {
|
||||
const { start, end } = result.trailingFileRange;
|
||||
const key = `pure-insert-trailing:${start}..${end}`;
|
||||
if (!emittedAbsorbKeys.has(key)) {
|
||||
emittedAbsorbKeys.add(key);
|
||||
warnings.push(
|
||||
`Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
|
||||
`(file lines ${start}..${end} already match the payload's trailing lines).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const text of result.keptPayload) {
|
||||
absorbed.push({
|
||||
kind: "insert",
|
||||
cursor: cloneCursor(pureInsert.cursor),
|
||||
text,
|
||||
lineNum: pureInsert.sourceLineNum,
|
||||
index: nextSyntheticIndex++,
|
||||
});
|
||||
}
|
||||
index = pureInsert.endIndex;
|
||||
continue;
|
||||
}
|
||||
for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) {
|
||||
absorbed.push(edits[groupIndex]);
|
||||
}
|
||||
index = pureInsert.endIndex;
|
||||
continue;
|
||||
}
|
||||
absorbed.push(edits[index]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const startLine = group.deletes[0].anchor.line;
|
||||
const endLine = group.deletes[group.deletes.length - 1].anchor.line;
|
||||
|
||||
const deletedBalance = computeDelimiterBalance(
|
||||
group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""),
|
||||
);
|
||||
const prefixCount =
|
||||
countMatchingPrefixBlock(fileLines, startLine, group.replacement) ||
|
||||
countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance);
|
||||
const suffixCount =
|
||||
countMatchingSuffixBlock(fileLines, endLine, group.replacement) ||
|
||||
countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance);
|
||||
const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
|
||||
const suffixLines = contiguousRange(endLine + 1, suffixCount);
|
||||
const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
|
||||
const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount;
|
||||
|
||||
if (safePrefixCount > 0) {
|
||||
const absorbStart = startLine - safePrefixCount;
|
||||
const key = `prefix:${absorbStart}..${startLine - 1}`;
|
||||
if (!emittedAbsorbKeys.has(key)) {
|
||||
emittedAbsorbKeys.add(key);
|
||||
warnings.push(
|
||||
`Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` +
|
||||
`(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` +
|
||||
`widened the deletion to absorb them).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (safeSuffixCount > 0) {
|
||||
const absorbEnd = endLine + safeSuffixCount;
|
||||
const key = `suffix:${endLine + 1}..${absorbEnd}`;
|
||||
if (!emittedAbsorbKeys.has(key)) {
|
||||
emittedAbsorbKeys.add(key);
|
||||
warnings.push(
|
||||
`Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` +
|
||||
`(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` +
|
||||
`widened the deletion to absorb them).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
|
||||
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
|
||||
}
|
||||
for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
|
||||
absorbed.push(edits[groupIndex]);
|
||||
}
|
||||
for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
|
||||
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
|
||||
}
|
||||
|
||||
index = group.endIndex;
|
||||
}
|
||||
|
||||
return absorbed;
|
||||
}
|
||||
|
||||
function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
|
||||
const byLine = new Map<number, IndexedEdit[]>();
|
||||
for (const entry of edits) {
|
||||
const line =
|
||||
entry.edit.kind === "delete"
|
||||
? entry.edit.anchor.line
|
||||
: entry.edit.kind === "modify"
|
||||
? entry.edit.anchor.line
|
||||
: entry.edit.cursor.kind === "before_anchor"
|
||||
? entry.edit.cursor.anchor.line
|
||||
: 0;
|
||||
const bucket = byLine.get(line);
|
||||
if (bucket) bucket.push(entry);
|
||||
else byLine.set(line, [entry]);
|
||||
}
|
||||
return byLine;
|
||||
}
|
||||
|
||||
export function applyHashlineEdits(
|
||||
text: string,
|
||||
edits: HashlineEdit[],
|
||||
options: HashlineApplyOptions = {},
|
||||
): HashlineApplyResult {
|
||||
if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
|
||||
|
||||
const fileLines = text.split("\n");
|
||||
const lineOrigins: HashlineLineOrigin[] = fileLines.map(() => "original");
|
||||
const warnings: string[] = [];
|
||||
|
||||
let firstChangedLine: number | undefined;
|
||||
const trackFirstChanged = (line: number) => {
|
||||
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
||||
};
|
||||
|
||||
const mismatches = validateHashlineAnchors(edits, fileLines);
|
||||
if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
|
||||
|
||||
const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
|
||||
|
||||
// Normalize after_anchor inserts to before_anchor of the next line, or EOF
|
||||
// when the anchor is the final line. This keeps the bucketing logic below
|
||||
// (which only knows about before_anchor / bof / eof) untouched.
|
||||
for (const edit of normalizedEdits) {
|
||||
if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue;
|
||||
const anchorLine = edit.cursor.anchor.line;
|
||||
if (anchorLine >= fileLines.length) {
|
||||
edit.cursor = { kind: "eof" };
|
||||
continue;
|
||||
}
|
||||
const nextLineNum = anchorLine + 1;
|
||||
const nextContent = fileLines[nextLineNum - 1] ?? "";
|
||||
edit.cursor = {
|
||||
kind: "before_anchor",
|
||||
anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) },
|
||||
};
|
||||
}
|
||||
|
||||
// Partition edits into BOF, EOF, and anchor-targeted buckets.
|
||||
const bofLines: string[] = [];
|
||||
const eofLines: string[] = [];
|
||||
const anchorEdits: IndexedEdit[] = [];
|
||||
normalizedEdits.forEach((edit, idx) => {
|
||||
if (edit.kind === "insert" && edit.cursor.kind === "bof") {
|
||||
bofLines.push(edit.text);
|
||||
} else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
|
||||
eofLines.push(edit.text);
|
||||
} else {
|
||||
anchorEdits.push({ edit, idx });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply per-line buckets bottom-up so earlier indices stay valid.
|
||||
const byLine = bucketAnchorEditsByLine(anchorEdits);
|
||||
for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
|
||||
const bucket = byLine.get(line);
|
||||
if (!bucket) continue;
|
||||
bucket.sort((a, b) => a.idx - b.idx);
|
||||
|
||||
const idx = line - 1;
|
||||
const currentLine = fileLines[idx] ?? "";
|
||||
const beforeLines: string[] = [];
|
||||
let deleteLine = false;
|
||||
let prefix = "";
|
||||
let suffix = "";
|
||||
let modified = false;
|
||||
|
||||
for (const { edit } of bucket) {
|
||||
if (edit.kind === "insert") {
|
||||
beforeLines.push(edit.text);
|
||||
} else if (edit.kind === "delete") {
|
||||
deleteLine = true;
|
||||
} else if (edit.kind === "modify") {
|
||||
prefix = edit.prefix + prefix;
|
||||
suffix = suffix + edit.suffix;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (beforeLines.length === 0 && !deleteLine && !modified) continue;
|
||||
if (deleteLine && modified) {
|
||||
throw new Error(
|
||||
`line ${line}: cannot combine inline modify ("< ${line}${HL_EDIT_SEP}…" or "+ ${line}${HL_EDIT_SEP}…") with a delete or replace targeting the same line.`,
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveLine = modified ? prefix + currentLine + suffix : currentLine;
|
||||
const replacement = deleteLine ? beforeLines : [...beforeLines, effectiveLine];
|
||||
const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
|
||||
if (!deleteLine) {
|
||||
origins[origins.length - 1] = modified ? "replacement" : (lineOrigins[idx] ?? "original");
|
||||
}
|
||||
|
||||
fileLines.splice(idx, 1, ...replacement);
|
||||
lineOrigins.splice(idx, 1, ...origins);
|
||||
trackFirstChanged(line);
|
||||
}
|
||||
|
||||
if (bofLines.length > 0) {
|
||||
insertAtStart(fileLines, lineOrigins, bofLines);
|
||||
trackFirstChanged(1);
|
||||
}
|
||||
const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
|
||||
if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
|
||||
|
||||
return {
|
||||
lines: fileLines.join("\n"),
|
||||
firstChangedLine,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
[
|
||||
"aa",
|
||||
"ab",
|
||||
"ac",
|
||||
"ad",
|
||||
"ae",
|
||||
"af",
|
||||
"ag",
|
||||
"ah",
|
||||
"ai",
|
||||
"aj",
|
||||
"ak",
|
||||
"al",
|
||||
"am",
|
||||
"an",
|
||||
"ao",
|
||||
"ap",
|
||||
"aq",
|
||||
"ar",
|
||||
"as",
|
||||
"at",
|
||||
"au",
|
||||
"av",
|
||||
"aw",
|
||||
"ax",
|
||||
"ay",
|
||||
"az",
|
||||
"ba",
|
||||
"bb",
|
||||
"bc",
|
||||
"bd",
|
||||
"be",
|
||||
"bf",
|
||||
"bg",
|
||||
"bh",
|
||||
"bi",
|
||||
"bj",
|
||||
"bk",
|
||||
"bl",
|
||||
"bm",
|
||||
"bn",
|
||||
"bo",
|
||||
"bp",
|
||||
"br",
|
||||
"bs",
|
||||
"bt",
|
||||
"bu",
|
||||
"bv",
|
||||
"bw",
|
||||
"bx",
|
||||
"by",
|
||||
"bz",
|
||||
"ca",
|
||||
"cb",
|
||||
"cc",
|
||||
"cd",
|
||||
"ce",
|
||||
"cf",
|
||||
"cg",
|
||||
"ch",
|
||||
"ci",
|
||||
"cj",
|
||||
"ck",
|
||||
"cl",
|
||||
"cm",
|
||||
"cn",
|
||||
"co",
|
||||
"cp",
|
||||
"cq",
|
||||
"cr",
|
||||
"cs",
|
||||
"ct",
|
||||
"cu",
|
||||
"cv",
|
||||
"cw",
|
||||
"cx",
|
||||
"cy",
|
||||
"cz",
|
||||
"da",
|
||||
"db",
|
||||
"dc",
|
||||
"dd",
|
||||
"de",
|
||||
"df",
|
||||
"dg",
|
||||
"dh",
|
||||
"di",
|
||||
"dj",
|
||||
"dk",
|
||||
"dl",
|
||||
"dm",
|
||||
"dn",
|
||||
"do",
|
||||
"dp",
|
||||
"dq",
|
||||
"dr",
|
||||
"ds",
|
||||
"dt",
|
||||
"du",
|
||||
"dv",
|
||||
"dw",
|
||||
"dx",
|
||||
"dy",
|
||||
"dz",
|
||||
"ea",
|
||||
"eb",
|
||||
"ec",
|
||||
"ed",
|
||||
"ee",
|
||||
"ef",
|
||||
"eg",
|
||||
"eh",
|
||||
"ei",
|
||||
"ej",
|
||||
"ek",
|
||||
"el",
|
||||
"em",
|
||||
"en",
|
||||
"eo",
|
||||
"ep",
|
||||
"eq",
|
||||
"er",
|
||||
"es",
|
||||
"et",
|
||||
"eu",
|
||||
"ev",
|
||||
"ew",
|
||||
"ex",
|
||||
"ey",
|
||||
"ez",
|
||||
"fa",
|
||||
"fb",
|
||||
"fc",
|
||||
"fd",
|
||||
"fe",
|
||||
"ff",
|
||||
"fg",
|
||||
"fh",
|
||||
"fi",
|
||||
"fj",
|
||||
"fk",
|
||||
"fl",
|
||||
"fm",
|
||||
"fn",
|
||||
"fo",
|
||||
"fp",
|
||||
"fq",
|
||||
"fr",
|
||||
"fs",
|
||||
"ft",
|
||||
"fu",
|
||||
"fv",
|
||||
"fw",
|
||||
"fx",
|
||||
"fy",
|
||||
"fz",
|
||||
"ga",
|
||||
"gb",
|
||||
"gc",
|
||||
"gd",
|
||||
"ge",
|
||||
"gf",
|
||||
"gg",
|
||||
"gh",
|
||||
"gi",
|
||||
"gj",
|
||||
"gl",
|
||||
"gm",
|
||||
"gn",
|
||||
"go",
|
||||
"gp",
|
||||
"gr",
|
||||
"gs",
|
||||
"gt",
|
||||
"gu",
|
||||
"gv",
|
||||
"gw",
|
||||
"gx",
|
||||
"gy",
|
||||
"gz",
|
||||
"ha",
|
||||
"hb",
|
||||
"hc",
|
||||
"hd",
|
||||
"he",
|
||||
"hf",
|
||||
"hg",
|
||||
"hh",
|
||||
"hi",
|
||||
"hj",
|
||||
"hk",
|
||||
"hl",
|
||||
"hm",
|
||||
"hn",
|
||||
"ho",
|
||||
"hp",
|
||||
"hq",
|
||||
"hr",
|
||||
"hs",
|
||||
"ht",
|
||||
"hu",
|
||||
"hv",
|
||||
"hw",
|
||||
"hx",
|
||||
"hy",
|
||||
"hz",
|
||||
"ia",
|
||||
"ib",
|
||||
"ic",
|
||||
"id",
|
||||
"ie",
|
||||
"if",
|
||||
"ig",
|
||||
"ih",
|
||||
"ii",
|
||||
"ij",
|
||||
"ik",
|
||||
"il",
|
||||
"im",
|
||||
"in",
|
||||
"io",
|
||||
"ip",
|
||||
"iq",
|
||||
"ir",
|
||||
"is",
|
||||
"it",
|
||||
"iu",
|
||||
"iv",
|
||||
"iw",
|
||||
"ix",
|
||||
"iy",
|
||||
"iz",
|
||||
"ja",
|
||||
"jb",
|
||||
"jc",
|
||||
"jd",
|
||||
"je",
|
||||
"jf",
|
||||
"jg",
|
||||
"jh",
|
||||
"ji",
|
||||
"jj",
|
||||
"jk",
|
||||
"jl",
|
||||
"jm",
|
||||
"jn",
|
||||
"jo",
|
||||
"jp",
|
||||
"jq",
|
||||
"jr",
|
||||
"js",
|
||||
"jt",
|
||||
"ju",
|
||||
"jw",
|
||||
"jx",
|
||||
"jy",
|
||||
"ka",
|
||||
"kb",
|
||||
"kc",
|
||||
"kd",
|
||||
"ke",
|
||||
"kf",
|
||||
"kg",
|
||||
"kh",
|
||||
"ki",
|
||||
"kj",
|
||||
"kk",
|
||||
"kl",
|
||||
"km",
|
||||
"kn",
|
||||
"ko",
|
||||
"kp",
|
||||
"kr",
|
||||
"ks",
|
||||
"kt",
|
||||
"ku",
|
||||
"kv",
|
||||
"kw",
|
||||
"kx",
|
||||
"ky",
|
||||
"la",
|
||||
"lb",
|
||||
"lc",
|
||||
"ld",
|
||||
"le",
|
||||
"lf",
|
||||
"lg",
|
||||
"lh",
|
||||
"li",
|
||||
"lj",
|
||||
"lk",
|
||||
"ll",
|
||||
"lm",
|
||||
"ln",
|
||||
"lo",
|
||||
"lp",
|
||||
"lr",
|
||||
"ls",
|
||||
"lt",
|
||||
"lu",
|
||||
"lv",
|
||||
"lw",
|
||||
"lx",
|
||||
"ly",
|
||||
"lz",
|
||||
"ma",
|
||||
"mb",
|
||||
"mc",
|
||||
"md",
|
||||
"me",
|
||||
"mf",
|
||||
"mg",
|
||||
"mh",
|
||||
"mi",
|
||||
"mj",
|
||||
"mk",
|
||||
"ml",
|
||||
"mm",
|
||||
"mn",
|
||||
"mo",
|
||||
"mp",
|
||||
"mq",
|
||||
"mr",
|
||||
"ms",
|
||||
"mt",
|
||||
"mu",
|
||||
"mv",
|
||||
"mw",
|
||||
"mx",
|
||||
"my",
|
||||
"mz",
|
||||
"na",
|
||||
"nb",
|
||||
"nc",
|
||||
"nd",
|
||||
"ne",
|
||||
"nf",
|
||||
"ng",
|
||||
"nh",
|
||||
"ni",
|
||||
"nj",
|
||||
"nk",
|
||||
"nl",
|
||||
"nm",
|
||||
"nn",
|
||||
"no",
|
||||
"np",
|
||||
"nr",
|
||||
"ns",
|
||||
"nt",
|
||||
"nu",
|
||||
"nv",
|
||||
"nw",
|
||||
"nx",
|
||||
"ny",
|
||||
"nz",
|
||||
"oa",
|
||||
"ob",
|
||||
"oc",
|
||||
"od",
|
||||
"oe",
|
||||
"of",
|
||||
"og",
|
||||
"oh",
|
||||
"oi",
|
||||
"oj",
|
||||
"ok",
|
||||
"ol",
|
||||
"om",
|
||||
"on",
|
||||
"oo",
|
||||
"op",
|
||||
"oq",
|
||||
"or",
|
||||
"os",
|
||||
"ot",
|
||||
"ou",
|
||||
"ov",
|
||||
"ow",
|
||||
"ox",
|
||||
"oy",
|
||||
"oz",
|
||||
"pa",
|
||||
"pb",
|
||||
"pc",
|
||||
"pd",
|
||||
"pe",
|
||||
"pf",
|
||||
"pg",
|
||||
"ph",
|
||||
"pi",
|
||||
"pj",
|
||||
"pk",
|
||||
"pl",
|
||||
"pm",
|
||||
"pn",
|
||||
"po",
|
||||
"pp",
|
||||
"pq",
|
||||
"pr",
|
||||
"ps",
|
||||
"pt",
|
||||
"pu",
|
||||
"pv",
|
||||
"pw",
|
||||
"px",
|
||||
"py",
|
||||
"pz",
|
||||
"qa",
|
||||
"qb",
|
||||
"qc",
|
||||
"qd",
|
||||
"qe",
|
||||
"qh",
|
||||
"qi",
|
||||
"ql",
|
||||
"qm",
|
||||
"qn",
|
||||
"qo",
|
||||
"qp",
|
||||
"qq",
|
||||
"qr",
|
||||
"qs",
|
||||
"qt",
|
||||
"qu",
|
||||
"qw",
|
||||
"qx",
|
||||
"qy",
|
||||
"ra",
|
||||
"rb",
|
||||
"rc",
|
||||
"rd",
|
||||
"re",
|
||||
"rf",
|
||||
"rg",
|
||||
"rh",
|
||||
"ri",
|
||||
"rk",
|
||||
"rl",
|
||||
"rm",
|
||||
"rn",
|
||||
"ro",
|
||||
"rp",
|
||||
"rq",
|
||||
"rr",
|
||||
"rs",
|
||||
"rt",
|
||||
"ru",
|
||||
"rv",
|
||||
"rw",
|
||||
"rx",
|
||||
"ry",
|
||||
"rz",
|
||||
"sa",
|
||||
"sb",
|
||||
"sc",
|
||||
"sd",
|
||||
"se",
|
||||
"sf",
|
||||
"sg",
|
||||
"sh",
|
||||
"si",
|
||||
"sj",
|
||||
"sk",
|
||||
"sl",
|
||||
"sm",
|
||||
"sn",
|
||||
"so",
|
||||
"sp",
|
||||
"sq",
|
||||
"sr",
|
||||
"ss",
|
||||
"st",
|
||||
"su",
|
||||
"sv",
|
||||
"sw",
|
||||
"sx",
|
||||
"sy",
|
||||
"sz",
|
||||
"ta",
|
||||
"tb",
|
||||
"tc",
|
||||
"td",
|
||||
"te",
|
||||
"tf",
|
||||
"tg",
|
||||
"th",
|
||||
"ti",
|
||||
"tj",
|
||||
"tk",
|
||||
"tl",
|
||||
"tm",
|
||||
"tn",
|
||||
"to",
|
||||
"tp",
|
||||
"tr",
|
||||
"ts",
|
||||
"tt",
|
||||
"tu",
|
||||
"tv",
|
||||
"tw",
|
||||
"tx",
|
||||
"ty",
|
||||
"tz",
|
||||
"ua",
|
||||
"ub",
|
||||
"uc",
|
||||
"ud",
|
||||
"ue",
|
||||
"uf",
|
||||
"ug",
|
||||
"uh",
|
||||
"ui",
|
||||
"uj",
|
||||
"uk",
|
||||
"ul",
|
||||
"um",
|
||||
"un",
|
||||
"uo",
|
||||
"up",
|
||||
"uq",
|
||||
"ur",
|
||||
"us",
|
||||
"ut",
|
||||
"uu",
|
||||
"uv",
|
||||
"uw",
|
||||
"ux",
|
||||
"uy",
|
||||
"uz",
|
||||
"va",
|
||||
"vb",
|
||||
"vc",
|
||||
"vd",
|
||||
"ve",
|
||||
"vf",
|
||||
"vg",
|
||||
"vh",
|
||||
"vi",
|
||||
"vj",
|
||||
"vk",
|
||||
"vl",
|
||||
"vm",
|
||||
"vn",
|
||||
"vo",
|
||||
"vp",
|
||||
"vq",
|
||||
"vr",
|
||||
"vs",
|
||||
"vt",
|
||||
"vu",
|
||||
"vv",
|
||||
"vw",
|
||||
"vx",
|
||||
"vy",
|
||||
"vz",
|
||||
"wa",
|
||||
"wb",
|
||||
"wc",
|
||||
"wd",
|
||||
"we",
|
||||
"wf",
|
||||
"wg",
|
||||
"wh",
|
||||
"wi",
|
||||
"wj",
|
||||
"wk",
|
||||
"wl",
|
||||
"wm",
|
||||
"wn",
|
||||
"wo",
|
||||
"wp",
|
||||
"wr",
|
||||
"ws",
|
||||
"wt",
|
||||
"wu",
|
||||
"wv",
|
||||
"ww",
|
||||
"wx",
|
||||
"wy",
|
||||
"xa",
|
||||
"xb",
|
||||
"xc",
|
||||
"xd",
|
||||
"xe",
|
||||
"xf",
|
||||
"xh",
|
||||
"xi",
|
||||
"xl",
|
||||
"xm",
|
||||
"xn",
|
||||
"xo",
|
||||
"xp",
|
||||
"xr",
|
||||
"xs",
|
||||
"xt",
|
||||
"xu",
|
||||
"xx",
|
||||
"xy",
|
||||
"xz",
|
||||
"ya",
|
||||
"yb",
|
||||
"yc",
|
||||
"yd",
|
||||
"ye",
|
||||
"yf",
|
||||
"yg",
|
||||
"yh",
|
||||
"yi",
|
||||
"yj",
|
||||
"yk",
|
||||
"yl",
|
||||
"ym",
|
||||
"yn",
|
||||
"yo",
|
||||
"yp",
|
||||
"yr",
|
||||
"ys",
|
||||
"yt",
|
||||
"yu",
|
||||
"yv",
|
||||
"yw",
|
||||
"yx",
|
||||
"yy",
|
||||
"yz",
|
||||
"za",
|
||||
"zb",
|
||||
"zc",
|
||||
"zd",
|
||||
"ze",
|
||||
"zf",
|
||||
"zg",
|
||||
"zh",
|
||||
"zi",
|
||||
"zk",
|
||||
"zl",
|
||||
"zm",
|
||||
"zn",
|
||||
"zo",
|
||||
"zp",
|
||||
"zr",
|
||||
"zs",
|
||||
"zt",
|
||||
"zu",
|
||||
"zw",
|
||||
"zx",
|
||||
"zy",
|
||||
"zz"
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Lines of context shown either side of a hash mismatch. */
|
||||
export const MISMATCH_CONTEXT = 2;
|
||||
|
||||
/** Filler hash used for the interior of a multi-line range; not validated. */
|
||||
export const RANGE_INTERIOR_HASH = "**";
|
||||
|
||||
/** Header marker introducing a new file section in multi-section input. */
|
||||
export const FILE_HEADER_PREFIX = "@";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { computeLineHash, HL_BODY_SEP } from "./hash";
|
||||
import type { CompactHashlineDiffOptions, CompactHashlineDiffPreview } from "./types";
|
||||
|
||||
export function buildCompactHashlineDiffPreview(
|
||||
diff: string,
|
||||
_options: CompactHashlineDiffOptions = {},
|
||||
): CompactHashlineDiffPreview {
|
||||
const lines = diff.length === 0 ? [] : diff.split("\n");
|
||||
let addedLines = 0;
|
||||
let removedLines = 0;
|
||||
|
||||
// `generateDiffString` numbers `+` lines with the post-edit line number,
|
||||
// `-` lines with the pre-edit line number, and context lines with the
|
||||
// pre-edit line number. To emit fresh anchors usable for follow-up edits,
|
||||
// we convert context-line numbers to post-edit positions by tracking the
|
||||
// running offset (added so far - removed so far) as we walk the diff.
|
||||
const formatted = lines.map(line => {
|
||||
const kind = line[0];
|
||||
if (kind !== "+" && kind !== "-" && kind !== " ") return line;
|
||||
|
||||
const body = line.slice(1);
|
||||
const sep = body.indexOf("|");
|
||||
if (sep === -1) return line;
|
||||
|
||||
const lineNumber = Number.parseInt(body.slice(0, sep), 10);
|
||||
const content = body.slice(sep + 1);
|
||||
|
||||
switch (kind) {
|
||||
case "+":
|
||||
addedLines++;
|
||||
return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`;
|
||||
case "-":
|
||||
removedLines++;
|
||||
return `-${lineNumber}--${HL_BODY_SEP}${content}`;
|
||||
default: {
|
||||
const newLineNumber = lineNumber + addedLines - removedLines;
|
||||
return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { preview: formatted.join("\n"), addedLines, removedLines };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { generateDiffString } from "../edit/diff";
|
||||
import { normalizeToLF, stripBom } from "../edit/normalize";
|
||||
import { readEditFileText } from "../edit/read-file";
|
||||
import { resolveToCwd } from "../tools/path-utils";
|
||||
import { applyHashlineEdits } from "./apply";
|
||||
import { type HashlineInputSection, splitHashlineInputs } from "./input";
|
||||
import { parseHashline } from "./parser";
|
||||
import type { HashlineApplyOptions } from "./types";
|
||||
|
||||
async function readHashlineFileText(
|
||||
_file: { text(): Promise<string> },
|
||||
absolutePath: string,
|
||||
pathText: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await readEditFileText(absolutePath, pathText);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(message || `Unable to read ${pathText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function computeHashlineSectionDiff(
|
||||
section: HashlineInputSection,
|
||||
cwd: string,
|
||||
options: HashlineApplyOptions = {},
|
||||
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
||||
try {
|
||||
const absolutePath = resolveToCwd(section.path, cwd);
|
||||
const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
|
||||
const { text: content } = stripBom(rawContent);
|
||||
const normalized = normalizeToLF(content);
|
||||
const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
|
||||
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
|
||||
return generateDiffString(normalized, result.lines);
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function computeHashlineDiff(
|
||||
input: { input: string; path?: string },
|
||||
cwd: string,
|
||||
options: HashlineApplyOptions = {},
|
||||
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
||||
let sections: HashlineInputSection[];
|
||||
try {
|
||||
sections = splitHashlineInputs(input.input, { cwd, path: input.path });
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (sections.length !== 1) {
|
||||
return { error: "Streaming diff preview supports exactly one hashline section." };
|
||||
}
|
||||
return computeHashlineSectionDiff(sections[0], cwd, options);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { generateDiffString } from "../edit/diff";
|
||||
import { getFileReadCache } from "../edit/file-read-cache";
|
||||
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../edit/normalize";
|
||||
import { readEditFileText, serializeEditFileText } from "../edit/read-file";
|
||||
import type { EditToolDetails } from "../edit/renderer";
|
||||
import type { ToolSession } from "../tools";
|
||||
import { assertEditableFileContent } from "../tools/auto-generated-guard";
|
||||
import { invalidateFsScanAfterWrite } from "../tools/fs-cache-invalidation";
|
||||
import { outputMeta } from "../tools/output-meta";
|
||||
import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard";
|
||||
import { HashlineMismatchError } from "./anchors";
|
||||
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
|
||||
import { buildCompactHashlineDiffPreview } from "./diff-preview";
|
||||
import { type HashlineInputSection, splitHashlineInputs } from "./input";
|
||||
import { parseHashlineWithWarnings } from "./parser";
|
||||
import { tryRecoverHashlineWithCache } from "./recovery";
|
||||
import type {
|
||||
ExecuteHashlineSingleOptions,
|
||||
HashlineApplyOptions,
|
||||
HashlineEdit,
|
||||
hashlineEditParamsSchema,
|
||||
} from "./types";
|
||||
|
||||
interface ReadHashlineFileResult {
|
||||
exists: boolean;
|
||||
rawContent: string;
|
||||
}
|
||||
|
||||
async function readHashlineFile(absolutePath: string, pathText: string): Promise<ReadHashlineFileResult> {
|
||||
try {
|
||||
return { exists: true, rawContent: await readEditFileText(absolutePath, pathText) };
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return { exists: false, rawContent: "" };
|
||||
if (error instanceof Error && error.message === `File not found: ${pathText}`)
|
||||
return { exists: false, rawContent: "" };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
|
||||
return edits.some(edit => {
|
||||
if (edit.kind === "delete") return true;
|
||||
if (edit.kind === "modify") return true;
|
||||
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
||||
});
|
||||
}
|
||||
|
||||
function formatNoChangeDiagnostic(pathText: string): string {
|
||||
return `Edits to ${pathText} resulted in no changes being made.`;
|
||||
}
|
||||
|
||||
function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions {
|
||||
return {
|
||||
autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
|
||||
};
|
||||
}
|
||||
|
||||
function getTextContent(result: AgentToolResult<EditToolDetails>): string {
|
||||
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
|
||||
}
|
||||
|
||||
function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
|
||||
return result.details ?? { diff: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply hashline edits with anchor-stale recovery: on `HashlineMismatchError`,
|
||||
* consult the read-snapshot cache for the file and 3-way-merge the edits onto
|
||||
* the current text. If recovery succeeds, return the merged result with a
|
||||
* synthetic warning. Otherwise re-throw the original mismatch error.
|
||||
*/
|
||||
function applyHashlineEditsWithRecovery(
|
||||
session: ToolSession,
|
||||
absolutePath: string,
|
||||
text: string,
|
||||
edits: HashlineEdit[],
|
||||
options: HashlineApplyOptions,
|
||||
): HashlineApplyResult {
|
||||
try {
|
||||
return applyHashlineEdits(text, edits, options);
|
||||
} catch (err) {
|
||||
if (!(err instanceof HashlineMismatchError)) throw err;
|
||||
const recovered = tryRecoverHashlineWithCache({
|
||||
cache: getFileReadCache(session),
|
||||
absolutePath,
|
||||
currentText: text,
|
||||
edits,
|
||||
options,
|
||||
});
|
||||
if (!recovered) throw err;
|
||||
return {
|
||||
lines: recovered.lines,
|
||||
firstChangedLine: recovered.firstChangedLine,
|
||||
warnings: recovered.warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all the front-end checks (notebook guard, parse, plan-mode check, file
|
||||
* load, edit application) without writing. Used to fail fast before applying
|
||||
* any changes in a multi-section batch.
|
||||
*/
|
||||
async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
|
||||
const { session, path: sectionPath, diff } = options;
|
||||
|
||||
const absolutePath = resolvePlanPath(session, sectionPath);
|
||||
const { edits } = parseHashlineWithWarnings(diff);
|
||||
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
||||
|
||||
const source = await readHashlineFile(absolutePath, sectionPath);
|
||||
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`);
|
||||
if (source.exists) assertEditableFileContent(source.rawContent, sectionPath);
|
||||
|
||||
const { text } = stripBom(source.rawContent);
|
||||
const normalized = normalizeToLF(text);
|
||||
const result = applyHashlineEditsWithRecovery(
|
||||
session,
|
||||
absolutePath,
|
||||
normalized,
|
||||
edits,
|
||||
getHashlineApplyOptions(session),
|
||||
);
|
||||
if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
|
||||
}
|
||||
|
||||
async function executeHashlineSection(
|
||||
options: ExecuteHashlineSingleOptions & HashlineInputSection,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
||||
const {
|
||||
session,
|
||||
path: sourcePath,
|
||||
diff,
|
||||
signal,
|
||||
batchRequest,
|
||||
writethrough,
|
||||
beginDeferredDiagnosticsForPath,
|
||||
} = options;
|
||||
|
||||
const absolutePath = resolvePlanPath(session, sourcePath);
|
||||
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
|
||||
enforcePlanModeWrite(session, sourcePath, { op: "update" });
|
||||
|
||||
const source = await readHashlineFile(absolutePath, sourcePath);
|
||||
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`);
|
||||
if (source.exists) assertEditableFileContent(source.rawContent, sourcePath);
|
||||
|
||||
const { bom, text } = stripBom(source.rawContent);
|
||||
const originalEnding = detectLineEnding(text);
|
||||
const originalNormalized = normalizeToLF(text);
|
||||
const result = applyHashlineEditsWithRecovery(
|
||||
session,
|
||||
absolutePath,
|
||||
originalNormalized,
|
||||
edits,
|
||||
getHashlineApplyOptions(session),
|
||||
);
|
||||
|
||||
if (originalNormalized === result.lines) {
|
||||
return {
|
||||
content: [{ type: "text", text: formatNoChangeDiagnostic(sourcePath) }],
|
||||
details: { diff: "", op: "update", meta: outputMeta().get() },
|
||||
};
|
||||
}
|
||||
|
||||
const finalContent = await serializeEditFileText(
|
||||
absolutePath,
|
||||
sourcePath,
|
||||
bom + restoreLineEndings(result.lines, originalEnding),
|
||||
);
|
||||
const diagnostics = await writethrough(
|
||||
absolutePath,
|
||||
finalContent,
|
||||
signal,
|
||||
Bun.file(absolutePath),
|
||||
batchRequest,
|
||||
dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
|
||||
);
|
||||
invalidateFsScanAfterWrite(absolutePath);
|
||||
// The post-edit content is the freshest, most authoritative "model view"
|
||||
// of the file: the model just received it back as the diff/preview. Cache
|
||||
// it so a follow-up edit anchored against this state can still recover
|
||||
// if the file is touched out-of-band before the next edit lands.
|
||||
getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"));
|
||||
|
||||
const diffResult = generateDiffString(originalNormalized, result.lines);
|
||||
const meta = outputMeta()
|
||||
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
||||
.get();
|
||||
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
||||
|
||||
const warnings = [...parseWarnings, ...(result.warnings ?? [])];
|
||||
const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
||||
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
||||
const headline = preview.preview
|
||||
? `${sourcePath}:`
|
||||
: source.exists
|
||||
? `Updated ${sourcePath}`
|
||||
: `Created ${sourcePath}`;
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }],
|
||||
details: {
|
||||
diff: diffResult.diff,
|
||||
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
||||
diagnostics,
|
||||
op: source.exists ? "update" : "create",
|
||||
meta,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeHashlineSingle(
|
||||
options: ExecuteHashlineSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
||||
const sections = mergeSamePathSections(
|
||||
splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path }),
|
||||
);
|
||||
|
||||
// Fast path: a single section needs no preflight pass.
|
||||
if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] });
|
||||
|
||||
// Multi-section: validate everything up front so we don't apply a partial batch.
|
||||
for (const section of sections) await preflightHashlineSection({ ...options, ...section });
|
||||
|
||||
const results = [];
|
||||
for (const section of sections) {
|
||||
results.push({ path: section.path, result: await executeHashlineSection({ ...options, ...section }) });
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: results.map(({ result }) => getTextContent(result)).join("\n\n") }],
|
||||
details: {
|
||||
diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
|
||||
perFileResults: results.map(({ path: resultPath, result }) => {
|
||||
const details = getEditDetails(result);
|
||||
return {
|
||||
path: resultPath,
|
||||
diff: details.diff,
|
||||
firstChangedLine: details.firstChangedLine,
|
||||
diagnostics: details.diagnostics,
|
||||
op: details.op,
|
||||
move: details.move,
|
||||
meta: details.meta,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse consecutive or interleaved sections targeting the same path into a
|
||||
* single section with concatenated diffs. Anchors authored against the same
|
||||
* file snapshot must be applied as one batch; otherwise the first sub-edit
|
||||
* shifts line numbers out from under the second's anchors and validation fails.
|
||||
* Path order is preserved by first occurrence.
|
||||
*/
|
||||
function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
|
||||
const byPath = new Map<string, string[]>();
|
||||
for (const section of sections) {
|
||||
const existing = byPath.get(section.path);
|
||||
if (existing) existing.push(section.diff);
|
||||
else byPath.set(section.path, [section.diff]);
|
||||
}
|
||||
return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") }));
|
||||
}
|
||||
+5
-651
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Lightweight line-hash utilities extracted from hashline.ts to avoid
|
||||
* circular dependencies (prompt-templates → hashline → tools → edit).
|
||||
* Core hash utilities shared by hashline edit mode, read/search output,
|
||||
* and prompt helpers.
|
||||
*/
|
||||
|
||||
import bigrams from "./bigrams.json" with { type: "json" };
|
||||
|
||||
/**
|
||||
* 647 single-token BPE bigrams for hashline anchors. Every entry tokenizes as
|
||||
* exactly one token in modern BPE vocabularies (cl100k / o200k / Claude family),
|
||||
@@ -15,655 +17,7 @@
|
||||
* Order is stable forever — changing it would invalidate every saved
|
||||
* `LINE+ID` reference in transcripts and prompts.
|
||||
*/
|
||||
export const HL_BIGRAMS = [
|
||||
"aa",
|
||||
"ab",
|
||||
"ac",
|
||||
"ad",
|
||||
"ae",
|
||||
"af",
|
||||
"ag",
|
||||
"ah",
|
||||
"ai",
|
||||
"aj",
|
||||
"ak",
|
||||
"al",
|
||||
"am",
|
||||
"an",
|
||||
"ao",
|
||||
"ap",
|
||||
"aq",
|
||||
"ar",
|
||||
"as",
|
||||
"at",
|
||||
"au",
|
||||
"av",
|
||||
"aw",
|
||||
"ax",
|
||||
"ay",
|
||||
"az",
|
||||
"ba",
|
||||
"bb",
|
||||
"bc",
|
||||
"bd",
|
||||
"be",
|
||||
"bf",
|
||||
"bg",
|
||||
"bh",
|
||||
"bi",
|
||||
"bj",
|
||||
"bk",
|
||||
"bl",
|
||||
"bm",
|
||||
"bn",
|
||||
"bo",
|
||||
"bp",
|
||||
"br",
|
||||
"bs",
|
||||
"bt",
|
||||
"bu",
|
||||
"bv",
|
||||
"bw",
|
||||
"bx",
|
||||
"by",
|
||||
"bz",
|
||||
"ca",
|
||||
"cb",
|
||||
"cc",
|
||||
"cd",
|
||||
"ce",
|
||||
"cf",
|
||||
"cg",
|
||||
"ch",
|
||||
"ci",
|
||||
"cj",
|
||||
"ck",
|
||||
"cl",
|
||||
"cm",
|
||||
"cn",
|
||||
"co",
|
||||
"cp",
|
||||
"cq",
|
||||
"cr",
|
||||
"cs",
|
||||
"ct",
|
||||
"cu",
|
||||
"cv",
|
||||
"cw",
|
||||
"cx",
|
||||
"cy",
|
||||
"cz",
|
||||
"da",
|
||||
"db",
|
||||
"dc",
|
||||
"dd",
|
||||
"de",
|
||||
"df",
|
||||
"dg",
|
||||
"dh",
|
||||
"di",
|
||||
"dj",
|
||||
"dk",
|
||||
"dl",
|
||||
"dm",
|
||||
"dn",
|
||||
"do",
|
||||
"dp",
|
||||
"dq",
|
||||
"dr",
|
||||
"ds",
|
||||
"dt",
|
||||
"du",
|
||||
"dv",
|
||||
"dw",
|
||||
"dx",
|
||||
"dy",
|
||||
"dz",
|
||||
"ea",
|
||||
"eb",
|
||||
"ec",
|
||||
"ed",
|
||||
"ee",
|
||||
"ef",
|
||||
"eg",
|
||||
"eh",
|
||||
"ei",
|
||||
"ej",
|
||||
"ek",
|
||||
"el",
|
||||
"em",
|
||||
"en",
|
||||
"eo",
|
||||
"ep",
|
||||
"eq",
|
||||
"er",
|
||||
"es",
|
||||
"et",
|
||||
"eu",
|
||||
"ev",
|
||||
"ew",
|
||||
"ex",
|
||||
"ey",
|
||||
"ez",
|
||||
"fa",
|
||||
"fb",
|
||||
"fc",
|
||||
"fd",
|
||||
"fe",
|
||||
"ff",
|
||||
"fg",
|
||||
"fh",
|
||||
"fi",
|
||||
"fj",
|
||||
"fk",
|
||||
"fl",
|
||||
"fm",
|
||||
"fn",
|
||||
"fo",
|
||||
"fp",
|
||||
"fq",
|
||||
"fr",
|
||||
"fs",
|
||||
"ft",
|
||||
"fu",
|
||||
"fv",
|
||||
"fw",
|
||||
"fx",
|
||||
"fy",
|
||||
"fz",
|
||||
"ga",
|
||||
"gb",
|
||||
"gc",
|
||||
"gd",
|
||||
"ge",
|
||||
"gf",
|
||||
"gg",
|
||||
"gh",
|
||||
"gi",
|
||||
"gj",
|
||||
"gl",
|
||||
"gm",
|
||||
"gn",
|
||||
"go",
|
||||
"gp",
|
||||
"gr",
|
||||
"gs",
|
||||
"gt",
|
||||
"gu",
|
||||
"gv",
|
||||
"gw",
|
||||
"gx",
|
||||
"gy",
|
||||
"gz",
|
||||
"ha",
|
||||
"hb",
|
||||
"hc",
|
||||
"hd",
|
||||
"he",
|
||||
"hf",
|
||||
"hg",
|
||||
"hh",
|
||||
"hi",
|
||||
"hj",
|
||||
"hk",
|
||||
"hl",
|
||||
"hm",
|
||||
"hn",
|
||||
"ho",
|
||||
"hp",
|
||||
"hq",
|
||||
"hr",
|
||||
"hs",
|
||||
"ht",
|
||||
"hu",
|
||||
"hv",
|
||||
"hw",
|
||||
"hx",
|
||||
"hy",
|
||||
"hz",
|
||||
"ia",
|
||||
"ib",
|
||||
"ic",
|
||||
"id",
|
||||
"ie",
|
||||
"if",
|
||||
"ig",
|
||||
"ih",
|
||||
"ii",
|
||||
"ij",
|
||||
"ik",
|
||||
"il",
|
||||
"im",
|
||||
"in",
|
||||
"io",
|
||||
"ip",
|
||||
"iq",
|
||||
"ir",
|
||||
"is",
|
||||
"it",
|
||||
"iu",
|
||||
"iv",
|
||||
"iw",
|
||||
"ix",
|
||||
"iy",
|
||||
"iz",
|
||||
"ja",
|
||||
"jb",
|
||||
"jc",
|
||||
"jd",
|
||||
"je",
|
||||
"jf",
|
||||
"jg",
|
||||
"jh",
|
||||
"ji",
|
||||
"jj",
|
||||
"jk",
|
||||
"jl",
|
||||
"jm",
|
||||
"jn",
|
||||
"jo",
|
||||
"jp",
|
||||
"jq",
|
||||
"jr",
|
||||
"js",
|
||||
"jt",
|
||||
"ju",
|
||||
"jw",
|
||||
"jx",
|
||||
"jy",
|
||||
"ka",
|
||||
"kb",
|
||||
"kc",
|
||||
"kd",
|
||||
"ke",
|
||||
"kf",
|
||||
"kg",
|
||||
"kh",
|
||||
"ki",
|
||||
"kj",
|
||||
"kk",
|
||||
"kl",
|
||||
"km",
|
||||
"kn",
|
||||
"ko",
|
||||
"kp",
|
||||
"kr",
|
||||
"ks",
|
||||
"kt",
|
||||
"ku",
|
||||
"kv",
|
||||
"kw",
|
||||
"kx",
|
||||
"ky",
|
||||
"la",
|
||||
"lb",
|
||||
"lc",
|
||||
"ld",
|
||||
"le",
|
||||
"lf",
|
||||
"lg",
|
||||
"lh",
|
||||
"li",
|
||||
"lj",
|
||||
"lk",
|
||||
"ll",
|
||||
"lm",
|
||||
"ln",
|
||||
"lo",
|
||||
"lp",
|
||||
"lr",
|
||||
"ls",
|
||||
"lt",
|
||||
"lu",
|
||||
"lv",
|
||||
"lw",
|
||||
"lx",
|
||||
"ly",
|
||||
"lz",
|
||||
"ma",
|
||||
"mb",
|
||||
"mc",
|
||||
"md",
|
||||
"me",
|
||||
"mf",
|
||||
"mg",
|
||||
"mh",
|
||||
"mi",
|
||||
"mj",
|
||||
"mk",
|
||||
"ml",
|
||||
"mm",
|
||||
"mn",
|
||||
"mo",
|
||||
"mp",
|
||||
"mq",
|
||||
"mr",
|
||||
"ms",
|
||||
"mt",
|
||||
"mu",
|
||||
"mv",
|
||||
"mw",
|
||||
"mx",
|
||||
"my",
|
||||
"mz",
|
||||
"na",
|
||||
"nb",
|
||||
"nc",
|
||||
"nd",
|
||||
"ne",
|
||||
"nf",
|
||||
"ng",
|
||||
"nh",
|
||||
"ni",
|
||||
"nj",
|
||||
"nk",
|
||||
"nl",
|
||||
"nm",
|
||||
"nn",
|
||||
"no",
|
||||
"np",
|
||||
"nr",
|
||||
"ns",
|
||||
"nt",
|
||||
"nu",
|
||||
"nv",
|
||||
"nw",
|
||||
"nx",
|
||||
"ny",
|
||||
"nz",
|
||||
"oa",
|
||||
"ob",
|
||||
"oc",
|
||||
"od",
|
||||
"oe",
|
||||
"of",
|
||||
"og",
|
||||
"oh",
|
||||
"oi",
|
||||
"oj",
|
||||
"ok",
|
||||
"ol",
|
||||
"om",
|
||||
"on",
|
||||
"oo",
|
||||
"op",
|
||||
"oq",
|
||||
"or",
|
||||
"os",
|
||||
"ot",
|
||||
"ou",
|
||||
"ov",
|
||||
"ow",
|
||||
"ox",
|
||||
"oy",
|
||||
"oz",
|
||||
"pa",
|
||||
"pb",
|
||||
"pc",
|
||||
"pd",
|
||||
"pe",
|
||||
"pf",
|
||||
"pg",
|
||||
"ph",
|
||||
"pi",
|
||||
"pj",
|
||||
"pk",
|
||||
"pl",
|
||||
"pm",
|
||||
"pn",
|
||||
"po",
|
||||
"pp",
|
||||
"pq",
|
||||
"pr",
|
||||
"ps",
|
||||
"pt",
|
||||
"pu",
|
||||
"pv",
|
||||
"pw",
|
||||
"px",
|
||||
"py",
|
||||
"pz",
|
||||
"qa",
|
||||
"qb",
|
||||
"qc",
|
||||
"qd",
|
||||
"qe",
|
||||
"qh",
|
||||
"qi",
|
||||
"ql",
|
||||
"qm",
|
||||
"qn",
|
||||
"qo",
|
||||
"qp",
|
||||
"qq",
|
||||
"qr",
|
||||
"qs",
|
||||
"qt",
|
||||
"qu",
|
||||
"qw",
|
||||
"qx",
|
||||
"qy",
|
||||
"ra",
|
||||
"rb",
|
||||
"rc",
|
||||
"rd",
|
||||
"re",
|
||||
"rf",
|
||||
"rg",
|
||||
"rh",
|
||||
"ri",
|
||||
"rk",
|
||||
"rl",
|
||||
"rm",
|
||||
"rn",
|
||||
"ro",
|
||||
"rp",
|
||||
"rq",
|
||||
"rr",
|
||||
"rs",
|
||||
"rt",
|
||||
"ru",
|
||||
"rv",
|
||||
"rw",
|
||||
"rx",
|
||||
"ry",
|
||||
"rz",
|
||||
"sa",
|
||||
"sb",
|
||||
"sc",
|
||||
"sd",
|
||||
"se",
|
||||
"sf",
|
||||
"sg",
|
||||
"sh",
|
||||
"si",
|
||||
"sj",
|
||||
"sk",
|
||||
"sl",
|
||||
"sm",
|
||||
"sn",
|
||||
"so",
|
||||
"sp",
|
||||
"sq",
|
||||
"sr",
|
||||
"ss",
|
||||
"st",
|
||||
"su",
|
||||
"sv",
|
||||
"sw",
|
||||
"sx",
|
||||
"sy",
|
||||
"sz",
|
||||
"ta",
|
||||
"tb",
|
||||
"tc",
|
||||
"td",
|
||||
"te",
|
||||
"tf",
|
||||
"tg",
|
||||
"th",
|
||||
"ti",
|
||||
"tj",
|
||||
"tk",
|
||||
"tl",
|
||||
"tm",
|
||||
"tn",
|
||||
"to",
|
||||
"tp",
|
||||
"tr",
|
||||
"ts",
|
||||
"tt",
|
||||
"tu",
|
||||
"tv",
|
||||
"tw",
|
||||
"tx",
|
||||
"ty",
|
||||
"tz",
|
||||
"ua",
|
||||
"ub",
|
||||
"uc",
|
||||
"ud",
|
||||
"ue",
|
||||
"uf",
|
||||
"ug",
|
||||
"uh",
|
||||
"ui",
|
||||
"uj",
|
||||
"uk",
|
||||
"ul",
|
||||
"um",
|
||||
"un",
|
||||
"uo",
|
||||
"up",
|
||||
"uq",
|
||||
"ur",
|
||||
"us",
|
||||
"ut",
|
||||
"uu",
|
||||
"uv",
|
||||
"uw",
|
||||
"ux",
|
||||
"uy",
|
||||
"uz",
|
||||
"va",
|
||||
"vb",
|
||||
"vc",
|
||||
"vd",
|
||||
"ve",
|
||||
"vf",
|
||||
"vg",
|
||||
"vh",
|
||||
"vi",
|
||||
"vj",
|
||||
"vk",
|
||||
"vl",
|
||||
"vm",
|
||||
"vn",
|
||||
"vo",
|
||||
"vp",
|
||||
"vq",
|
||||
"vr",
|
||||
"vs",
|
||||
"vt",
|
||||
"vu",
|
||||
"vv",
|
||||
"vw",
|
||||
"vx",
|
||||
"vy",
|
||||
"vz",
|
||||
"wa",
|
||||
"wb",
|
||||
"wc",
|
||||
"wd",
|
||||
"we",
|
||||
"wf",
|
||||
"wg",
|
||||
"wh",
|
||||
"wi",
|
||||
"wj",
|
||||
"wk",
|
||||
"wl",
|
||||
"wm",
|
||||
"wn",
|
||||
"wo",
|
||||
"wp",
|
||||
"wr",
|
||||
"ws",
|
||||
"wt",
|
||||
"wu",
|
||||
"wv",
|
||||
"ww",
|
||||
"wx",
|
||||
"wy",
|
||||
"xa",
|
||||
"xb",
|
||||
"xc",
|
||||
"xd",
|
||||
"xe",
|
||||
"xf",
|
||||
"xh",
|
||||
"xi",
|
||||
"xl",
|
||||
"xm",
|
||||
"xn",
|
||||
"xo",
|
||||
"xp",
|
||||
"xr",
|
||||
"xs",
|
||||
"xt",
|
||||
"xu",
|
||||
"xx",
|
||||
"xy",
|
||||
"xz",
|
||||
"ya",
|
||||
"yb",
|
||||
"yc",
|
||||
"yd",
|
||||
"ye",
|
||||
"yf",
|
||||
"yg",
|
||||
"yh",
|
||||
"yi",
|
||||
"yj",
|
||||
"yk",
|
||||
"yl",
|
||||
"ym",
|
||||
"yn",
|
||||
"yo",
|
||||
"yp",
|
||||
"yr",
|
||||
"ys",
|
||||
"yt",
|
||||
"yu",
|
||||
"yv",
|
||||
"yw",
|
||||
"yx",
|
||||
"yy",
|
||||
"yz",
|
||||
"za",
|
||||
"zb",
|
||||
"zc",
|
||||
"zd",
|
||||
"ze",
|
||||
"zf",
|
||||
"zg",
|
||||
"zh",
|
||||
"zi",
|
||||
"zk",
|
||||
"zl",
|
||||
"zm",
|
||||
"zn",
|
||||
"zo",
|
||||
"zp",
|
||||
"zr",
|
||||
"zs",
|
||||
"zt",
|
||||
"zu",
|
||||
"zw",
|
||||
"zx",
|
||||
"zy",
|
||||
"zz",
|
||||
] as const;
|
||||
export const HL_BIGRAMS: readonly string[] = bigrams;
|
||||
|
||||
export const HL_BIGRAMS_COUNT = HL_BIGRAMS.length;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from "./anchors";
|
||||
export * from "./apply";
|
||||
export * from "./constants";
|
||||
export * from "./diff";
|
||||
export * from "./diff-preview";
|
||||
export * from "./execute";
|
||||
export * from "./input";
|
||||
export * from "./hash";
|
||||
export * from "./parser";
|
||||
export * from "./prefixes";
|
||||
export * from "./recovery";
|
||||
export * from "./stream";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as path from "node:path";
|
||||
import { FILE_HEADER_PREFIX } from "./constants";
|
||||
import { HL_EDIT_SEP } from "./hash";
|
||||
import type { SplitHashlineOptions } from "./types";
|
||||
import { stripTrailingCarriageReturn } from "./utils";
|
||||
|
||||
export interface HashlineInputSection {
|
||||
path: string;
|
||||
diff: string;
|
||||
}
|
||||
|
||||
function unquoteHashlinePath(pathText: string): string {
|
||||
if (pathText.length < 2) return pathText;
|
||||
const first = pathText[0];
|
||||
const last = pathText[pathText.length - 1];
|
||||
if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
|
||||
return pathText;
|
||||
}
|
||||
|
||||
function normalizeHashlinePath(rawPath: string, cwd?: string): string {
|
||||
const unquoted = unquoteHashlinePath(rawPath.trim());
|
||||
if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
|
||||
const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
|
||||
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
return isWithinCwd ? relative || "." : unquoted;
|
||||
}
|
||||
|
||||
function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed === FILE_HEADER_PREFIX) {
|
||||
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
||||
}
|
||||
if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null;
|
||||
const parsedPath = normalizeHashlinePath(trimmed.slice(1), cwd);
|
||||
if (parsedPath.length === 0) {
|
||||
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
||||
}
|
||||
return { path: parsedPath, diff: "" };
|
||||
}
|
||||
|
||||
function stripLeadingBlankLines(input: string): string {
|
||||
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
||||
const lines = stripped.split("\n");
|
||||
while (lines.length > 0 && lines[0].replace(/\r$/, "").trim().length === 0) lines.shift();
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function containsRecognizableHashlineOperations(input: string): boolean {
|
||||
for (const rawLine of input.split("\n")) {
|
||||
const line = stripTrailingCarriageReturn(rawLine);
|
||||
if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string {
|
||||
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
||||
const hasExplicitHeader = stripped
|
||||
.split("\n")
|
||||
.some(rawLine => parseHashlineHeaderLine(stripTrailingCarriageReturn(rawLine), options.cwd) !== null);
|
||||
if (hasExplicitHeader) return input;
|
||||
|
||||
if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
|
||||
const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
|
||||
if (fallbackPath.length === 0) return input;
|
||||
return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`;
|
||||
}
|
||||
|
||||
export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
|
||||
const [section] = splitHashlineInputs(input, options);
|
||||
return section;
|
||||
}
|
||||
|
||||
export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] {
|
||||
const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
|
||||
const lines = stripped.split("\n");
|
||||
const firstLine = stripTrailingCarriageReturn(lines[0] ?? "");
|
||||
|
||||
if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
|
||||
const preview = JSON.stringify(firstLine.slice(0, 120));
|
||||
throw new Error(
|
||||
`input must begin with "@PATH" on the first non-blank line; got: ${preview}. ` +
|
||||
`Example: "@src/foo.ts" then edit ops.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sections: HashlineInputSection[] = [];
|
||||
let currentPath = "";
|
||||
let currentLines: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (currentPath.length === 0) return;
|
||||
sections.push({ path: currentPath, diff: currentLines.join("\n") });
|
||||
currentLines = [];
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = stripTrailingCarriageReturn(rawLine);
|
||||
const header = parseHashlineHeaderLine(line, options.cwd);
|
||||
if (header !== null) {
|
||||
flush();
|
||||
currentPath = header.path;
|
||||
currentLines = [];
|
||||
} else {
|
||||
currentLines.push(rawLine);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return sections;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { RANGE_INTERIOR_HASH } from "./constants";
|
||||
import { describeAnchorExamples, HL_EDIT_SEP, HL_EDIT_SEP_RE_RAW, HL_HASH_CAPTURE_RE_RAW } from "./hash";
|
||||
import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
|
||||
import { stripTrailingCarriageReturn } from "./utils";
|
||||
|
||||
const HL_EDIT_SEPARATOR_RE = HL_EDIT_SEP_RE_RAW;
|
||||
const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
|
||||
|
||||
function parseLid(raw: string, lineNum: number): Anchor {
|
||||
const match = LID_CAPTURE_RE.exec(raw);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`line ${lineNum}: expected a full anchor such as ${describeAnchorExamples("119")}; ` +
|
||||
`got ${JSON.stringify(raw)}.`,
|
||||
);
|
||||
}
|
||||
return { line: Number.parseInt(match[1], 10), hash: match[2] };
|
||||
}
|
||||
|
||||
interface ParsedRange {
|
||||
start: Anchor;
|
||||
end: Anchor;
|
||||
}
|
||||
|
||||
function parseRange(raw: string, lineNum: number): ParsedRange {
|
||||
const [startRaw, endRaw] = raw.split("..");
|
||||
if (!startRaw) throw new Error(`line ${lineNum}: range is missing its first anchor.`);
|
||||
const start = parseLid(startRaw, lineNum);
|
||||
const end = endRaw === undefined ? { ...start } : parseLid(endRaw, lineNum);
|
||||
if (end.line < start.line) {
|
||||
throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`);
|
||||
}
|
||||
if (end.line === start.line && end.hash !== start.hash) {
|
||||
throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} uses two different hashes for the same line.`);
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function expandRange(range: ParsedRange): Anchor[] {
|
||||
const anchors: Anchor[] = [];
|
||||
for (let line = range.start.line; line <= range.end.line; line++) {
|
||||
const hash =
|
||||
line === range.start.line ? range.start.hash : line === range.end.line ? range.end.hash : RANGE_INTERIOR_HASH;
|
||||
anchors.push({ line, hash });
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor {
|
||||
if (raw === "BOF") return { kind: "bof" };
|
||||
if (raw === "EOF") return { kind: "eof" };
|
||||
const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
|
||||
return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
|
||||
}
|
||||
|
||||
const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
|
||||
const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
|
||||
const DELETE_OP_RE = /^-\s*(\S+)$/;
|
||||
const REPLACE_OP_RE = /^=\s*(\S+)$/;
|
||||
const INLINE_BEFORE_OP_RE = new RegExp(`^<\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
|
||||
const INLINE_AFTER_OP_RE = new RegExp(`^\\+\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
|
||||
|
||||
export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
|
||||
if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
|
||||
if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function collectPayload(
|
||||
lines: string[],
|
||||
startIndex: number,
|
||||
opLineNum: number,
|
||||
requirePayload: boolean,
|
||||
): { payload: string[]; nextIndex: number } {
|
||||
const payload: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length) {
|
||||
const line = stripTrailingCarriageReturn(lines[index]);
|
||||
if (!line.startsWith(HL_EDIT_SEP)) break;
|
||||
payload.push(line.slice(1));
|
||||
index++;
|
||||
}
|
||||
if (payload.length === 0 && requirePayload) {
|
||||
throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
|
||||
}
|
||||
return { payload, nextIndex: index };
|
||||
}
|
||||
|
||||
export function parseHashline(diff: string): HashlineEdit[] {
|
||||
return parseHashlineWithWarnings(diff).edits;
|
||||
}
|
||||
|
||||
export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
|
||||
const edits: HashlineEdit[] = [];
|
||||
const warnings: string[] = [];
|
||||
const lines = diff.split("\n");
|
||||
let editIndex = 0;
|
||||
|
||||
const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
|
||||
edits.push({ kind: "insert", cursor: cloneCursor(cursor), text, lineNum, index: editIndex++ });
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const lineNum = i + 1;
|
||||
const line = stripTrailingCarriageReturn(lines[i]);
|
||||
|
||||
if (line.trim().length === 0) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(HL_EDIT_SEP)) {
|
||||
throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
|
||||
}
|
||||
|
||||
const inlineBeforeMatch = INLINE_BEFORE_OP_RE.exec(line);
|
||||
if (inlineBeforeMatch) {
|
||||
const anchor = parseLid(`${inlineBeforeMatch[1]}${inlineBeforeMatch[2]}`, lineNum);
|
||||
edits.push({
|
||||
kind: "modify",
|
||||
anchor,
|
||||
prefix: inlineBeforeMatch[3],
|
||||
suffix: "",
|
||||
lineNum,
|
||||
index: editIndex++,
|
||||
});
|
||||
const cursor: HashlineCursor = { kind: "before_anchor", anchor };
|
||||
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
|
||||
for (const text of payload) pushInsert(cursor, text, lineNum);
|
||||
i = nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inlineAfterMatch = INLINE_AFTER_OP_RE.exec(line);
|
||||
if (inlineAfterMatch) {
|
||||
const anchor = parseLid(`${inlineAfterMatch[1]}${inlineAfterMatch[2]}`, lineNum);
|
||||
edits.push({
|
||||
kind: "modify",
|
||||
anchor,
|
||||
prefix: "",
|
||||
suffix: inlineAfterMatch[3],
|
||||
lineNum,
|
||||
index: editIndex++,
|
||||
});
|
||||
const cursor: HashlineCursor = { kind: "after_anchor", anchor };
|
||||
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
|
||||
for (const text of payload) pushInsert(cursor, text, lineNum);
|
||||
i = nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
|
||||
if (insertBeforeMatch) {
|
||||
const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
|
||||
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
|
||||
for (const text of payload) pushInsert(cursor, text, lineNum);
|
||||
i = nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
|
||||
if (insertAfterMatch) {
|
||||
const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
|
||||
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
|
||||
for (const text of payload) pushInsert(cursor, text, lineNum);
|
||||
i = nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const deleteMatch = DELETE_OP_RE.exec(line);
|
||||
if (deleteMatch) {
|
||||
for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) {
|
||||
edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceMatch = REPLACE_OP_RE.exec(line);
|
||||
if (replaceMatch) {
|
||||
const range = parseRange(replaceMatch[1], lineNum);
|
||||
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
|
||||
// `= A..B` with no payload blanks the range to a single empty line.
|
||||
const replacement = payload.length === 0 ? [""] : payload;
|
||||
for (const text of replacement) {
|
||||
edits.push({
|
||||
kind: "insert",
|
||||
cursor: { kind: "before_anchor", anchor: { ...range.start } },
|
||||
text,
|
||||
lineNum,
|
||||
index: editIndex++,
|
||||
});
|
||||
}
|
||||
for (const anchor of expandRange(range)) {
|
||||
edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
|
||||
}
|
||||
i = nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` +
|
||||
`Got ${JSON.stringify(line)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { edits, warnings };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { HL_BODY_SEP_RE_RAW } from "./hash";
|
||||
|
||||
const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
|
||||
const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
|
||||
const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
|
||||
const DIFF_PLUS_RE = /^[+](?![+])/;
|
||||
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
|
||||
|
||||
function stripLeadingHashlinePrefixes(line: string): string {
|
||||
let result = line;
|
||||
let previous: string;
|
||||
do {
|
||||
previous = result;
|
||||
result = result.replace(HL_PREFIX_RE, "");
|
||||
} while (result !== previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 5. Read-output prefix stripping
|
||||
//
|
||||
// When a model echoes back content from a `read` or `search` response, every
|
||||
// line is prefixed with either a hashline tag (`123ab|`) or, for diff-style
|
||||
// echoes, a leading `+`. These helpers detect that and recover the raw text.
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type LinePrefixStats = {
|
||||
nonEmpty: number;
|
||||
hashPrefixCount: number;
|
||||
diffPlusHashPrefixCount: number;
|
||||
diffPlusCount: number;
|
||||
truncationNoticeCount: number;
|
||||
};
|
||||
|
||||
function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
||||
const stats: LinePrefixStats = {
|
||||
nonEmpty: 0,
|
||||
hashPrefixCount: 0,
|
||||
diffPlusHashPrefixCount: 0,
|
||||
diffPlusCount: 0,
|
||||
truncationNoticeCount: 0,
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue;
|
||||
if (READ_TRUNCATION_NOTICE_RE.test(line)) {
|
||||
stats.truncationNoticeCount++;
|
||||
continue;
|
||||
}
|
||||
stats.nonEmpty++;
|
||||
if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++;
|
||||
if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
|
||||
if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function stripNewLinePrefixes(lines: string[]): string[] {
|
||||
const stats = collectLinePrefixStats(lines);
|
||||
if (stats.nonEmpty === 0) return lines;
|
||||
|
||||
const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty;
|
||||
const stripPlus =
|
||||
!stripHash &&
|
||||
stats.diffPlusHashPrefixCount === 0 &&
|
||||
stats.diffPlusCount > 0 &&
|
||||
stats.diffPlusCount >= stats.nonEmpty * 0.5;
|
||||
|
||||
if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
|
||||
|
||||
return lines
|
||||
.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
|
||||
.map(line => {
|
||||
if (stripHash) return stripLeadingHashlinePrefixes(line);
|
||||
if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
|
||||
if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) {
|
||||
return line.replace(HL_PREFIX_RE, "");
|
||||
}
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
export function stripHashlinePrefixes(lines: string[]): string[] {
|
||||
const stats = collectLinePrefixStats(lines);
|
||||
if (stats.nonEmpty === 0) return lines;
|
||||
if (stats.hashPrefixCount !== stats.nonEmpty) return lines;
|
||||
return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line payloads by stripping read/search line prefixes. `null` /
|
||||
* `undefined` yield `[]`; a single multiline string is split on `\n`.
|
||||
*/
|
||||
export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
|
||||
if (edit == null) return [];
|
||||
if (typeof edit === "string") {
|
||||
const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
|
||||
edit = trimmed.replaceAll("\r", "").split("\n");
|
||||
}
|
||||
return stripNewLinePrefixes(edit);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as Diff from "diff";
|
||||
import { generateDiffString } from "../edit/diff";
|
||||
import type { FileReadCache } from "../edit/file-read-cache";
|
||||
import { HashlineMismatchError } from "./anchors";
|
||||
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
|
||||
import type { HashlineApplyOptions, HashlineEdit } from "./types";
|
||||
|
||||
export interface HashlineRecoveryArgs {
|
||||
cache: FileReadCache;
|
||||
absolutePath: string;
|
||||
currentText: string;
|
||||
edits: HashlineEdit[];
|
||||
options: HashlineApplyOptions;
|
||||
}
|
||||
|
||||
export interface HashlineRecoveryResult {
|
||||
lines: string;
|
||||
firstChangedLine: number | undefined;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const HASHLINE_RECOVERY_FUZZ_FACTOR = 3;
|
||||
|
||||
const HASHLINE_RECOVERY_WARNING =
|
||||
"Recovered from stale anchors using a previous read snapshot (file changed externally between read and edit).";
|
||||
|
||||
/**
|
||||
* Attempt to recover from a `HashlineMismatchError` by replaying the edits
|
||||
* against a cached pre-edit snapshot of the file and 3-way-merging the result
|
||||
* onto the current on-disk content. Returns `null` when no recovery is
|
||||
* possible — callers should propagate the original mismatch error in that
|
||||
* case.
|
||||
*/
|
||||
export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null {
|
||||
const { cache, absolutePath, currentText, edits, options } = args;
|
||||
const snapshot = cache.get(absolutePath);
|
||||
if (!snapshot || snapshot.lines.size === 0) return null;
|
||||
|
||||
const overlaid = currentText.split("\n");
|
||||
let maxCachedLine = 0;
|
||||
for (const lineNum of snapshot.lines.keys()) {
|
||||
if (lineNum > maxCachedLine) maxCachedLine = lineNum;
|
||||
}
|
||||
while (overlaid.length < maxCachedLine) overlaid.push("");
|
||||
for (const [lineNum, content] of snapshot.lines) {
|
||||
overlaid[lineNum - 1] = content;
|
||||
}
|
||||
const previousText = overlaid.join("\n");
|
||||
if (previousText === currentText) return null;
|
||||
|
||||
let applied: HashlineApplyResult;
|
||||
try {
|
||||
applied = applyHashlineEdits(previousText, edits, options);
|
||||
} catch (err) {
|
||||
if (err instanceof HashlineMismatchError) return null;
|
||||
throw err;
|
||||
}
|
||||
if (applied.lines === previousText) return null;
|
||||
|
||||
const patch = Diff.structuredPatch("file", "file", previousText, applied.lines, "", "", { context: 3 });
|
||||
const merged = Diff.applyPatch(currentText, patch, { fuzzFactor: HASHLINE_RECOVERY_FUZZ_FACTOR });
|
||||
if (typeof merged !== "string" || merged === currentText) return null;
|
||||
|
||||
const mergedDiff = generateDiffString(currentText, merged);
|
||||
const recoveryWarnings = [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])];
|
||||
|
||||
return {
|
||||
lines: merged,
|
||||
firstChangedLine: mergedDiff.firstChangedLine ?? applied.firstChangedLine,
|
||||
warnings: recoveryWarnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { formatHashLine } from "./hash";
|
||||
import type { HashlineStreamOptions } from "./types";
|
||||
|
||||
interface ResolvedHashlineStreamOptions {
|
||||
startLine: number;
|
||||
maxChunkLines: number;
|
||||
maxChunkBytes: number;
|
||||
}
|
||||
|
||||
function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions {
|
||||
return {
|
||||
startLine: options.startLine ?? 1,
|
||||
maxChunkLines: options.maxChunkLines ?? 200,
|
||||
maxChunkBytes: options.maxChunkBytes ?? 64 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
interface HashlineChunkEmitter {
|
||||
pushLine: (line: string) => string[];
|
||||
flush: () => string | undefined;
|
||||
}
|
||||
|
||||
function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): HashlineChunkEmitter {
|
||||
let lineNumber = options.startLine;
|
||||
let outLines: string[] = [];
|
||||
let outBytes = 0;
|
||||
|
||||
const flush = (): string | undefined => {
|
||||
if (outLines.length === 0) return undefined;
|
||||
const chunk = outLines.join("\n");
|
||||
outLines = [];
|
||||
outBytes = 0;
|
||||
return chunk;
|
||||
};
|
||||
|
||||
const pushLine = (line: string): string[] => {
|
||||
const formatted = formatHashLine(lineNumber, line);
|
||||
lineNumber++;
|
||||
|
||||
const chunks: string[] = [];
|
||||
const sepBytes = outLines.length === 0 ? 0 : 1;
|
||||
const lineBytes = Buffer.byteLength(formatted, "utf-8");
|
||||
const wouldOverflow =
|
||||
outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes;
|
||||
|
||||
if (outLines.length > 0 && wouldOverflow) {
|
||||
const flushed = flush();
|
||||
if (flushed) chunks.push(flushed);
|
||||
}
|
||||
|
||||
outLines.push(formatted);
|
||||
outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
|
||||
|
||||
if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) {
|
||||
const flushed = flush();
|
||||
if (flushed) chunks.push(flushed);
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
return { pushLine, flush };
|
||||
}
|
||||
|
||||
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"getReader" in value &&
|
||||
typeof (value as { getReader?: unknown }).getReader === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
if (value) yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamHashLinesFromUtf8(
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
options: HashlineStreamOptions = {},
|
||||
): AsyncGenerator<string> {
|
||||
const resolved = resolveHashlineStreamOptions(options);
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source;
|
||||
const emitter = createHashlineChunkEmitter(resolved);
|
||||
|
||||
let pending = "";
|
||||
let sawAnyLine = false;
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
pending += decoder.decode(chunk, { stream: true });
|
||||
let nl = pending.indexOf("\n");
|
||||
while (nl !== -1) {
|
||||
const raw = pending.slice(0, nl);
|
||||
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
||||
sawAnyLine = true;
|
||||
for (const out of emitter.pushLine(line)) yield out;
|
||||
pending = pending.slice(nl + 1);
|
||||
nl = pending.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
pending += decoder.decode();
|
||||
if (pending.length > 0) {
|
||||
sawAnyLine = true;
|
||||
const tail = pending.endsWith("\r") ? pending.slice(0, -1) : pending;
|
||||
for (const out of emitter.pushLine(tail)) yield out;
|
||||
}
|
||||
if (!sawAnyLine) {
|
||||
for (const out of emitter.pushLine("")) yield out;
|
||||
}
|
||||
|
||||
const last = emitter.flush();
|
||||
if (last) yield last;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Static } from "@sinclair/typebox";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { LspBatchRequest } from "../edit/renderer";
|
||||
import type { WritethroughCallback, WritethroughDeferredHandle } from "../lsp";
|
||||
import type { ToolSession } from "../tools";
|
||||
|
||||
export interface HashMismatch {
|
||||
line: number;
|
||||
expected: string;
|
||||
actual: string;
|
||||
}
|
||||
|
||||
export type Anchor = {
|
||||
line: number;
|
||||
hash: string;
|
||||
contentHint?: string;
|
||||
};
|
||||
|
||||
export type HashlineCursor =
|
||||
| { kind: "bof" }
|
||||
| { kind: "eof" }
|
||||
| { kind: "before_anchor"; anchor: Anchor }
|
||||
| { kind: "after_anchor"; anchor: Anchor };
|
||||
|
||||
export type HashlineEdit =
|
||||
| { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
|
||||
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
||||
| { kind: "modify"; anchor: Anchor; prefix: string; suffix: string; lineNum: number; index: number };
|
||||
|
||||
export const hashlineEditParamsSchema = Type.Object({ input: Type.String() });
|
||||
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
||||
|
||||
export interface HashlineStreamOptions {
|
||||
/** First line number to use when formatting (1-indexed). */
|
||||
startLine?: number;
|
||||
/** Maximum formatted lines per yielded chunk (default: 200). */
|
||||
maxChunkLines?: number;
|
||||
/** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */
|
||||
maxChunkBytes?: number;
|
||||
}
|
||||
|
||||
export interface CompactHashlineDiffPreview {
|
||||
preview: string;
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
}
|
||||
|
||||
export interface CompactHashlineDiffOptions {
|
||||
/** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
|
||||
maxUnchangedRun?: number;
|
||||
}
|
||||
export interface HashlineApplyOptions {
|
||||
autoDropPureInsertDuplicates?: boolean;
|
||||
}
|
||||
|
||||
export interface SplitHashlineOptions {
|
||||
cwd?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface ExecuteHashlineSingleOptions {
|
||||
session: ToolSession;
|
||||
input: string;
|
||||
path?: string;
|
||||
signal?: AbortSignal;
|
||||
batchRequest?: LspBatchRequest;
|
||||
writethrough: WritethroughCallback;
|
||||
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function stripTrailingCarriageReturn(line: string): string {
|
||||
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
||||
}
|
||||
@@ -16,7 +16,6 @@ export type * from "./config/prompt-templates";
|
||||
export * from "./config/prompt-templates";
|
||||
export type { RetrySettings, SkillsSettings } from "./config/settings";
|
||||
export { Settings, settings } from "./config/settings";
|
||||
export * from "./edit/modes/hashline";
|
||||
// Custom commands
|
||||
export type * from "./extensibility/custom-commands/types";
|
||||
export type * from "./extensibility/custom-tools";
|
||||
@@ -30,6 +29,7 @@ export * from "./extensibility/extensions";
|
||||
export * from "./extensibility/skills";
|
||||
// Slash commands
|
||||
export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./extensibility/slash-commands";
|
||||
export * from "./hashline";
|
||||
export type * from "./lsp";
|
||||
// Main entry point
|
||||
export * from "./main";
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { computeLineHash, HL_BODY_SEP } from "../edit/line-hash";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
|
||||
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computeLineHash } from "../edit/line-hash";
|
||||
import { computeLineHash } from "../hashline/hash";
|
||||
|
||||
/**
|
||||
* Format a single line of match output for grep/ast-grep style results.
|
||||
|
||||
@@ -9,9 +9,9 @@ import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { getFileReadCache } from "../edit/file-read-cache";
|
||||
import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../edit/line-hash";
|
||||
import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
|
||||
import { parseInternalUrl } from "../internal-urls/parse";
|
||||
import type { InternalUrl } from "../internal-urls/types";
|
||||
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { glob } from "@oh-my-pi/pi-natives";
|
||||
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
|
||||
import { formatHashLines } from "../edit/line-hash";
|
||||
import { formatHashLines } from "../hashline/hash";
|
||||
import type { FileMentionMessage } from "../session/messages";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DEFAULT_FUZZY_THRESHOLD,
|
||||
findMatch,
|
||||
} from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import { HL_EDIT_SEP } from "@oh-my-pi/pi-coding-agent/edit/line-hash";
|
||||
import { HL_EDIT_SEP } from "@oh-my-pi/pi-coding-agent/hashline/hash";
|
||||
|
||||
describe("findMatch", () => {
|
||||
describe("exact matching", () => {
|
||||
|
||||
Reference in New Issue
Block a user