@oh-my-pi/pi-coding-agent 14.5.13 → 14.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/package.json +7 -7
- package/src/autoresearch/command-resume.md +5 -8
- package/src/autoresearch/git.ts +41 -51
- package/src/autoresearch/helpers.ts +43 -359
- package/src/autoresearch/index.ts +281 -273
- package/src/autoresearch/prompt-setup.md +43 -0
- package/src/autoresearch/prompt.md +52 -193
- package/src/autoresearch/resume-message.md +2 -8
- package/src/autoresearch/state.ts +59 -166
- package/src/autoresearch/storage.ts +687 -0
- package/src/autoresearch/tools/init-experiment.ts +201 -290
- package/src/autoresearch/tools/log-experiment.ts +304 -517
- package/src/autoresearch/tools/run-experiment.ts +117 -296
- package/src/autoresearch/tools/update-notes.ts +116 -0
- package/src/autoresearch/types.ts +16 -66
- package/src/commit/pipeline.ts +4 -3
- package/src/config/settings-schema.ts +1 -1
- package/src/config/settings.ts +20 -1
- package/src/config.ts +9 -6
- package/src/cursor.ts +1 -1
- package/src/edit/index.ts +9 -31
- package/src/edit/line-hash.ts +70 -43
- package/src/edit/modes/hashline.lark +26 -0
- package/src/edit/modes/hashline.ts +898 -1099
- package/src/edit/modes/patch.ts +0 -7
- package/src/edit/modes/replace.ts +0 -4
- package/src/edit/renderer.ts +22 -20
- package/src/edit/streaming.ts +8 -28
- package/src/eval/eval.lark +24 -30
- package/src/eval/js/context-manager.ts +5 -162
- package/src/eval/js/prelude.txt +0 -12
- package/src/eval/parse.ts +129 -129
- package/src/eval/py/kernel.ts +4 -4
- package/src/eval/py/prelude.py +1 -219
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +2 -2
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +10 -0
- package/src/mcp/manager.ts +22 -0
- package/src/modes/components/session-observer-overlay.ts +5 -2
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/status-line.ts +3 -5
- package/src/modes/components/tree-selector.ts +4 -5
- package/src/modes/components/welcome.ts +11 -1
- package/src/modes/controllers/command-controller.ts +2 -6
- package/src/modes/controllers/event-controller.ts +1 -2
- package/src/modes/controllers/extension-ui-controller.ts +3 -15
- package/src/modes/controllers/input-controller.ts +0 -1
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +5 -7
- package/src/modes/rpc/rpc-client.ts +9 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/rpc/rpc-types.ts +9 -0
- package/src/prompts/system/system-prompt.md +14 -38
- package/src/prompts/tools/ast-edit.md +8 -8
- package/src/prompts/tools/ast-grep.md +10 -10
- package/src/prompts/tools/eval.md +13 -31
- package/src/prompts/tools/find.md +2 -1
- package/src/prompts/tools/hashline.md +66 -57
- package/src/prompts/tools/search.md +2 -2
- package/src/sdk.ts +19 -4
- package/src/session/agent-session.ts +110 -4
- package/src/session/session-manager.ts +17 -13
- package/src/task/agents.ts +4 -5
- package/src/tools/archive-reader.ts +9 -3
- package/src/tools/ast-edit.ts +141 -44
- package/src/tools/ast-grep.ts +112 -36
- package/src/tools/browser/readable.ts +11 -6
- package/src/tools/browser/tab-supervisor.ts +2 -2
- package/src/tools/browser.ts +5 -3
- package/src/tools/eval.ts +2 -53
- package/src/tools/find.ts +16 -15
- package/src/tools/image-gen.ts +2 -2
- package/src/tools/path-utils.ts +36 -196
- package/src/tools/search.ts +56 -35
- package/src/tools/write.ts +8 -1
- package/src/utils/edit-mode.ts +2 -11
- package/src/utils/file-display-mode.ts +1 -1
- package/src/utils/git.ts +17 -0
- package/src/utils/session-color.ts +0 -12
- package/src/utils/title-generator.ts +22 -38
- package/src/web/scrapers/crossref.ts +3 -3
- package/src/web/scrapers/devto.ts +1 -1
- package/src/web/scrapers/discourse.ts +5 -5
- package/src/web/scrapers/firefox-addons.ts +1 -1
- package/src/web/scrapers/flathub.ts +2 -2
- package/src/web/scrapers/gitlab.ts +1 -1
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
- package/src/web/scrapers/mastodon.ts +9 -9
- package/src/web/scrapers/mdn.ts +11 -7
- package/src/web/scrapers/pub-dev.ts +1 -1
- package/src/web/scrapers/rawg.ts +3 -3
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/spdx.ts +1 -1
- package/src/web/scrapers/stackoverflow.ts +2 -2
- package/src/web/scrapers/types.ts +53 -39
- package/src/web/scrapers/w3c.ts +1 -1
- package/src/web/search/providers/gemini.ts +2 -2
- package/src/autoresearch/apply-contract-to-state.ts +0 -24
- package/src/autoresearch/contract.ts +0 -288
- package/src/edit/modes/atom.lark +0 -29
- package/src/edit/modes/atom.ts +0 -1773
- package/src/prompts/tools/atom.md +0 -150
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hashline edit mode
|
|
2
|
+
* Hashline edit mode.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* The combined `LINE+ID` reference acts as both an address and a staleness check:
|
|
8
|
-
* if the file has changed since the caller last read it, hash mismatches are caught
|
|
9
|
-
* before any mutation occurs.
|
|
4
|
+
* A compact, line-anchored wire format for file edits. Each section starts
|
|
5
|
+
* with `@PATH`. Edit ops are explicit blocks (`+ ANCHOR`, `- A..B`, `= A..B`)
|
|
6
|
+
* with payload lines prefixed by `|`.
|
|
10
7
|
*
|
|
11
|
-
*
|
|
12
|
-
* Reference format: `"LINE+ID"` (e.g. `"1ab"`)
|
|
8
|
+
* The module is organized into the following sections:
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* 1. Imports
|
|
11
|
+
* 2. Public types & schemas
|
|
12
|
+
* 3. Constants & shared regexes
|
|
13
|
+
* 4. Small string utilities
|
|
14
|
+
* 5. Read-output prefix stripping (stripNewLinePrefixes, hashlineParseText)
|
|
15
|
+
* 6. Hashline streaming (streamHashLinesFromUtf8)
|
|
16
|
+
* 7. Anchor parsing & validation (parseTag, parseLid, parseRange, ...)
|
|
17
|
+
* 8. Mismatch error & rebase (HashlineMismatchError, tryRebaseAnchor)
|
|
18
|
+
* 9. Compact diff preview (buildCompactHashlineDiffPreview)
|
|
19
|
+
* 10. Edit DSL parsing (parseHashline, parseHashlineWithWarnings)
|
|
20
|
+
* 11. Edit application (applyHashlineEdits)
|
|
21
|
+
* 12. Input splitting (splitHashlineInput, splitHashlineInputs)
|
|
22
|
+
* 13. Diff computation (computeHashlineDiff)
|
|
23
|
+
* 14. Execution (executeHashlineSingle)
|
|
16
24
|
*/
|
|
17
25
|
|
|
26
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// 1. Imports
|
|
28
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
import * as path from "node:path";
|
|
18
31
|
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
19
32
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
20
33
|
import { type Static, Type } from "@sinclair/typebox";
|
|
21
|
-
import type { BunFile } from "bun";
|
|
22
34
|
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
23
35
|
import type { ToolSession } from "../../tools";
|
|
24
36
|
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
@@ -28,39 +40,135 @@ import { resolveToCwd } from "../../tools/path-utils";
|
|
|
28
40
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
29
41
|
import { formatCodeFrameLine } from "../../tools/render-utils";
|
|
30
42
|
import { generateDiffString } from "../diff";
|
|
31
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
computeLineHash,
|
|
45
|
+
describeAnchorExamples,
|
|
46
|
+
formatHashLine,
|
|
47
|
+
HASHLINE_ANCHOR_RE_SRC,
|
|
48
|
+
HASHLINE_CONTENT_SEPARATOR,
|
|
49
|
+
HASHLINE_LID_CAPTURE_RE_SRC,
|
|
50
|
+
} from "../line-hash";
|
|
32
51
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
33
52
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
34
53
|
|
|
54
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// 2. Public types & schemas
|
|
56
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
35
58
|
export interface HashMismatch {
|
|
36
59
|
line: number;
|
|
37
60
|
expected: string;
|
|
38
61
|
actual: string;
|
|
39
62
|
}
|
|
40
63
|
|
|
41
|
-
export type Anchor = {
|
|
64
|
+
export type Anchor = {
|
|
65
|
+
line: number;
|
|
66
|
+
hash: string;
|
|
67
|
+
contentHint?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type HashlineCursor =
|
|
71
|
+
| { kind: "bof" }
|
|
72
|
+
| { kind: "eof" }
|
|
73
|
+
| { kind: "before_anchor"; anchor: Anchor }
|
|
74
|
+
| { kind: "after_anchor"; anchor: Anchor };
|
|
75
|
+
|
|
42
76
|
export type HashlineEdit =
|
|
43
|
-
| {
|
|
44
|
-
| {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
77
|
+
| { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
|
|
78
|
+
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
|
|
79
|
+
|
|
80
|
+
export const hashlineEditParamsSchema = Type.Object({ input: Type.String() });
|
|
81
|
+
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
|
82
|
+
|
|
83
|
+
export interface HashlineStreamOptions {
|
|
84
|
+
/** First line number to use when formatting (1-indexed). */
|
|
85
|
+
startLine?: number;
|
|
86
|
+
/** Maximum formatted lines per yielded chunk (default: 200). */
|
|
87
|
+
maxChunkLines?: number;
|
|
88
|
+
/** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */
|
|
89
|
+
maxChunkBytes?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CompactHashlineDiffPreview {
|
|
93
|
+
preview: string;
|
|
94
|
+
addedLines: number;
|
|
95
|
+
removedLines: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CompactHashlineDiffOptions {
|
|
99
|
+
/** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
|
|
100
|
+
maxUnchangedRun?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SplitHashlineOptions {
|
|
104
|
+
cwd?: string;
|
|
105
|
+
path?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ExecuteHashlineSingleOptions {
|
|
109
|
+
session: ToolSession;
|
|
110
|
+
input: string;
|
|
111
|
+
path?: string;
|
|
112
|
+
signal?: AbortSignal;
|
|
113
|
+
batchRequest?: LspBatchRequest;
|
|
114
|
+
writethrough: WritethroughCallback;
|
|
115
|
+
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// 3. Constants & shared regexes
|
|
120
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/** How far either side of an anchor we'll search when auto-rebasing on hash match. */
|
|
123
|
+
export const ANCHOR_REBASE_WINDOW = 5;
|
|
124
|
+
|
|
125
|
+
/** Lines of context shown either side of a hash mismatch. */
|
|
126
|
+
const MISMATCH_CONTEXT = 2;
|
|
127
|
+
|
|
128
|
+
/** Filler hash used for the interior of a multi-line range; not validated. */
|
|
129
|
+
const RANGE_INTERIOR_HASH = "**";
|
|
130
|
+
|
|
131
|
+
/** Header marker introducing a new file section in multi-section input. */
|
|
132
|
+
const FILE_HEADER_PREFIX = "@";
|
|
133
|
+
|
|
54
134
|
const HASHLINE_CONTENT_SEPARATOR_RE = "[:|]";
|
|
55
|
-
const HASHLINE_PREFIX_RE = new RegExp(
|
|
56
|
-
|
|
57
|
-
);
|
|
58
|
-
const HASHLINE_PREFIX_PLUS_RE = new RegExp(
|
|
59
|
-
`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
|
|
60
|
-
);
|
|
135
|
+
const HASHLINE_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HASHLINE_CONTENT_SEPARATOR_RE}`);
|
|
136
|
+
const HASHLINE_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HASHLINE_CONTENT_SEPARATOR_RE}`);
|
|
61
137
|
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
62
138
|
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L?\d+/;
|
|
63
139
|
|
|
140
|
+
const HASHLINE_HASH_HINT_RE = /^[a-z]{2}$/i;
|
|
141
|
+
const HASHLINE_ANCHOR_EXAMPLES = describeAnchorExamples("160");
|
|
142
|
+
|
|
143
|
+
const PARSE_TAG_RE = new RegExp(`^${HASHLINE_ANCHOR_RE_SRC}`);
|
|
144
|
+
const LID_CAPTURE_RE = new RegExp(`^${HASHLINE_LID_CAPTURE_RE_SRC}$`);
|
|
145
|
+
|
|
146
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
147
|
+
// 4. Small string utilities
|
|
148
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function stripTrailingCarriageReturn(line: string): string {
|
|
151
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function stripLeadingHashlinePrefixes(line: string): string {
|
|
155
|
+
let result = line;
|
|
156
|
+
let previous: string;
|
|
157
|
+
do {
|
|
158
|
+
previous = result;
|
|
159
|
+
result = result.replace(HASHLINE_PREFIX_RE, "");
|
|
160
|
+
} while (result !== previous);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
165
|
+
// 5. Read-output prefix stripping
|
|
166
|
+
//
|
|
167
|
+
// When a model echoes back content from a `read` or `search` response, every
|
|
168
|
+
// line is prefixed with either a hashline tag (`123ab|`) or, for diff-style
|
|
169
|
+
// echoes, a leading `+`. These helpers detect that and recover the raw text.
|
|
170
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
64
172
|
type LinePrefixStats = {
|
|
65
173
|
nonEmpty: number;
|
|
66
174
|
hashPrefixCount: number;
|
|
@@ -89,222 +197,60 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
|
|
89
197
|
if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
|
|
90
198
|
if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
|
|
91
199
|
}
|
|
92
|
-
|
|
93
200
|
return stats;
|
|
94
201
|
}
|
|
95
202
|
|
|
96
|
-
function stripLeadingHashlinePrefixes(line: string): string {
|
|
97
|
-
let result = line;
|
|
98
|
-
let prev: string;
|
|
99
|
-
do {
|
|
100
|
-
prev = result;
|
|
101
|
-
result = result.replace(HASHLINE_PREFIX_RE, "");
|
|
102
|
-
} while (result !== prev);
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function _filterTruncationNotices(lines: string[]): string[] {
|
|
107
|
-
return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
203
|
export function stripNewLinePrefixes(lines: string[]): string[] {
|
|
111
|
-
const
|
|
112
|
-
if (nonEmpty === 0) return lines;
|
|
204
|
+
const stats = collectLinePrefixStats(lines);
|
|
205
|
+
if (stats.nonEmpty === 0) return lines;
|
|
113
206
|
|
|
114
|
-
const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty;
|
|
207
|
+
const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty;
|
|
115
208
|
const stripPlus =
|
|
116
|
-
!stripHash &&
|
|
117
|
-
|
|
209
|
+
!stripHash &&
|
|
210
|
+
stats.diffPlusHashPrefixCount === 0 &&
|
|
211
|
+
stats.diffPlusCount > 0 &&
|
|
212
|
+
stats.diffPlusCount >= stats.nonEmpty * 0.5;
|
|
213
|
+
|
|
214
|
+
if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
|
|
118
215
|
|
|
119
|
-
|
|
216
|
+
return lines
|
|
120
217
|
.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
|
|
121
218
|
.map(line => {
|
|
122
219
|
if (stripHash) return stripLeadingHashlinePrefixes(line);
|
|
123
220
|
if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
|
|
124
|
-
if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
|
|
221
|
+
if (stats.diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
|
|
125
222
|
return line.replace(HASHLINE_PREFIX_RE, "");
|
|
126
223
|
}
|
|
127
224
|
return line;
|
|
128
225
|
});
|
|
129
|
-
return mapped;
|
|
130
226
|
}
|
|
131
227
|
|
|
132
228
|
export function stripHashlinePrefixes(lines: string[]): string[] {
|
|
133
|
-
const
|
|
134
|
-
if (nonEmpty === 0) return lines;
|
|
135
|
-
if (hashPrefixCount !== nonEmpty) return lines;
|
|
229
|
+
const stats = collectLinePrefixStats(lines);
|
|
230
|
+
if (stats.nonEmpty === 0) return lines;
|
|
231
|
+
if (stats.hashPrefixCount !== stats.nonEmpty) return lines;
|
|
136
232
|
return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
|
|
137
233
|
}
|
|
138
234
|
|
|
139
|
-
const linesSchema = Type.Union([Type.Array(Type.String()), Type.Null()]);
|
|
140
|
-
|
|
141
|
-
const locSchema = Type.Union(
|
|
142
|
-
[
|
|
143
|
-
Type.Literal("append"),
|
|
144
|
-
Type.Literal("prepend"),
|
|
145
|
-
Type.Object({ append: Type.String({ description: "anchor" }) }),
|
|
146
|
-
Type.Object({ prepend: Type.String({ description: "anchor" }) }),
|
|
147
|
-
Type.Object({
|
|
148
|
-
range: Type.Object({
|
|
149
|
-
pos: Type.String({ description: "first line to edit (inclusive)" }),
|
|
150
|
-
end: Type.String({ description: "last line to edit (inclusive)" }),
|
|
151
|
-
}),
|
|
152
|
-
}),
|
|
153
|
-
],
|
|
154
|
-
{ description: "insert location" },
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
export const hashlineEditSchema = Type.Object(
|
|
158
|
-
{
|
|
159
|
-
loc: Type.Optional(locSchema),
|
|
160
|
-
content: Type.Optional(linesSchema),
|
|
161
|
-
},
|
|
162
|
-
{ additionalProperties: false },
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
export const hashlineEditParamsSchema = Type.Object(
|
|
166
|
-
{
|
|
167
|
-
path: Type.String({ description: "file path for edits" }),
|
|
168
|
-
edits: Type.Array(hashlineEditSchema, { description: "edits" }),
|
|
169
|
-
},
|
|
170
|
-
{ additionalProperties: false },
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
export type HashlineToolEdit = Static<typeof hashlineEditSchema>;
|
|
174
|
-
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
|
175
|
-
|
|
176
|
-
export interface ExecuteHashlineSingleOptions {
|
|
177
|
-
session: ToolSession;
|
|
178
|
-
path: string;
|
|
179
|
-
edits: HashlineToolEdit[];
|
|
180
|
-
signal?: AbortSignal;
|
|
181
|
-
batchRequest?: LspBatchRequest;
|
|
182
|
-
writethrough: WritethroughCallback;
|
|
183
|
-
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
235
|
/**
|
|
187
|
-
* Normalize line payloads
|
|
188
|
-
*
|
|
189
|
-
* A single multiline `string` is still split on `\n` for the same normalization path.
|
|
236
|
+
* Normalize line payloads by stripping read/search line prefixes. `null` /
|
|
237
|
+
* `undefined` yield `[]`; a single multiline string is split on `\n`.
|
|
190
238
|
*/
|
|
191
239
|
export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
|
|
192
240
|
if (edit == null) return [];
|
|
193
241
|
if (typeof edit === "string") {
|
|
194
|
-
const
|
|
195
|
-
edit =
|
|
242
|
+
const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
|
|
243
|
+
edit = trimmed.replaceAll("\r", "").split("\n");
|
|
196
244
|
}
|
|
197
245
|
return stripNewLinePrefixes(edit);
|
|
198
246
|
}
|
|
199
247
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[] {
|
|
207
|
-
return edits.map((edit, editIndex) => {
|
|
208
|
-
if (!edit || typeof edit !== "object") {
|
|
209
|
-
throw new Error(`Invalid hashline edit at index ${editIndex}: expected object.`);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if ("op" in edit) {
|
|
213
|
-
return edit;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if ("loc" in edit) {
|
|
217
|
-
return resolveEditAnchor(edit);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
throw new Error(`Invalid hashline edit at index ${editIndex}: expected op/loc payload.`);
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export function formatFullAnchorRequirement(raw?: string): string {
|
|
225
|
-
const suffix = typeof raw === "string" ? raw.trim() : "";
|
|
226
|
-
const hashOnlyHint = /^[A-Za-z]{2}$/.test(suffix)
|
|
227
|
-
? ` It looks like you supplied only the 2-letter suffix (${JSON.stringify(suffix)}). Copy the full anchor exactly as shown (for example, "160${suffix}").`
|
|
228
|
-
: "";
|
|
229
|
-
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
|
|
230
|
-
return `the full anchor exactly as shown by read/grep (line number + 2-letter suffix, for example "160sr")${received}${hashOnlyHint}`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function tryParseTag(raw: string): Anchor | undefined {
|
|
234
|
-
try {
|
|
235
|
-
return parseTag(raw);
|
|
236
|
-
} catch {
|
|
237
|
-
return undefined;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor {
|
|
242
|
-
const anchor = tryParseTag(raw);
|
|
243
|
-
if (!anchor) throw new Error(`${op} requires ${formatFullAnchorRequirement(raw)}.`);
|
|
244
|
-
return anchor;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } {
|
|
248
|
-
const pos = tryParseTag(range.pos);
|
|
249
|
-
const end = tryParseTag(range.end);
|
|
250
|
-
if (!pos || !end) {
|
|
251
|
-
const invalid = [
|
|
252
|
-
!pos ? `pos=${JSON.stringify(range.pos)}` : null,
|
|
253
|
-
!end ? `end=${JSON.stringify(range.end)}` : null,
|
|
254
|
-
]
|
|
255
|
-
.filter(Boolean)
|
|
256
|
-
.join(", ");
|
|
257
|
-
throw new Error(
|
|
258
|
-
`range requires valid pos and end anchors. Use ${formatFullAnchorRequirement()}. Invalid: ${invalid}.`,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
return { pos, end };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function resolveEditAnchor(edit: HashlineToolEdit): HashlineEdit {
|
|
265
|
-
const lines = hashlineParseText(edit.content);
|
|
266
|
-
const loc = edit.loc;
|
|
267
|
-
|
|
268
|
-
if (loc === "append") {
|
|
269
|
-
return { op: "append_file", lines };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (loc === "prepend") {
|
|
273
|
-
return { op: "prepend_file", lines };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (typeof loc !== "object") {
|
|
277
|
-
throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if ("append" in loc) {
|
|
281
|
-
return { op: "append_at", pos: requireParsedAnchor(loc.append, "append"), lines };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if ("prepend" in loc) {
|
|
285
|
-
return { op: "prepend_at", pos: requireParsedAnchor(loc.prepend, "prepend"), lines };
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if ("range" in loc) {
|
|
289
|
-
const { pos, end } = requireParsedRange(loc.range);
|
|
290
|
-
return { op: "replace_range", pos, end, lines };
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
throw new Error("Unknown loc shape. Expected append, prepend, or range.");
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
297
|
-
// Hashline streaming formatter
|
|
298
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
299
|
-
|
|
300
|
-
export interface HashlineStreamOptions {
|
|
301
|
-
/** First line number to use when formatting (1-indexed). */
|
|
302
|
-
startLine?: number;
|
|
303
|
-
/** Maximum formatted lines per yielded chunk (default: 200). */
|
|
304
|
-
maxChunkLines?: number;
|
|
305
|
-
/** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */
|
|
306
|
-
maxChunkBytes?: number;
|
|
307
|
-
}
|
|
248
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
249
|
+
// 6. Hashline streaming
|
|
250
|
+
//
|
|
251
|
+
// Convert a UTF-8 byte stream into a sequence of formatted hashline chunks,
|
|
252
|
+
// each capped by line count and byte size.
|
|
253
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
308
254
|
|
|
309
255
|
interface ResolvedHashlineStreamOptions {
|
|
310
256
|
startLine: number;
|
|
@@ -312,11 +258,6 @@ interface ResolvedHashlineStreamOptions {
|
|
|
312
258
|
maxChunkBytes: number;
|
|
313
259
|
}
|
|
314
260
|
|
|
315
|
-
interface HashlineChunkEmitter {
|
|
316
|
-
pushLine: (line: string) => string[];
|
|
317
|
-
flush: () => string | undefined;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
261
|
function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions {
|
|
321
262
|
return {
|
|
322
263
|
startLine: options.startLine ?? 1,
|
|
@@ -325,10 +266,12 @@ function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedH
|
|
|
325
266
|
};
|
|
326
267
|
}
|
|
327
268
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
269
|
+
interface HashlineChunkEmitter {
|
|
270
|
+
pushLine: (line: string) => string[];
|
|
271
|
+
flush: () => string | undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): HashlineChunkEmitter {
|
|
332
275
|
let lineNumber = options.startLine;
|
|
333
276
|
let outLines: string[] = [];
|
|
334
277
|
let outBytes = 0;
|
|
@@ -342,19 +285,18 @@ function createHashlineChunkEmitter(
|
|
|
342
285
|
};
|
|
343
286
|
|
|
344
287
|
const pushLine = (line: string): string[] => {
|
|
345
|
-
const formatted =
|
|
288
|
+
const formatted = formatHashLine(lineNumber, line);
|
|
346
289
|
lineNumber++;
|
|
347
290
|
|
|
348
|
-
const
|
|
291
|
+
const chunks: string[] = [];
|
|
349
292
|
const sepBytes = outLines.length === 0 ? 0 : 1;
|
|
350
293
|
const lineBytes = Buffer.byteLength(formatted, "utf-8");
|
|
294
|
+
const wouldOverflow =
|
|
295
|
+
outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes;
|
|
351
296
|
|
|
352
|
-
if (
|
|
353
|
-
outLines.length > 0 &&
|
|
354
|
-
(outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes)
|
|
355
|
-
) {
|
|
297
|
+
if (outLines.length > 0 && wouldOverflow) {
|
|
356
298
|
const flushed = flush();
|
|
357
|
-
if (flushed)
|
|
299
|
+
if (flushed) chunks.push(flushed);
|
|
358
300
|
}
|
|
359
301
|
|
|
360
302
|
outLines.push(formatted);
|
|
@@ -362,10 +304,9 @@ function createHashlineChunkEmitter(
|
|
|
362
304
|
|
|
363
305
|
if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) {
|
|
364
306
|
const flushed = flush();
|
|
365
|
-
if (flushed)
|
|
307
|
+
if (flushed) chunks.push(flushed);
|
|
366
308
|
}
|
|
367
|
-
|
|
368
|
-
return chunksToYield;
|
|
309
|
+
return chunks;
|
|
369
310
|
};
|
|
370
311
|
|
|
371
312
|
return { pushLine, flush };
|
|
@@ -393,1040 +334,863 @@ async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): Asy
|
|
|
393
334
|
}
|
|
394
335
|
}
|
|
395
336
|
|
|
396
|
-
/**
|
|
397
|
-
* Stream hashline-formatted output from a UTF-8 byte source.
|
|
398
|
-
*
|
|
399
|
-
* This is intended for large files where callers want incremental output
|
|
400
|
-
* (e.g. while reading from a file handle) rather than allocating a single
|
|
401
|
-
* large string.
|
|
402
|
-
*/
|
|
403
337
|
export async function* streamHashLinesFromUtf8(
|
|
404
338
|
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
405
339
|
options: HashlineStreamOptions = {},
|
|
406
340
|
): AsyncGenerator<string> {
|
|
407
|
-
const
|
|
341
|
+
const resolved = resolveHashlineStreamOptions(options);
|
|
408
342
|
const decoder = new TextDecoder("utf-8");
|
|
409
343
|
const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source;
|
|
344
|
+
const emitter = createHashlineChunkEmitter(resolved);
|
|
345
|
+
|
|
410
346
|
let pending = "";
|
|
411
|
-
let
|
|
412
|
-
|
|
413
|
-
const emitter = createHashlineChunkEmitter(resolvedOptions);
|
|
414
|
-
|
|
415
|
-
const consumeText = (text: string): string[] => {
|
|
416
|
-
if (text.length === 0) return [];
|
|
417
|
-
sawAnyText = true;
|
|
418
|
-
pending += text;
|
|
419
|
-
const chunksToYield: string[] = [];
|
|
420
|
-
while (true) {
|
|
421
|
-
const idx = pending.indexOf("\n");
|
|
422
|
-
if (idx === -1) break;
|
|
423
|
-
const line = pending.slice(0, idx);
|
|
424
|
-
pending = pending.slice(idx + 1);
|
|
425
|
-
endedWithNewline = true;
|
|
426
|
-
chunksToYield.push(...emitter.pushLine(line));
|
|
427
|
-
}
|
|
428
|
-
if (pending.length > 0) endedWithNewline = false;
|
|
429
|
-
return chunksToYield;
|
|
430
|
-
};
|
|
347
|
+
let sawAnyLine = false;
|
|
348
|
+
|
|
431
349
|
for await (const chunk of chunks) {
|
|
432
|
-
|
|
433
|
-
|
|
350
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
351
|
+
let nl = pending.indexOf("\n");
|
|
352
|
+
while (nl !== -1) {
|
|
353
|
+
const raw = pending.slice(0, nl);
|
|
354
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
355
|
+
sawAnyLine = true;
|
|
356
|
+
for (const out of emitter.pushLine(line)) yield out;
|
|
357
|
+
pending = pending.slice(nl + 1);
|
|
358
|
+
nl = pending.indexOf("\n");
|
|
434
359
|
}
|
|
435
360
|
}
|
|
436
361
|
|
|
437
|
-
|
|
438
|
-
|
|
362
|
+
pending += decoder.decode();
|
|
363
|
+
if (pending.length > 0) {
|
|
364
|
+
sawAnyLine = true;
|
|
365
|
+
const tail = pending.endsWith("\r") ? pending.slice(0, -1) : pending;
|
|
366
|
+
for (const out of emitter.pushLine(tail)) yield out;
|
|
439
367
|
}
|
|
440
|
-
if (!
|
|
441
|
-
|
|
442
|
-
for (const out of emitter.pushLine("")) {
|
|
443
|
-
yield out;
|
|
444
|
-
}
|
|
445
|
-
} else if (pending.length > 0 || endedWithNewline) {
|
|
446
|
-
// Emit the final line (may be empty if the file ended with a newline).
|
|
447
|
-
for (const out of emitter.pushLine(pending)) {
|
|
448
|
-
yield out;
|
|
449
|
-
}
|
|
368
|
+
if (!sawAnyLine) {
|
|
369
|
+
for (const out of emitter.pushLine("")) yield out;
|
|
450
370
|
}
|
|
451
371
|
|
|
452
372
|
const last = emitter.flush();
|
|
453
373
|
if (last) yield last;
|
|
454
374
|
}
|
|
455
375
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
* Each yielded chunk is a `\n`-joined string of one or more formatted lines.
|
|
460
|
-
*/
|
|
461
|
-
export async function* streamHashLinesFromLines(
|
|
462
|
-
lines: Iterable<string> | AsyncIterable<string>,
|
|
463
|
-
options: HashlineStreamOptions = {},
|
|
464
|
-
): AsyncGenerator<string> {
|
|
465
|
-
const resolvedOptions = resolveHashlineStreamOptions(options);
|
|
466
|
-
const emitter = createHashlineChunkEmitter(resolvedOptions);
|
|
467
|
-
let sawAnyLine = false;
|
|
468
|
-
|
|
469
|
-
const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator];
|
|
470
|
-
if (typeof asyncIterator === "function") {
|
|
471
|
-
for await (const line of lines as AsyncIterable<string>) {
|
|
472
|
-
sawAnyLine = true;
|
|
473
|
-
for (const out of emitter.pushLine(line)) {
|
|
474
|
-
yield out;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
} else {
|
|
478
|
-
for (const line of lines as Iterable<string>) {
|
|
479
|
-
sawAnyLine = true;
|
|
480
|
-
for (const out of emitter.pushLine(line)) {
|
|
481
|
-
yield out;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
if (!sawAnyLine) {
|
|
486
|
-
// Mirror `"".split("\n")` behavior: one empty line.
|
|
487
|
-
for (const out of emitter.pushLine("")) {
|
|
488
|
-
yield out;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
376
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
377
|
+
// 7. Anchor parsing & validation
|
|
378
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
491
379
|
|
|
492
|
-
|
|
493
|
-
|
|
380
|
+
export function formatFullAnchorRequirement(raw?: string): string {
|
|
381
|
+
const suffix = typeof raw === "string" ? raw.trim() : "";
|
|
382
|
+
const hashOnlyHint = HASHLINE_HASH_HINT_RE.test(suffix)
|
|
383
|
+
? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
|
|
384
|
+
`Copy the full anchor exactly as shown (for example, "160${suffix}").`
|
|
385
|
+
: "";
|
|
386
|
+
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
|
|
387
|
+
return (
|
|
388
|
+
`the full anchor exactly as shown by read/search output ` +
|
|
389
|
+
`(line number + hash, for example ${HASHLINE_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
|
|
390
|
+
);
|
|
494
391
|
}
|
|
495
392
|
|
|
496
|
-
/**
|
|
497
|
-
* Parse a line reference string like `"5th"` into structured form.
|
|
498
|
-
*
|
|
499
|
-
* @throws Error if the format is invalid (not `NUMBERBIGRAM`)
|
|
500
|
-
*/
|
|
501
393
|
export function parseTag(ref: string): { line: number; hash: string } {
|
|
502
|
-
|
|
503
|
-
// 1. optional leading ">+-" markers and whitespace
|
|
504
|
-
// 2. line number (1+ digits)
|
|
505
|
-
// 3. hash (one BPE bigram from HASHLINE_BIGRAMS) directly adjacent (no separator)
|
|
506
|
-
const match = ref.match(new RegExp(`^\\s*[>+\\-*]*\\s*(\\d+)(${HASHLINE_BIGRAM_RE_SRC})`));
|
|
394
|
+
const match = ref.match(PARSE_TAG_RE);
|
|
507
395
|
if (!match) {
|
|
508
396
|
throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
|
|
509
397
|
}
|
|
510
398
|
const line = Number.parseInt(match[1], 10);
|
|
511
|
-
if (line < 1) {
|
|
512
|
-
throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
|
|
513
|
-
}
|
|
399
|
+
if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
|
|
514
400
|
return { line, hash: match[2] };
|
|
515
401
|
}
|
|
516
402
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
403
|
+
function parseLid(raw: string, lineNum: number): Anchor {
|
|
404
|
+
const match = LID_CAPTURE_RE.exec(raw);
|
|
405
|
+
if (!match) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`line ${lineNum}: expected a full anchor such as ${describeAnchorExamples("119")}; ` +
|
|
408
|
+
`got ${JSON.stringify(raw)}.`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return { line: Number.parseInt(match[1], 10), hash: match[2] };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
interface ParsedRange {
|
|
415
|
+
start: Anchor;
|
|
416
|
+
end: Anchor;
|
|
417
|
+
}
|
|
520
418
|
|
|
521
|
-
|
|
522
|
-
const
|
|
419
|
+
function parseRange(raw: string, lineNum: number): ParsedRange {
|
|
420
|
+
const [startRaw, endRaw] = raw.split("..");
|
|
421
|
+
if (!startRaw) throw new Error(`line ${lineNum}: range is missing its first anchor.`);
|
|
422
|
+
const start = parseLid(startRaw, lineNum);
|
|
423
|
+
const end = endRaw === undefined ? { ...start } : parseLid(endRaw, lineNum);
|
|
424
|
+
if (end.line < start.line) {
|
|
425
|
+
throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`);
|
|
426
|
+
}
|
|
427
|
+
if (end.line === start.line && end.hash !== start.hash) {
|
|
428
|
+
throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} uses two different hashes for the same line.`);
|
|
429
|
+
}
|
|
430
|
+
return { start, end };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function expandRange(range: ParsedRange): Anchor[] {
|
|
434
|
+
const anchors: Anchor[] = [];
|
|
435
|
+
for (let line = range.start.line; line <= range.end.line; line++) {
|
|
436
|
+
const hash =
|
|
437
|
+
line === range.start.line ? range.start.hash : line === range.end.line ? range.end.hash : RANGE_INTERIOR_HASH;
|
|
438
|
+
anchors.push({ line, hash });
|
|
439
|
+
}
|
|
440
|
+
return anchors;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor {
|
|
444
|
+
if (raw === "BOF") return { kind: "bof" };
|
|
445
|
+
if (raw === "EOF") return { kind: "eof" };
|
|
446
|
+
const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
|
|
447
|
+
return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
|
|
451
|
+
if (ref.line < 1 || ref.line > fileLines.length) {
|
|
452
|
+
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
453
|
+
}
|
|
454
|
+
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? "");
|
|
455
|
+
if (actualHash !== ref.hash) {
|
|
456
|
+
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
461
|
+
// 8. Mismatch error & rebase
|
|
462
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] {
|
|
465
|
+
const displayLines = new Set<number>();
|
|
466
|
+
for (const mismatch of mismatches) {
|
|
467
|
+
const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT);
|
|
468
|
+
const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT);
|
|
469
|
+
for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
|
|
470
|
+
}
|
|
471
|
+
return [...displayLines].sort((a, b) => a - b);
|
|
472
|
+
}
|
|
523
473
|
|
|
524
|
-
/**
|
|
525
|
-
* Error thrown when one or more hashline references have stale hashes.
|
|
526
|
-
*
|
|
527
|
-
* Displays grep-style output with `*` marker on mismatched lines and a leading space on
|
|
528
|
-
* surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
|
|
529
|
-
*/
|
|
530
474
|
export class HashlineMismatchError extends Error {
|
|
531
475
|
readonly remaps: ReadonlyMap<string, string>;
|
|
476
|
+
|
|
532
477
|
constructor(
|
|
533
478
|
public readonly mismatches: HashMismatch[],
|
|
534
479
|
public readonly fileLines: string[],
|
|
535
480
|
) {
|
|
536
481
|
super(HashlineMismatchError.formatMessage(mismatches, fileLines));
|
|
537
482
|
this.name = "HashlineMismatchError";
|
|
483
|
+
|
|
538
484
|
const remaps = new Map<string, string>();
|
|
539
|
-
for (const
|
|
540
|
-
const actual = computeLineHash(
|
|
541
|
-
remaps.set(`${
|
|
485
|
+
for (const mismatch of mismatches) {
|
|
486
|
+
const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "");
|
|
487
|
+
remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`);
|
|
542
488
|
}
|
|
543
489
|
this.remaps = remaps;
|
|
544
490
|
}
|
|
545
491
|
|
|
546
|
-
/**
|
|
547
|
-
* User-visible variant of {@link formatMessage} — omits the bigram fingerprint
|
|
548
|
-
* and uses a `│` gutter so TUI rendering is clean. The model still receives
|
|
549
|
-
* the full `LINE+ID|content` form via {@link Error.message}.
|
|
550
|
-
*/
|
|
551
492
|
get displayMessage(): string {
|
|
552
493
|
return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
|
|
553
494
|
}
|
|
554
495
|
|
|
555
|
-
static
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const displayLines = new Set<number>();
|
|
560
|
-
for (const m of mismatches) {
|
|
561
|
-
const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
|
|
562
|
-
const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
|
|
563
|
-
for (let i = lo; i <= hi; i++) displayLines.add(i);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const sorted = [...displayLines].sort((a, b) => a - b);
|
|
567
|
-
const out: string[] = [
|
|
568
|
-
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read (marked *).`,
|
|
496
|
+
private static rejectionHeader(mismatches: HashMismatch[]): string[] {
|
|
497
|
+
const noun = mismatches.length > 1 ? "lines have" : "line has";
|
|
498
|
+
return [
|
|
499
|
+
`Edit rejected: ${mismatches.length} ${noun} changed since the last read (marked *).`,
|
|
569
500
|
"The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
|
|
570
|
-
"",
|
|
571
501
|
];
|
|
502
|
+
}
|
|
572
503
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
504
|
+
static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
|
505
|
+
const mismatchSet = new Set<number>(mismatches.map(m => m.line));
|
|
506
|
+
const displayLines = getMismatchDisplayLines(mismatches, fileLines);
|
|
507
|
+
const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0);
|
|
508
|
+
|
|
509
|
+
const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""];
|
|
510
|
+
let previous = -1;
|
|
511
|
+
for (const lineNum of displayLines) {
|
|
512
|
+
if (previous !== -1 && lineNum > previous + 1) out.push("...");
|
|
513
|
+
previous = lineNum;
|
|
579
514
|
const marker = mismatchSet.has(lineNum) ? "*" : " ";
|
|
580
|
-
out.push(formatCodeFrameLine(marker, lineNum,
|
|
515
|
+
out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width));
|
|
581
516
|
}
|
|
582
517
|
return out.join("\n");
|
|
583
518
|
}
|
|
584
519
|
|
|
585
520
|
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
|
586
|
-
const mismatchSet = new
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
for (const m of mismatches) {
|
|
594
|
-
const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
|
|
595
|
-
const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
|
|
596
|
-
for (let i = lo; i <= hi; i++) {
|
|
597
|
-
displayLines.add(i);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const sorted = [...displayLines].sort((a, b) => a - b);
|
|
602
|
-
const lines: string[] = [];
|
|
603
|
-
|
|
604
|
-
lines.push(
|
|
605
|
-
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read (marked *).`,
|
|
606
|
-
"The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
|
|
607
|
-
);
|
|
608
|
-
|
|
609
|
-
let prevLine = -1;
|
|
610
|
-
for (const lineNum of sorted) {
|
|
611
|
-
// Gap separator between non-contiguous regions
|
|
612
|
-
if (prevLine !== -1 && lineNum > prevLine + 1) {
|
|
613
|
-
lines.push("...");
|
|
614
|
-
}
|
|
615
|
-
prevLine = lineNum;
|
|
616
|
-
|
|
617
|
-
const text = fileLines[lineNum - 1];
|
|
521
|
+
const mismatchSet = new Set<number>(mismatches.map(m => m.line));
|
|
522
|
+
const lines = HashlineMismatchError.rejectionHeader(mismatches);
|
|
523
|
+
let previous = -1;
|
|
524
|
+
for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) {
|
|
525
|
+
if (previous !== -1 && lineNum > previous + 1) lines.push("...");
|
|
526
|
+
previous = lineNum;
|
|
527
|
+
const text = fileLines[lineNum - 1] ?? "";
|
|
618
528
|
const hash = computeLineHash(lineNum, text);
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
if (mismatchSet.has(lineNum)) {
|
|
622
|
-
lines.push(`*${prefix}|${text}`);
|
|
623
|
-
} else {
|
|
624
|
-
lines.push(` ${prefix}|${text}`);
|
|
625
|
-
}
|
|
529
|
+
const marker = mismatchSet.has(lineNum) ? "*" : " ";
|
|
530
|
+
lines.push(`${marker}${lineNum}${hash}${HASHLINE_CONTENT_SEPARATOR}${text}`);
|
|
626
531
|
}
|
|
627
532
|
return lines.join("\n");
|
|
628
533
|
}
|
|
629
534
|
}
|
|
630
535
|
|
|
631
536
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
* @param fileLines - Array of file lines (0-indexed)
|
|
636
|
-
* @throws HashlineMismatchError if the hash doesn't match (includes correct hashes in context)
|
|
637
|
-
* @throws Error if the line is out of range
|
|
537
|
+
* Try to find a unique line within ±window where the file's actual hash
|
|
538
|
+
* matches the anchor's expected hash. Returns the new line number, or `null`
|
|
539
|
+
* if zero or multiple candidates were found.
|
|
638
540
|
*/
|
|
639
|
-
export function
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
541
|
+
export function tryRebaseAnchor(
|
|
542
|
+
anchor: { line: number; hash: string },
|
|
543
|
+
fileLines: string[],
|
|
544
|
+
window: number = ANCHOR_REBASE_WINDOW,
|
|
545
|
+
): number | null {
|
|
546
|
+
const lo = Math.max(1, anchor.line - window);
|
|
547
|
+
const hi = Math.min(fileLines.length, anchor.line + window);
|
|
548
|
+
let found: number | null = null;
|
|
549
|
+
for (let lineNum = lo; lineNum <= hi; lineNum++) {
|
|
550
|
+
if (computeLineHash(lineNum, fileLines[lineNum - 1] ?? "") !== anchor.hash) continue;
|
|
551
|
+
if (found !== null) return null;
|
|
552
|
+
found = lineNum;
|
|
646
553
|
}
|
|
554
|
+
return found;
|
|
647
555
|
}
|
|
648
556
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
export const ANCHOR_REBASE_WINDOW = 5;
|
|
557
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
558
|
+
// 9. Compact diff preview
|
|
559
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
653
560
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
*
|
|
662
|
-
* The exact-position match (anchor.line itself) is intentionally skipped: the
|
|
663
|
-
* caller has already determined the requested line's hash does not match.
|
|
664
|
-
*/
|
|
665
|
-
export function tryRebaseAnchor(
|
|
666
|
-
anchor: { line: number; hash: string },
|
|
667
|
-
fileLines: string[],
|
|
668
|
-
window: number = ANCHOR_REBASE_WINDOW,
|
|
669
|
-
): number | null {
|
|
670
|
-
const lo = Math.max(1, anchor.line - window);
|
|
671
|
-
const hi = Math.min(fileLines.length, anchor.line + window);
|
|
672
|
-
let found: number | null = null;
|
|
673
|
-
for (let line = lo; line <= hi; line++) {
|
|
674
|
-
if (line === anchor.line) continue;
|
|
675
|
-
if (computeLineHash(line, fileLines[line - 1]) !== anchor.hash) continue;
|
|
676
|
-
if (found !== null) return null; // ambiguous: more than one match in window
|
|
677
|
-
found = line;
|
|
678
|
-
}
|
|
679
|
-
return found;
|
|
680
|
-
}
|
|
561
|
+
export function buildCompactHashlineDiffPreview(
|
|
562
|
+
diff: string,
|
|
563
|
+
_options: CompactHashlineDiffOptions = {},
|
|
564
|
+
): CompactHashlineDiffPreview {
|
|
565
|
+
const lines = diff.length === 0 ? [] : diff.split("\n");
|
|
566
|
+
let addedLines = 0;
|
|
567
|
+
let removedLines = 0;
|
|
681
568
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
569
|
+
// `generateDiffString` numbers `+` lines with the post-edit line number,
|
|
570
|
+
// `-` lines with the pre-edit line number, and context lines with the
|
|
571
|
+
// pre-edit line number. To emit fresh anchors usable for follow-up edits,
|
|
572
|
+
// we convert context-line numbers to post-edit positions by tracking the
|
|
573
|
+
// running offset (added so far - removed so far) as we walk the diff.
|
|
574
|
+
const formatted = lines.map(line => {
|
|
575
|
+
const kind = line[0];
|
|
576
|
+
if (kind !== "+" && kind !== "-" && kind !== " ") return line;
|
|
687
577
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
case "replace_line":
|
|
692
|
-
endLine = edit.pos.line;
|
|
693
|
-
break;
|
|
694
|
-
case "replace_range":
|
|
695
|
-
endLine = edit.end.line;
|
|
696
|
-
break;
|
|
697
|
-
default:
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
578
|
+
const body = line.slice(1);
|
|
579
|
+
const sep = body.indexOf("|");
|
|
580
|
+
if (sep === -1) return line;
|
|
700
581
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
if (nextSurvivingIdx >= originalFileLines.length) return;
|
|
704
|
-
const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
|
|
705
|
-
const lastInsertedLine = edit.lines[edit.lines.length - 1];
|
|
706
|
-
const trimmedNext = nextSurvivingLine.trim();
|
|
707
|
-
const trimmedLast = lastInsertedLine.trim();
|
|
708
|
-
if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
|
|
709
|
-
const tag = formatHashLine(endLine + 1, nextSurvivingLine);
|
|
710
|
-
warnings.push(
|
|
711
|
-
`Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
|
|
712
|
-
`If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
582
|
+
const lineNumber = Number.parseInt(body.slice(0, sep), 10);
|
|
583
|
+
const content = body.slice(sep + 1);
|
|
716
584
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
lineKey = `r:${edit.pos.line}:${edit.end.line}`;
|
|
729
|
-
break;
|
|
730
|
-
case "append_at":
|
|
731
|
-
lineKey = `i:${edit.pos.line}`;
|
|
732
|
-
break;
|
|
733
|
-
case "prepend_at":
|
|
734
|
-
lineKey = `ib:${edit.pos.line}`;
|
|
735
|
-
break;
|
|
736
|
-
case "append_file":
|
|
737
|
-
lineKey = "ieof";
|
|
738
|
-
break;
|
|
739
|
-
case "prepend_file":
|
|
740
|
-
lineKey = "ibef";
|
|
741
|
-
break;
|
|
742
|
-
}
|
|
743
|
-
const dstKey = `${lineKey}:${edit.lines.join("\n")}`;
|
|
744
|
-
if (seenEditKeys.has(dstKey)) {
|
|
745
|
-
dedupIndices.add(i);
|
|
746
|
-
} else {
|
|
747
|
-
seenEditKeys.set(dstKey, i);
|
|
585
|
+
switch (kind) {
|
|
586
|
+
case "+":
|
|
587
|
+
addedLines++;
|
|
588
|
+
return `+${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
|
|
589
|
+
case "-":
|
|
590
|
+
removedLines++;
|
|
591
|
+
return `-${lineNumber}--${HASHLINE_CONTENT_SEPARATOR}${content}`;
|
|
592
|
+
default: {
|
|
593
|
+
const newLineNumber = lineNumber + addedLines - removedLines;
|
|
594
|
+
return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
|
|
595
|
+
}
|
|
748
596
|
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
return { preview: formatted.join("\n"), addedLines, removedLines };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
603
|
+
// 10. Edit DSL parsing
|
|
604
|
+
//
|
|
605
|
+
// Grammar (one op per "block"):
|
|
606
|
+
// "+ ANCHOR" followed by 1+ "|TEXT" payload lines — insert
|
|
607
|
+
// "- A..B" no payload — delete range
|
|
608
|
+
// "= A..B" followed by 1+ "|TEXT" payload lines — replace
|
|
609
|
+
//
|
|
610
|
+
// ANCHOR is `LINE<hash>`, e.g. `160ab`. BOF / EOF are also valid insert targets.
|
|
611
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
|
|
614
|
+
const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
|
|
615
|
+
const DELETE_OP_RE = /^-\s*(\S+)$/;
|
|
616
|
+
const REPLACE_OP_RE = /^=\s*(\S+)$/;
|
|
617
|
+
|
|
618
|
+
function cloneCursor(cursor: HashlineCursor): HashlineCursor {
|
|
619
|
+
if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
|
|
620
|
+
if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
|
|
621
|
+
return cursor;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function collectPayload(
|
|
625
|
+
lines: string[],
|
|
626
|
+
startIndex: number,
|
|
627
|
+
opLineNum: number,
|
|
628
|
+
requirePayload: boolean,
|
|
629
|
+
): { payload: string[]; nextIndex: number } {
|
|
630
|
+
const payload: string[] = [];
|
|
631
|
+
let index = startIndex;
|
|
632
|
+
while (index < lines.length) {
|
|
633
|
+
const line = stripTrailingCarriageReturn(lines[index]);
|
|
634
|
+
if (!line.startsWith("|")) break;
|
|
635
|
+
payload.push(line.slice(1));
|
|
636
|
+
index++;
|
|
749
637
|
}
|
|
750
|
-
if (
|
|
751
|
-
|
|
752
|
-
if (dedupIndices.has(i)) edits.splice(i, 1);
|
|
638
|
+
if (payload.length === 0 && requirePayload) {
|
|
639
|
+
throw new Error(`line ${opLineNum}: + and < operations require at least one |TEXT payload line.`);
|
|
753
640
|
}
|
|
641
|
+
return { payload, nextIndex: index };
|
|
754
642
|
}
|
|
755
643
|
|
|
756
|
-
function
|
|
757
|
-
|
|
758
|
-
case "replace_line":
|
|
759
|
-
return { sortLine: edit.pos.line, precedence: 0 };
|
|
760
|
-
case "replace_range":
|
|
761
|
-
return { sortLine: edit.end.line, precedence: 0 };
|
|
762
|
-
case "append_at":
|
|
763
|
-
return { sortLine: edit.pos.line, precedence: 1 };
|
|
764
|
-
case "prepend_at":
|
|
765
|
-
return { sortLine: edit.pos.line, precedence: 2 };
|
|
766
|
-
case "append_file":
|
|
767
|
-
return { sortLine: fileLineCount + 1, precedence: 1 };
|
|
768
|
-
case "prepend_file":
|
|
769
|
-
return { sortLine: 0, precedence: 2 };
|
|
770
|
-
}
|
|
644
|
+
export function parseHashline(diff: string): HashlineEdit[] {
|
|
645
|
+
return parseHashlineWithWarnings(diff).edits;
|
|
771
646
|
}
|
|
772
647
|
|
|
773
|
-
function
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
current: origLines.join("\n"),
|
|
790
|
-
});
|
|
791
|
-
break;
|
|
792
|
-
}
|
|
793
|
-
fileLines.splice(edit.pos.line - 1, 1, ...newLines);
|
|
794
|
-
trackFirstChanged(edit.pos.line);
|
|
795
|
-
break;
|
|
648
|
+
export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
|
|
649
|
+
const edits: HashlineEdit[] = [];
|
|
650
|
+
const lines = diff.split("\n");
|
|
651
|
+
let editIndex = 0;
|
|
652
|
+
|
|
653
|
+
const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
|
|
654
|
+
edits.push({ kind: "insert", cursor: cloneCursor(cursor), text, lineNum, index: editIndex++ });
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
for (let i = 0; i < lines.length; ) {
|
|
658
|
+
const lineNum = i + 1;
|
|
659
|
+
const line = stripTrailingCarriageReturn(lines[i]);
|
|
660
|
+
|
|
661
|
+
if (line.trim().length === 0) {
|
|
662
|
+
i++;
|
|
663
|
+
continue;
|
|
796
664
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const origRange = originalFileLines.slice(edit.pos.line - 1, edit.pos.line - 1 + count);
|
|
800
|
-
if (count === edit.lines.length && origRange.every((line, i) => line === edit.lines[i])) {
|
|
801
|
-
noopEdits.push({
|
|
802
|
-
editIndex,
|
|
803
|
-
loc: `${edit.pos.line}${edit.pos.hash}-${edit.end.line}${edit.end.hash}`,
|
|
804
|
-
current: origRange.join("\n"),
|
|
805
|
-
});
|
|
806
|
-
break;
|
|
807
|
-
}
|
|
808
|
-
fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
|
|
809
|
-
trackFirstChanged(edit.pos.line);
|
|
810
|
-
break;
|
|
665
|
+
if (line.startsWith("|")) {
|
|
666
|
+
throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
|
|
811
667
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
break;
|
|
821
|
-
}
|
|
822
|
-
fileLines.splice(edit.pos.line, 0, ...inserted);
|
|
823
|
-
trackFirstChanged(edit.pos.line + 1);
|
|
824
|
-
break;
|
|
668
|
+
|
|
669
|
+
const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
|
|
670
|
+
if (insertBeforeMatch) {
|
|
671
|
+
const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
|
|
672
|
+
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
|
|
673
|
+
for (const text of payload) pushInsert(cursor, text, lineNum);
|
|
674
|
+
i = nextIndex;
|
|
675
|
+
continue;
|
|
825
676
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
break;
|
|
835
|
-
}
|
|
836
|
-
fileLines.splice(edit.pos.line - 1, 0, ...inserted);
|
|
837
|
-
trackFirstChanged(edit.pos.line);
|
|
838
|
-
break;
|
|
677
|
+
|
|
678
|
+
const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
|
|
679
|
+
if (insertAfterMatch) {
|
|
680
|
+
const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
|
|
681
|
+
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
|
|
682
|
+
for (const text of payload) pushInsert(cursor, text, lineNum);
|
|
683
|
+
i = nextIndex;
|
|
684
|
+
continue;
|
|
839
685
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
}
|
|
846
|
-
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
847
|
-
fileLines.splice(0, 1, ...inserted);
|
|
848
|
-
trackFirstChanged(1);
|
|
849
|
-
} else {
|
|
850
|
-
fileLines.splice(fileLines.length, 0, ...inserted);
|
|
851
|
-
trackFirstChanged(fileLines.length - inserted.length + 1);
|
|
686
|
+
|
|
687
|
+
const deleteMatch = DELETE_OP_RE.exec(line);
|
|
688
|
+
if (deleteMatch) {
|
|
689
|
+
for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) {
|
|
690
|
+
edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
|
|
852
691
|
}
|
|
853
|
-
|
|
692
|
+
i++;
|
|
693
|
+
continue;
|
|
854
694
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
695
|
+
|
|
696
|
+
const replaceMatch = REPLACE_OP_RE.exec(line);
|
|
697
|
+
if (replaceMatch) {
|
|
698
|
+
const range = parseRange(replaceMatch[1], lineNum);
|
|
699
|
+
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
|
|
700
|
+
// `= A..B` with no payload blanks the range to a single empty line.
|
|
701
|
+
const replacement = payload.length === 0 ? [""] : payload;
|
|
702
|
+
for (const text of replacement) {
|
|
703
|
+
edits.push({
|
|
704
|
+
kind: "insert",
|
|
705
|
+
cursor: { kind: "before_anchor", anchor: { ...range.start } },
|
|
706
|
+
text,
|
|
707
|
+
lineNum,
|
|
708
|
+
index: editIndex++,
|
|
709
|
+
});
|
|
860
710
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
} else {
|
|
864
|
-
fileLines.splice(0, 0, ...inserted);
|
|
711
|
+
for (const anchor of expandRange(range)) {
|
|
712
|
+
edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
|
|
865
713
|
}
|
|
866
|
-
|
|
867
|
-
|
|
714
|
+
i = nextIndex;
|
|
715
|
+
continue;
|
|
868
716
|
}
|
|
717
|
+
|
|
718
|
+
throw new Error(
|
|
719
|
+
`line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or |TEXT payload lines. ` +
|
|
720
|
+
`Got ${JSON.stringify(line)}.`,
|
|
721
|
+
);
|
|
869
722
|
}
|
|
723
|
+
|
|
724
|
+
return { edits, warnings: [] };
|
|
870
725
|
}
|
|
871
726
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}): {
|
|
727
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
728
|
+
// 11. Edit application
|
|
729
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
interface HashlineApplyResult {
|
|
878
732
|
lines: string;
|
|
879
|
-
firstChangedLine
|
|
733
|
+
firstChangedLine?: number;
|
|
880
734
|
warnings?: string[];
|
|
881
|
-
noopEdits?:
|
|
882
|
-
} {
|
|
883
|
-
const { fileLines, firstChangedLine, warnings, noopEdits } = params;
|
|
884
|
-
return {
|
|
885
|
-
lines: fileLines.join("\n"),
|
|
886
|
-
firstChangedLine,
|
|
887
|
-
...(warnings.length > 0 ? { warnings } : {}),
|
|
888
|
-
...(noopEdits.length > 0 ? { noopEdits } : {}),
|
|
889
|
-
};
|
|
735
|
+
noopEdits?: HashlineNoopEdit[];
|
|
890
736
|
}
|
|
891
737
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
break;
|
|
899
|
-
case "replace_range":
|
|
900
|
-
validateHashlineRef(edit.pos);
|
|
901
|
-
validateHashlineRef(edit.end);
|
|
902
|
-
if (edit.pos.line > edit.end.line) {
|
|
903
|
-
throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
|
|
904
|
-
}
|
|
905
|
-
break;
|
|
906
|
-
case "append_at":
|
|
907
|
-
case "prepend_at":
|
|
908
|
-
validateHashlineRef(edit.pos);
|
|
909
|
-
ensureHashlineEditHasContent(edit);
|
|
910
|
-
break;
|
|
911
|
-
case "append_file":
|
|
912
|
-
case "prepend_file":
|
|
913
|
-
ensureHashlineEditHasContent(edit);
|
|
914
|
-
break;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
return mismatches;
|
|
738
|
+
interface HashlineNoopEdit {
|
|
739
|
+
editIndex: number;
|
|
740
|
+
loc: string;
|
|
741
|
+
reason: string;
|
|
742
|
+
current: string;
|
|
743
|
+
}
|
|
918
744
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
warnings.push(
|
|
932
|
-
`Auto-rebased anchor ${original} → ${rebased}${ref.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
|
|
933
|
-
);
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
937
|
-
}
|
|
745
|
+
type HashlineLineOrigin = "original" | "insert" | "replacement";
|
|
746
|
+
|
|
747
|
+
interface IndexedEdit {
|
|
748
|
+
edit: HashlineEdit;
|
|
749
|
+
idx: number;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
|
|
753
|
+
if (edit.kind === "delete") return [edit.anchor];
|
|
754
|
+
if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
|
|
755
|
+
if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
|
|
756
|
+
return [];
|
|
938
757
|
}
|
|
939
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
940
|
-
// Edit Application
|
|
941
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
942
758
|
|
|
943
759
|
/**
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
* and hashes validated before any mutation.
|
|
949
|
-
*
|
|
950
|
-
* Edits are sorted bottom-up (highest effective line first) so earlier
|
|
951
|
-
* splices don't invalidate later line numbers.
|
|
952
|
-
*
|
|
953
|
-
* @returns The modified content and the 1-indexed first changed line number
|
|
760
|
+
* Verify every anchor's hash, attempting a small ±window rebase before
|
|
761
|
+
* reporting a mismatch. Mutates anchors in place when rebased. Also detects
|
|
762
|
+
* ambiguous cases where two edits target the same line via different anchors,
|
|
763
|
+
* one of which had to be rebased (treated as a mismatch).
|
|
954
764
|
*/
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
)
|
|
959
|
-
lines: string;
|
|
960
|
-
firstChangedLine: number | undefined;
|
|
961
|
-
warnings?: string[];
|
|
962
|
-
noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
|
|
963
|
-
} {
|
|
964
|
-
if (edits.length === 0) {
|
|
965
|
-
return { lines: text, firstChangedLine: undefined };
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
const fileLines = text.split("\n");
|
|
969
|
-
const originalFileLines = [...fileLines];
|
|
970
|
-
let firstChangedLine: number | undefined;
|
|
971
|
-
const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
|
|
972
|
-
const warnings: string[] = [];
|
|
765
|
+
function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
|
|
766
|
+
const mismatches: HashMismatch[] = [];
|
|
767
|
+
const rebasedAnchors = new Map<Anchor, HashMismatch>();
|
|
768
|
+
const emittedRebaseKeys = new Set<string>();
|
|
973
769
|
|
|
974
|
-
const mismatches = validateHashlineEditRefs(edits, fileLines, warnings);
|
|
975
|
-
if (mismatches.length > 0) {
|
|
976
|
-
throw new HashlineMismatchError(mismatches, fileLines);
|
|
977
|
-
}
|
|
978
770
|
for (const edit of edits) {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
771
|
+
for (const anchor of getHashlineEditAnchors(edit)) {
|
|
772
|
+
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
773
|
+
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
774
|
+
}
|
|
775
|
+
if (anchor.hash === RANGE_INTERIOR_HASH) continue;
|
|
776
|
+
|
|
777
|
+
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? "");
|
|
778
|
+
if (actualHash === anchor.hash) continue;
|
|
779
|
+
|
|
780
|
+
const rebased = tryRebaseAnchor(anchor, fileLines);
|
|
781
|
+
if (rebased !== null) {
|
|
782
|
+
const original = `${anchor.line}${anchor.hash}`;
|
|
783
|
+
rebasedAnchors.set(anchor, { line: anchor.line, expected: anchor.hash, actual: actualHash });
|
|
784
|
+
anchor.line = rebased;
|
|
785
|
+
const rebaseKey = `${original}→${rebased}${anchor.hash}`;
|
|
786
|
+
if (!emittedRebaseKeys.has(rebaseKey)) {
|
|
787
|
+
emittedRebaseKeys.add(rebaseKey);
|
|
788
|
+
warnings.push(
|
|
789
|
+
`Auto-rebased anchor ${original} → ${rebased}${anchor.hash} ` +
|
|
790
|
+
`(line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
|
|
796
|
+
}
|
|
992
797
|
}
|
|
993
798
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
799
|
+
// Detect collisions: two delete edits resolving to the same line, where at
|
|
800
|
+
// least one had to be rebased — that's likely the rebase landing on the
|
|
801
|
+
// wrong row, so surface the original mismatch.
|
|
802
|
+
const seenLines = new Map<number, Anchor>();
|
|
803
|
+
for (const edit of edits) {
|
|
804
|
+
if (edit.kind !== "delete") continue;
|
|
805
|
+
const existing = seenLines.get(edit.anchor.line);
|
|
806
|
+
if (existing) {
|
|
807
|
+
const rebasedA = rebasedAnchors.get(edit.anchor);
|
|
808
|
+
const rebasedB = rebasedAnchors.get(existing);
|
|
809
|
+
if (rebasedA) mismatches.push(rebasedA);
|
|
810
|
+
else if (rebasedB) mismatches.push(rebasedB);
|
|
811
|
+
continue;
|
|
999
812
|
}
|
|
813
|
+
seenLines.set(edit.anchor.line, edit.anchor);
|
|
1000
814
|
}
|
|
1001
|
-
}
|
|
1002
815
|
|
|
1003
|
-
|
|
1004
|
-
preview: string;
|
|
1005
|
-
addedLines: number;
|
|
1006
|
-
removedLines: number;
|
|
816
|
+
return mismatches;
|
|
1007
817
|
}
|
|
1008
818
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
819
|
+
function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
|
|
820
|
+
if (lines.length === 0) return;
|
|
821
|
+
const origins = lines.map((): HashlineLineOrigin => "insert");
|
|
822
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
823
|
+
fileLines.splice(0, 1, ...lines);
|
|
824
|
+
lineOrigins.splice(0, 1, ...origins);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
fileLines.splice(0, 0, ...lines);
|
|
828
|
+
lineOrigins.splice(0, 0, ...origins);
|
|
1012
829
|
}
|
|
1013
830
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
831
|
+
function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): number | undefined {
|
|
832
|
+
if (lines.length === 0) return undefined;
|
|
833
|
+
const origins = lines.map((): HashlineLineOrigin => "insert");
|
|
834
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
835
|
+
fileLines.splice(0, 1, ...lines);
|
|
836
|
+
lineOrigins.splice(0, 1, ...origins);
|
|
837
|
+
return 1;
|
|
838
|
+
}
|
|
839
|
+
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
|
|
840
|
+
const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
841
|
+
fileLines.splice(insertIndex, 0, ...lines);
|
|
842
|
+
lineOrigins.splice(insertIndex, 0, ...origins);
|
|
843
|
+
return insertIndex + 1;
|
|
1026
844
|
}
|
|
1027
845
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
846
|
+
/** Bucket edits by the line they target so we can apply each line's group in one splice. */
|
|
847
|
+
function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
|
|
848
|
+
const byLine = new Map<number, IndexedEdit[]>();
|
|
849
|
+
for (const entry of edits) {
|
|
850
|
+
const line =
|
|
851
|
+
entry.edit.kind === "delete"
|
|
852
|
+
? entry.edit.anchor.line
|
|
853
|
+
: entry.edit.cursor.kind === "before_anchor"
|
|
854
|
+
? entry.edit.cursor.anchor.line
|
|
855
|
+
: 0;
|
|
856
|
+
const bucket = byLine.get(line);
|
|
857
|
+
if (bucket) bucket.push(entry);
|
|
858
|
+
else byLine.set(line, [entry]);
|
|
859
|
+
}
|
|
860
|
+
return byLine;
|
|
1031
861
|
}
|
|
1032
862
|
|
|
1033
|
-
|
|
863
|
+
export function applyHashlineEdits(text: string, edits: HashlineEdit[]): HashlineApplyResult {
|
|
864
|
+
if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
|
|
1034
865
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}
|
|
866
|
+
const fileLines = text.split("\n");
|
|
867
|
+
const lineOrigins: HashlineLineOrigin[] = fileLines.map(() => "original");
|
|
868
|
+
const warnings: string[] = [];
|
|
1039
869
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
}
|
|
870
|
+
let firstChangedLine: number | undefined;
|
|
871
|
+
const trackFirstChanged = (line: number) => {
|
|
872
|
+
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
873
|
+
};
|
|
1045
874
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
newLine?: number;
|
|
1049
|
-
}
|
|
875
|
+
const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
|
|
876
|
+
if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
|
|
1050
877
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
878
|
+
// Normalize after_anchor inserts to before_anchor of the next line, or EOF
|
|
879
|
+
// when the anchor is the final line. This keeps the bucketing logic below
|
|
880
|
+
// (which only knows about before_anchor / bof / eof) untouched.
|
|
881
|
+
for (const edit of edits) {
|
|
882
|
+
if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue;
|
|
883
|
+
const anchorLine = edit.cursor.anchor.line;
|
|
884
|
+
if (anchorLine >= fileLines.length) {
|
|
885
|
+
edit.cursor = { kind: "eof" };
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const nextLineNum = anchorLine + 1;
|
|
889
|
+
const nextContent = fileLines[nextLineNum - 1] ?? "";
|
|
890
|
+
edit.cursor = {
|
|
891
|
+
kind: "before_anchor",
|
|
892
|
+
anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) },
|
|
893
|
+
};
|
|
894
|
+
}
|
|
1054
895
|
|
|
1055
|
-
|
|
1056
|
-
|
|
896
|
+
// Partition edits into BOF, EOF, and anchor-targeted buckets.
|
|
897
|
+
const bofLines: string[] = [];
|
|
898
|
+
const eofLines: string[] = [];
|
|
899
|
+
const anchorEdits: IndexedEdit[] = [];
|
|
900
|
+
edits.forEach((edit, idx) => {
|
|
901
|
+
if (edit.kind === "insert" && edit.cursor.kind === "bof") {
|
|
902
|
+
bofLines.push(edit.text);
|
|
903
|
+
} else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
|
|
904
|
+
eofLines.push(edit.text);
|
|
905
|
+
} else {
|
|
906
|
+
anchorEdits.push({ edit, idx });
|
|
907
|
+
}
|
|
908
|
+
});
|
|
1057
909
|
|
|
1058
|
-
|
|
1059
|
-
|
|
910
|
+
// Apply per-line buckets bottom-up so earlier indices stay valid.
|
|
911
|
+
const byLine = bucketAnchorEditsByLine(anchorEdits);
|
|
912
|
+
for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
|
|
913
|
+
const bucket = byLine.get(line);
|
|
914
|
+
if (!bucket) continue;
|
|
915
|
+
bucket.sort((a, b) => a.idx - b.idx);
|
|
916
|
+
|
|
917
|
+
const idx = line - 1;
|
|
918
|
+
const currentLine = fileLines[idx] ?? "";
|
|
919
|
+
const beforeLines: string[] = [];
|
|
920
|
+
let deleteLine = false;
|
|
921
|
+
|
|
922
|
+
for (const { edit } of bucket) {
|
|
923
|
+
if (edit.kind === "insert") beforeLines.push(edit.text);
|
|
924
|
+
else deleteLine = true;
|
|
925
|
+
}
|
|
926
|
+
if (beforeLines.length === 0 && !deleteLine) continue;
|
|
1060
927
|
|
|
1061
|
-
|
|
1062
|
-
|
|
928
|
+
const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
|
|
929
|
+
const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
|
|
930
|
+
if (!deleteLine) origins[origins.length - 1] = lineOrigins[idx] ?? "original";
|
|
1063
931
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
counters.newLine = lineNumber;
|
|
1068
|
-
return;
|
|
932
|
+
fileLines.splice(idx, 1, ...replacement);
|
|
933
|
+
lineOrigins.splice(idx, 1, ...origins);
|
|
934
|
+
trackFirstChanged(line);
|
|
1069
935
|
}
|
|
1070
936
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: number): void {
|
|
1077
|
-
if (counters.oldLine === undefined || counters.newLine === undefined) {
|
|
1078
|
-
counters.oldLine = lineNumber;
|
|
1079
|
-
counters.newLine = lineNumber;
|
|
1080
|
-
return;
|
|
937
|
+
if (bofLines.length > 0) {
|
|
938
|
+
insertAtStart(fileLines, lineOrigins, bofLines);
|
|
939
|
+
trackFirstChanged(1);
|
|
1081
940
|
}
|
|
941
|
+
const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
|
|
942
|
+
if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
|
|
1082
943
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
944
|
+
return {
|
|
945
|
+
lines: fileLines.join("\n"),
|
|
946
|
+
firstChangedLine,
|
|
947
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
948
|
+
};
|
|
1086
949
|
}
|
|
1087
950
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
const entries: Entry[] = [];
|
|
1096
|
-
const counters: CompactPreviewCounters = {};
|
|
951
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
952
|
+
// 12. Input splitting
|
|
953
|
+
//
|
|
954
|
+
// Hashline input may contain multiple file sections, each introduced by a
|
|
955
|
+
// header line of the form `@<path>`. If the input contains recognizable ops
|
|
956
|
+
// but no header, we synthesize one from the caller-supplied `path` option.
|
|
957
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1097
958
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
continue;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
const isEllipsis = parsed.content === ELLIPSIS;
|
|
959
|
+
interface HashlineInputSection {
|
|
960
|
+
path: string;
|
|
961
|
+
diff: string;
|
|
962
|
+
}
|
|
1106
963
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
}
|
|
964
|
+
function unquoteHashlinePath(pathText: string): string {
|
|
965
|
+
if (pathText.length < 2) return pathText;
|
|
966
|
+
const first = pathText[0];
|
|
967
|
+
const last = pathText[pathText.length - 1];
|
|
968
|
+
if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
|
|
969
|
+
return pathText;
|
|
970
|
+
}
|
|
1115
971
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
}
|
|
972
|
+
function normalizeHashlinePath(rawPath: string, cwd?: string): string {
|
|
973
|
+
const unquoted = unquoteHashlinePath(rawPath.trim());
|
|
974
|
+
if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
|
|
975
|
+
const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
|
|
976
|
+
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
977
|
+
return isWithinCwd ? relative || "." : unquoted;
|
|
978
|
+
}
|
|
1124
979
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
entries.push({ kind: " ", oldLine, newLine, content: parsed.content });
|
|
1130
|
-
if (!isEllipsis) {
|
|
1131
|
-
counters.oldLine = oldLine + 1;
|
|
1132
|
-
counters.newLine = newLine + 1;
|
|
1133
|
-
}
|
|
980
|
+
function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
|
|
981
|
+
const trimmed = line.trimEnd();
|
|
982
|
+
if (trimmed === FILE_HEADER_PREFIX) {
|
|
983
|
+
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
|
1134
984
|
}
|
|
985
|
+
if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null;
|
|
986
|
+
const parsedPath = normalizeHashlinePath(trimmed.slice(1), cwd);
|
|
987
|
+
if (parsedPath.length === 0) {
|
|
988
|
+
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
|
989
|
+
}
|
|
990
|
+
return { path: parsedPath, diff: "" };
|
|
991
|
+
}
|
|
1135
992
|
|
|
1136
|
-
|
|
993
|
+
function stripLeadingBlankLines(input: string): string {
|
|
994
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
995
|
+
const lines = stripped.split("\n");
|
|
996
|
+
while (lines.length > 0 && lines[0].replace(/\r$/, "").trim().length === 0) lines.shift();
|
|
997
|
+
return lines.join("\n");
|
|
1137
998
|
}
|
|
1138
999
|
|
|
1139
|
-
function
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
if (prev && prev.kind === entry.kind) {
|
|
1144
|
-
prev.entries.push(entry);
|
|
1145
|
-
continue;
|
|
1146
|
-
}
|
|
1147
|
-
runs.push({ kind: entry.kind, entries: [entry] });
|
|
1000
|
+
function containsRecognizableHashlineOperations(input: string): boolean {
|
|
1001
|
+
for (const rawLine of input.split("\n")) {
|
|
1002
|
+
const line = stripTrailingCarriageReturn(rawLine);
|
|
1003
|
+
if (/^[+<=-]\s+/.test(line) || line.startsWith("|")) return true;
|
|
1148
1004
|
}
|
|
1149
|
-
return
|
|
1005
|
+
return false;
|
|
1150
1006
|
}
|
|
1151
1007
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
for (let i = 0; i < runs.length; i++) {
|
|
1165
|
-
const run = runs[i];
|
|
1166
|
-
const next = runs[i + 1];
|
|
1167
|
-
if (run.kind !== "-" || !next || next.kind !== "+") {
|
|
1168
|
-
out.push(run);
|
|
1169
|
-
continue;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
const dels = run.entries.filter(isPairable);
|
|
1173
|
-
const adds = next.entries.filter(isPairable);
|
|
1174
|
-
const pairCount = Math.min(dels.length, adds.length);
|
|
1175
|
-
if (pairCount === 0) {
|
|
1176
|
-
out.push(run);
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1008
|
+
function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string {
|
|
1009
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
1010
|
+
const hasExplicitHeader = stripped
|
|
1011
|
+
.split("\n")
|
|
1012
|
+
.some(rawLine => parseHashlineHeaderLine(stripTrailingCarriageReturn(rawLine), options.cwd) !== null);
|
|
1013
|
+
if (hasExplicitHeader) return input;
|
|
1014
|
+
|
|
1015
|
+
if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
|
|
1016
|
+
const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
|
|
1017
|
+
if (fallbackPath.length === 0) return input;
|
|
1018
|
+
return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`;
|
|
1019
|
+
}
|
|
1179
1020
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
oldLine: dels[p].oldLine,
|
|
1185
|
-
newLine: adds[p].newLine,
|
|
1186
|
-
content: adds[p].content,
|
|
1187
|
-
});
|
|
1188
|
-
}
|
|
1189
|
-
out.push({ kind: "*", entries: mods });
|
|
1021
|
+
export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
|
|
1022
|
+
const [section] = splitHashlineInputs(input, options);
|
|
1023
|
+
return section;
|
|
1024
|
+
}
|
|
1190
1025
|
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
out.push({ kind: "+", entries: adds.slice(pairCount) });
|
|
1196
|
-
}
|
|
1026
|
+
export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] {
|
|
1027
|
+
const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
|
|
1028
|
+
const lines = stripped.split("\n");
|
|
1029
|
+
const firstLine = stripTrailingCarriageReturn(lines[0] ?? "");
|
|
1197
1030
|
|
|
1198
|
-
|
|
1031
|
+
if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
|
|
1032
|
+
const preview = JSON.stringify(firstLine.slice(0, 120));
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
`input must begin with "@PATH" on the first non-blank line; got: ${preview}. ` +
|
|
1035
|
+
`Example: "@src/foo.ts" then edit ops.`,
|
|
1036
|
+
);
|
|
1199
1037
|
}
|
|
1200
|
-
return out;
|
|
1201
|
-
}
|
|
1202
1038
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1039
|
+
const sections: HashlineInputSection[] = [];
|
|
1040
|
+
let currentPath = "";
|
|
1041
|
+
let currentLines: string[] = [];
|
|
1205
1042
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
}
|
|
1043
|
+
const flush = () => {
|
|
1044
|
+
if (currentPath.length === 0) return;
|
|
1045
|
+
sections.push({ path: currentPath, diff: currentLines.join("\n") });
|
|
1046
|
+
currentLines = [];
|
|
1047
|
+
};
|
|
1212
1048
|
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1049
|
+
for (const rawLine of lines) {
|
|
1050
|
+
const line = stripTrailingCarriageReturn(rawLine);
|
|
1051
|
+
const header = parseHashlineHeaderLine(line, options.cwd);
|
|
1052
|
+
if (header !== null) {
|
|
1053
|
+
flush();
|
|
1054
|
+
currentPath = header.path;
|
|
1055
|
+
currentLines = [];
|
|
1056
|
+
} else {
|
|
1057
|
+
currentLines.push(rawLine);
|
|
1058
|
+
}
|
|
1222
1059
|
}
|
|
1060
|
+
flush();
|
|
1061
|
+
return sections;
|
|
1223
1062
|
}
|
|
1224
1063
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
return [
|
|
1229
|
-
...entries.slice(0, maxRun).map(formatEntry),
|
|
1230
|
-
` ... ${hidden} more unchanged lines`,
|
|
1231
|
-
...entries.slice(-maxRun).map(formatEntry),
|
|
1232
|
-
];
|
|
1233
|
-
}
|
|
1064
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1065
|
+
// 13. Diff computation (for streaming preview)
|
|
1066
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1234
1067
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
*/
|
|
1243
|
-
export function buildCompactHashlineDiffPreview(
|
|
1244
|
-
diff: string,
|
|
1245
|
-
options: CompactHashlineDiffOptions = {},
|
|
1246
|
-
): CompactHashlineDiffPreview {
|
|
1247
|
-
const maxUnchangedRun = options.maxUnchangedRun ?? 2;
|
|
1248
|
-
|
|
1249
|
-
const inputLines = diff.length === 0 ? [] : diff.split("\n");
|
|
1250
|
-
const runs = pairModifications(groupRuns(parseDiffEntries(inputLines)));
|
|
1251
|
-
|
|
1252
|
-
const out: string[] = [];
|
|
1253
|
-
let addedLines = 0;
|
|
1254
|
-
let removedLines = 0;
|
|
1255
|
-
|
|
1256
|
-
for (let runIndex = 0; runIndex < runs.length; runIndex++) {
|
|
1257
|
-
const run = runs[runIndex];
|
|
1258
|
-
switch (run.kind) {
|
|
1259
|
-
case "meta":
|
|
1260
|
-
for (const entry of run.entries) out.push(formatEntry(entry));
|
|
1261
|
-
break;
|
|
1262
|
-
case "+":
|
|
1263
|
-
for (const entry of run.entries) {
|
|
1264
|
-
if (entry.kind !== "meta" && entry.content !== ELLIPSIS) addedLines++;
|
|
1265
|
-
out.push(formatEntry(entry));
|
|
1266
|
-
}
|
|
1267
|
-
break;
|
|
1268
|
-
case "-":
|
|
1269
|
-
for (const entry of run.entries) {
|
|
1270
|
-
if (entry.kind !== "meta" && entry.content !== ELLIPSIS) removedLines++;
|
|
1271
|
-
out.push(formatEntry(entry));
|
|
1272
|
-
}
|
|
1273
|
-
break;
|
|
1274
|
-
case "*":
|
|
1275
|
-
for (const entry of run.entries) {
|
|
1276
|
-
addedLines++;
|
|
1277
|
-
removedLines++;
|
|
1278
|
-
out.push(formatEntry(entry));
|
|
1279
|
-
}
|
|
1280
|
-
break;
|
|
1281
|
-
case " ":
|
|
1282
|
-
if (runIndex === 0) {
|
|
1283
|
-
out.push(...run.entries.slice(-maxUnchangedRun).map(formatEntry));
|
|
1284
|
-
break;
|
|
1285
|
-
}
|
|
1286
|
-
if (runIndex === runs.length - 1) {
|
|
1287
|
-
out.push(...run.entries.slice(0, maxUnchangedRun).map(formatEntry));
|
|
1288
|
-
break;
|
|
1289
|
-
}
|
|
1290
|
-
out.push(...collapseUnchangedMiddle(run.entries, maxUnchangedRun));
|
|
1291
|
-
break;
|
|
1292
|
-
}
|
|
1068
|
+
async function readHashlineFileText(file: { text(): Promise<string> }, pathText: string): Promise<string> {
|
|
1069
|
+
try {
|
|
1070
|
+
return await file.text();
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
if (isEnoent(error)) throw new Error(`File not found: ${pathText}`);
|
|
1073
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1074
|
+
throw new Error(message || `Unable to read ${pathText}`);
|
|
1293
1075
|
}
|
|
1294
|
-
|
|
1295
|
-
return { preview: out.join("\n"), addedLines, removedLines };
|
|
1296
1076
|
}
|
|
1297
1077
|
|
|
1298
1078
|
export async function computeHashlineDiff(
|
|
1299
|
-
input: {
|
|
1079
|
+
input: { input: string; path?: string },
|
|
1300
1080
|
cwd: string,
|
|
1301
|
-
): Promise<
|
|
1302
|
-
| {
|
|
1303
|
-
diff: string;
|
|
1304
|
-
firstChangedLine: number | undefined;
|
|
1305
|
-
}
|
|
1306
|
-
| {
|
|
1307
|
-
error: string;
|
|
1308
|
-
}
|
|
1309
|
-
> {
|
|
1310
|
-
const { path, edits } = input;
|
|
1311
|
-
|
|
1081
|
+
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
1312
1082
|
try {
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
const rawContent = await readHashlineFileText(file, path);
|
|
1318
|
-
|
|
1319
|
-
const { text: content } = stripBom(rawContent);
|
|
1320
|
-
const normalizedContent = normalizeToLF(content);
|
|
1321
|
-
const result = applyHashlineEdits(normalizedContent, resolvedEdits);
|
|
1322
|
-
if (normalizedContent === result.lines) {
|
|
1323
|
-
return { error: `No changes would be made to ${path}. The edits produce identical content.` };
|
|
1083
|
+
const sections = splitHashlineInputs(input.input, { cwd, path: input.path });
|
|
1084
|
+
if (sections.length !== 1) {
|
|
1085
|
+
return { error: "Streaming diff preview supports exactly one hashline section." };
|
|
1324
1086
|
}
|
|
1087
|
+
const [section] = sections;
|
|
1325
1088
|
|
|
1326
|
-
|
|
1089
|
+
const absolutePath = resolveToCwd(section.path, cwd);
|
|
1090
|
+
const rawContent = await readHashlineFileText(Bun.file(absolutePath), section.path);
|
|
1091
|
+
const { text: content } = stripBom(rawContent);
|
|
1092
|
+
const normalized = normalizeToLF(content);
|
|
1093
|
+
const result = applyHashlineEdits(normalized, parseHashline(section.diff));
|
|
1094
|
+
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
|
|
1095
|
+
return generateDiffString(normalized, result.lines);
|
|
1327
1096
|
} catch (err) {
|
|
1328
1097
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
1329
1098
|
}
|
|
1330
1099
|
}
|
|
1331
1100
|
|
|
1332
|
-
|
|
1101
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1102
|
+
// 14. Execution
|
|
1103
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1104
|
+
|
|
1105
|
+
interface ReadHashlineFileResult {
|
|
1106
|
+
exists: boolean;
|
|
1107
|
+
rawContent: string;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function readHashlineFile(absolutePath: string): Promise<ReadHashlineFileResult> {
|
|
1333
1111
|
try {
|
|
1334
|
-
return await file.text();
|
|
1112
|
+
return { exists: true, rawContent: await Bun.file(absolutePath).text() };
|
|
1335
1113
|
} catch (error) {
|
|
1336
|
-
if (isEnoent(error)) {
|
|
1337
|
-
|
|
1338
|
-
}
|
|
1339
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1340
|
-
throw new Error(message || `Unable to read ${path}`);
|
|
1114
|
+
if (isEnoent(error)) return { exists: false, rawContent: "" };
|
|
1115
|
+
throw error;
|
|
1341
1116
|
}
|
|
1342
1117
|
}
|
|
1343
1118
|
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1119
|
+
function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
|
|
1120
|
+
return edits.some(edit => {
|
|
1121
|
+
if (edit.kind === "delete") return true;
|
|
1122
|
+
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1348
1125
|
|
|
1349
|
-
|
|
1126
|
+
function formatNoChangeDiagnostic(pathText: string): string {
|
|
1127
|
+
return `Edits to ${pathText} resulted in no changes being made.`;
|
|
1128
|
+
}
|
|
1350
1129
|
|
|
1351
|
-
|
|
1130
|
+
function getTextContent(result: AgentToolResult<EditToolDetails>): string {
|
|
1131
|
+
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
|
|
1132
|
+
}
|
|
1352
1133
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1134
|
+
function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
|
|
1135
|
+
return result.details ?? { diff: "" };
|
|
1136
|
+
}
|
|
1356
1137
|
|
|
1357
|
-
|
|
1138
|
+
/**
|
|
1139
|
+
* Run all the front-end checks (notebook guard, parse, plan-mode check, file
|
|
1140
|
+
* load, edit application) without writing. Used to fail fast before applying
|
|
1141
|
+
* any changes in a multi-section batch.
|
|
1142
|
+
*/
|
|
1143
|
+
async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
|
|
1144
|
+
const { session, path: sectionPath, diff } = options;
|
|
1358
1145
|
|
|
1359
|
-
const
|
|
1360
|
-
const
|
|
1146
|
+
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
1147
|
+
const { edits } = parseHashlineWithWarnings(diff);
|
|
1148
|
+
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
1361
1149
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
if (edit.loc === "append") {
|
|
1366
|
-
lines.push(...hashlineParseText(edit.content));
|
|
1367
|
-
} else if (edit.loc === "prepend") {
|
|
1368
|
-
lines.unshift(...hashlineParseText(edit.content));
|
|
1369
|
-
} else {
|
|
1370
|
-
throw new Error(`File not found: ${path}`);
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1150
|
+
const source = await readHashlineFile(absolutePath);
|
|
1151
|
+
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`);
|
|
1152
|
+
if (source.exists) assertEditableFileContent(source.rawContent, sectionPath);
|
|
1373
1153
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1154
|
+
const { text } = stripBom(source.rawContent);
|
|
1155
|
+
const normalized = normalizeToLF(text);
|
|
1156
|
+
const result = applyHashlineEdits(normalized, edits);
|
|
1157
|
+
if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function executeHashlineSection(
|
|
1161
|
+
options: ExecuteHashlineSingleOptions & HashlineInputSection,
|
|
1162
|
+
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
1163
|
+
const {
|
|
1164
|
+
session,
|
|
1165
|
+
path: sourcePath,
|
|
1166
|
+
diff,
|
|
1167
|
+
signal,
|
|
1168
|
+
batchRequest,
|
|
1169
|
+
writethrough,
|
|
1170
|
+
beginDeferredDiagnosticsForPath,
|
|
1171
|
+
} = options;
|
|
1172
|
+
|
|
1173
|
+
const absolutePath = resolvePlanPath(session, sourcePath);
|
|
1174
|
+
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
|
|
1175
|
+
enforcePlanModeWrite(session, sourcePath, { op: "update" });
|
|
1385
1176
|
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
assertEditableFileContent(rawContent,
|
|
1177
|
+
const source = await readHashlineFile(absolutePath);
|
|
1178
|
+
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`);
|
|
1179
|
+
if (source.exists) assertEditableFileContent(source.rawContent, sourcePath);
|
|
1389
1180
|
|
|
1390
|
-
const { bom, text } = stripBom(rawContent);
|
|
1181
|
+
const { bom, text } = stripBom(source.rawContent);
|
|
1391
1182
|
const originalEnding = detectLineEnding(text);
|
|
1392
1183
|
const originalNormalized = normalizeToLF(text);
|
|
1393
|
-
|
|
1184
|
+
const result = applyHashlineEdits(originalNormalized, edits);
|
|
1394
1185
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
firstChangedLine: anchorResult.firstChangedLine,
|
|
1401
|
-
warnings: anchorResult.warnings,
|
|
1402
|
-
noopEdits: anchorResult.noopEdits,
|
|
1403
|
-
};
|
|
1404
|
-
if (originalNormalized === result.text) {
|
|
1405
|
-
let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
|
|
1406
|
-
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
1407
|
-
const details = result.noopEdits
|
|
1408
|
-
.map(
|
|
1409
|
-
edit =>
|
|
1410
|
-
`Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}| ${edit.current}`,
|
|
1411
|
-
)
|
|
1412
|
-
.join("\n");
|
|
1413
|
-
diagnostic += `\n${details}`;
|
|
1414
|
-
if (result.noopEdits.length === 1 && result.noopEdits[0]?.current) {
|
|
1415
|
-
const preview = result.noopEdits[0].current.trimEnd();
|
|
1416
|
-
if (preview.length > 0) {
|
|
1417
|
-
diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`;
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
if (result.noopEdits.some(e => e.loc.includes("-"))) {
|
|
1421
|
-
diagnostic +=
|
|
1422
|
-
"\nHint: a `range` loc replaces the entire span inclusive of both endpoints. " +
|
|
1423
|
-
"If your replacement repeats the existing content, narrow the range or change the replacement.";
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
throw new Error(diagnostic);
|
|
1186
|
+
if (originalNormalized === result.lines) {
|
|
1187
|
+
return {
|
|
1188
|
+
content: [{ type: "text", text: formatNoChangeDiagnostic(sourcePath) }],
|
|
1189
|
+
details: { diff: "", op: "update", meta: outputMeta().get() },
|
|
1190
|
+
};
|
|
1427
1191
|
}
|
|
1428
1192
|
|
|
1429
|
-
const finalContent = bom + restoreLineEndings(result.
|
|
1193
|
+
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
|
|
1430
1194
|
const diagnostics = await writethrough(
|
|
1431
1195
|
absolutePath,
|
|
1432
1196
|
finalContent,
|
|
@@ -1437,30 +1201,65 @@ export async function executeHashlineSingle(
|
|
|
1437
1201
|
);
|
|
1438
1202
|
invalidateFsScanAfterWrite(absolutePath);
|
|
1439
1203
|
|
|
1440
|
-
const diffResult = generateDiffString(originalNormalized, result.
|
|
1204
|
+
const diffResult = generateDiffString(originalNormalized, result.lines);
|
|
1441
1205
|
const meta = outputMeta()
|
|
1442
1206
|
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
1443
1207
|
.get();
|
|
1444
|
-
|
|
1445
|
-
const resultText = `Updated ${path}`;
|
|
1446
1208
|
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
1447
|
-
|
|
1448
|
-
const
|
|
1449
|
-
const
|
|
1209
|
+
|
|
1210
|
+
const warnings = [...parseWarnings, ...(result.warnings ?? [])];
|
|
1211
|
+
const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
1212
|
+
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
1213
|
+
const headline = preview.preview
|
|
1214
|
+
? `${sourcePath}:`
|
|
1215
|
+
: source.exists
|
|
1216
|
+
? `Updated ${sourcePath}`
|
|
1217
|
+
: `Created ${sourcePath}`;
|
|
1450
1218
|
|
|
1451
1219
|
return {
|
|
1452
|
-
content: [
|
|
1453
|
-
{
|
|
1454
|
-
type: "text",
|
|
1455
|
-
text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
|
|
1456
|
-
},
|
|
1457
|
-
],
|
|
1220
|
+
content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }],
|
|
1458
1221
|
details: {
|
|
1459
1222
|
diff: diffResult.diff,
|
|
1460
1223
|
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
|
1461
1224
|
diagnostics,
|
|
1462
|
-
op: "update",
|
|
1225
|
+
op: source.exists ? "update" : "create",
|
|
1463
1226
|
meta,
|
|
1464
1227
|
},
|
|
1465
1228
|
};
|
|
1466
1229
|
}
|
|
1230
|
+
|
|
1231
|
+
export async function executeHashlineSingle(
|
|
1232
|
+
options: ExecuteHashlineSingleOptions,
|
|
1233
|
+
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
1234
|
+
const sections = splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path });
|
|
1235
|
+
|
|
1236
|
+
// Fast path: a single section needs no preflight pass.
|
|
1237
|
+
if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] });
|
|
1238
|
+
|
|
1239
|
+
// Multi-section: validate everything up front so we don't apply a partial batch.
|
|
1240
|
+
for (const section of sections) await preflightHashlineSection({ ...options, ...section });
|
|
1241
|
+
|
|
1242
|
+
const results = [];
|
|
1243
|
+
for (const section of sections) {
|
|
1244
|
+
results.push({ path: section.path, result: await executeHashlineSection({ ...options, ...section }) });
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
return {
|
|
1248
|
+
content: [{ type: "text", text: results.map(({ result }) => getTextContent(result)).join("\n\n") }],
|
|
1249
|
+
details: {
|
|
1250
|
+
diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
|
|
1251
|
+
perFileResults: results.map(({ path: resultPath, result }) => {
|
|
1252
|
+
const details = getEditDetails(result);
|
|
1253
|
+
return {
|
|
1254
|
+
path: resultPath,
|
|
1255
|
+
diff: details.diff,
|
|
1256
|
+
firstChangedLine: details.firstChangedLine,
|
|
1257
|
+
diagnostics: details.diagnostics,
|
|
1258
|
+
op: details.op,
|
|
1259
|
+
move: details.move,
|
|
1260
|
+
meta: details.meta,
|
|
1261
|
+
};
|
|
1262
|
+
}),
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
}
|