pi-hashline-edit-pro 4.4.4 → 4.5.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.
@@ -96,6 +96,25 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
96
96
  return { description: base.description, snippet: base.snippet, guidelines };
97
97
  }
98
98
 
99
+ export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags, toolName: "copy" | "move"): { description: string; snippet: string; guidelines: string[] } {
100
+ const descriptionParts = [base.description];
101
+ const snippetParts = [base.snippet];
102
+ const guidelines = [...base.guidelines];
103
+ if (flags.requirePath) {
104
+ descriptionParts.push("Also give `path` matching the source or destination file the anchors were served for; it is required and must match anchor ownership.");
105
+ snippetParts.push("; include `path` (required)");
106
+ guidelines.push(`\`${toolName}\`: include \`path\` matching the source or destination file the anchors were served for; it is required.`);
107
+ } else {
108
+ descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
109
+ guidelines.push(`\`${toolName}\`: path resolution is anchor-only; don't pass \`path\`.`);
110
+ }
111
+ if (flags.strictInput) {
112
+ descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
113
+ guidelines.push(`\`${toolName}\`: strict-input is on: auto-fixable slips are rejected instead of fixed.`);
114
+ }
115
+ return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
116
+ }
117
+
99
118
  function staleAnchorMessage(ref: string, hash: string, owners: Array<OwnedAnchor | undefined>): string {
100
119
  const knownPaths = new Set<string>();
101
120
  for (const owner of owners) {
@@ -5,6 +5,7 @@ import { loadFileKindAndText, type LFile } from "./file-kind";
5
5
  import { resolveTarget, type FileIdentity } from "./fs-write";
6
6
  import { toCwd, toDisplayPath } from "./paths";
7
7
  import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
8
+ import { splitSeparators } from "./line-endings";
8
9
  import { abortIf, errCode, assertLineLimit } from "./utils";
9
10
  import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
10
11
  import { valKind, valAccess } from "./validation";
@@ -14,6 +15,7 @@ export interface NormFile {
14
15
  normalized: string;
15
16
  bom: string;
16
17
  originalEnding: LineEnding;
18
+ endingSeparators: LineEnding[];
17
19
  fileHashes: string[];
18
20
  hadUtf8DecodeErrors: boolean;
19
21
  identity: FileIdentity;
@@ -91,6 +93,7 @@ export async function readNormFile(
91
93
  abortIf(signal);
92
94
  const { bom, text: rawContent } = stripBOM(file.text);
93
95
  const originalEnding = detectEnding(rawContent);
96
+ const endingSeparators = splitSeparators(rawContent);
94
97
  const normalized = toLF(rawContent);
95
98
 
96
99
  if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
@@ -106,6 +109,7 @@ export async function readNormFile(
106
109
  normalized,
107
110
  bom,
108
111
  originalEnding,
112
+ endingSeparators,
109
113
  fileHashes,
110
114
  identity,
111
115
  hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
@@ -60,3 +60,25 @@ export function isBusyError(error: unknown): boolean {
60
60
  }
61
61
  return error instanceof Error && /busy|locked/i.test(error.message);
62
62
  }
63
+
64
+ export interface SeparatorParse {
65
+ ok: boolean;
66
+ value?: string[];
67
+ }
68
+
69
+ export function parseSeparators(raw: unknown): SeparatorParse {
70
+ if (raw === null || raw === undefined || raw === "") return { ok: true };
71
+ if (typeof raw !== "string") return { ok: false };
72
+ let parsed: unknown;
73
+ try {
74
+ parsed = JSON.parse(raw);
75
+ } catch (error) {
76
+ console.error("[parseSeparators] failed to parse stored separators JSON:", error);
77
+ return { ok: false };
78
+ }
79
+ if (!Array.isArray(parsed) || !parsed.every((entry) => entry === "\n" || entry === "\r" || entry === "\r\n")) {
80
+ console.error("[parseSeparators] stored separators did not pass validation:", JSON.stringify(parsed).slice(0, 200));
81
+ return { ok: false };
82
+ }
83
+ return { ok: true, value: parsed as string[] };
84
+ }
package/src/hash-store.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  isValidSnapshot,
12
12
  isCorruptionError,
13
13
  parseHashList,
14
+ parseSeparators,
14
15
  } from "./hash-store/validation";
15
16
  import {
16
17
  withBusyRetry,
@@ -18,6 +19,7 @@ import {
18
19
  withBusyRetryAsync,
19
20
  } from "./hash-store/retry";
20
21
  import { snapshotCache, cacheSnapshot, SNAPSHOT_CACHE_LIMIT } from "./hash-store/cache";
22
+ import { countNewlines } from "./line-endings";
21
23
 
22
24
  export { isValidHashList, parseHashList, parseStoredHashes, isCorruptionError };
23
25
  export { SNAPSHOT_CACHE_LIMIT };
@@ -135,8 +137,10 @@ export interface UndoRecord {
135
137
  content: string;
136
138
  bom: string;
137
139
  ending: string;
140
+ separators?: string[];
138
141
  hashes: string[];
139
142
  resultContent: string;
143
+ resultSeparators?: string[];
140
144
  mode?: number;
141
145
  }
142
146
 
@@ -181,8 +185,10 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
181
185
  "content TEXT NOT NULL, " +
182
186
  "bom TEXT NOT NULL, " +
183
187
  "ending TEXT NOT NULL, " +
188
+ "separators TEXT, " +
184
189
  "hashes TEXT NOT NULL, " +
185
190
  "result_content TEXT NOT NULL, " +
191
+ "result_separators TEXT, " +
186
192
  "updated_at INTEGER NOT NULL" +
187
193
  ")"
188
194
  );
@@ -192,6 +198,12 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
192
198
  try {
193
199
  db.exec("ALTER TABLE undo ADD COLUMN mode INTEGER");
194
200
  } catch {}
201
+ try {
202
+ db.exec("ALTER TABLE undo ADD COLUMN separators TEXT");
203
+ } catch {}
204
+ try {
205
+ db.exec("ALTER TABLE undo ADD COLUMN result_separators TEXT");
206
+ } catch {}
195
207
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
196
208
  if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
197
209
  db.exec("DELETE FROM snapshots");
@@ -210,11 +222,11 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
210
222
  "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, line_checksums = excluded.line_checksums, updated_at = excluded.updated_at"
211
223
  );
212
224
  const undoUpsertStmt = db.prepare(
213
- "INSERT INTO undo (path, content, bom, ending, hashes, result_content, mode, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) " +
214
- "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, mode = excluded.mode, updated_at = excluded.updated_at"
225
+ "INSERT INTO undo (path, content, bom, ending, separators, hashes, result_content, result_separators, mode, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
226
+ "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, separators = excluded.separators, hashes = excluded.hashes, result_content = excluded.result_content, result_separators = excluded.result_separators, mode = excluded.mode, updated_at = excluded.updated_at"
215
227
  );
216
228
  const undoGetStmt = db.prepare(
217
- "SELECT content, bom, ending, hashes, result_content, mode FROM undo WHERE path = ?"
229
+ "SELECT content, bom, ending, separators, hashes, result_content, result_separators, mode FROM undo WHERE path = ?"
218
230
  );
219
231
  const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
220
232
  const snapshotTimeStmt = db.prepare("SELECT updated_at FROM snapshots WHERE path = ?");
@@ -543,8 +555,10 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
543
555
  entry.content,
544
556
  entry.bom,
545
557
  entry.ending,
558
+ entry.separators ? JSON.stringify(entry.separators) : null,
546
559
  JSON.stringify(entry.hashes),
547
560
  entry.resultContent,
561
+ entry.resultSeparators ? JSON.stringify(entry.resultSeparators) : null,
548
562
  typeof entry.mode === "number" ? entry.mode : null,
549
563
  Date.now(),
550
564
  );
@@ -560,12 +574,26 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
560
574
  store.stmts.undoDelete(path);
561
575
  return undefined;
562
576
  }
577
+ const separators = parseSeparators(row.separators);
578
+ const resultSeparators = parseSeparators(row.result_separators);
579
+ const resultContent = row.result_content as string;
580
+ if (
581
+ !separators.ok ||
582
+ !resultSeparators.ok ||
583
+ (separators.value !== undefined && separators.value.length !== countNewlines(content)) ||
584
+ (resultSeparators.value !== undefined && resultSeparators.value.length !== countNewlines(resultContent))
585
+ ) {
586
+ store.stmts.undoDelete(path);
587
+ return undefined;
588
+ }
563
589
  return {
564
590
  content,
565
591
  bom: row.bom as string,
566
592
  ending: row.ending as string,
593
+ ...(separators.value !== undefined ? { separators: separators.value } : {}),
567
594
  hashes: parsed,
568
- resultContent: row.result_content as string,
595
+ resultContent,
596
+ ...(resultSeparators.value !== undefined ? { resultSeparators: resultSeparators.value } : {}),
569
597
  ...(typeof row.mode === "number" ? { mode: row.mode } : {}),
570
598
  };
571
599
  }
@@ -11,6 +11,7 @@ import {
11
11
  type RHEdit,
12
12
  type NEdit,
13
13
  type HEdit,
14
+ type StripWarningLocation,
14
15
  } from "./resolve";
15
16
 
16
17
  type LIdx = {
@@ -160,6 +161,7 @@ export function planEdit(
160
161
  servedHashes?: ReadonlyMap<string, string>;
161
162
  signal?: AbortSignal;
162
163
  baseFileLines?: string[];
164
+ stripWarning?: StripWarningLocation;
163
165
  },
164
166
  ): PlannedEdit {
165
167
  const signal = options?.signal;
@@ -171,8 +173,9 @@ export function planEdit(
171
173
 
172
174
  const rangeFixed = swapReversedRanges(edit, fileHashes);
173
175
  const prefixFixed = stripDiffPrefixes(
174
- stripBarePrefixes(rangeFixed, warnings),
176
+ stripBarePrefixes(rangeFixed, warnings, options?.stripWarning),
175
177
  warnings,
178
+ options?.stripWarning,
176
179
  );
177
180
 
178
181
  const { resolved: initialResolved, mismatches } = valEdit(
@@ -213,6 +216,8 @@ export function applyEdit(
213
216
  precomputedHashes?: string[],
214
217
  filePath?: string,
215
218
  servedHashes?: ReadonlyMap<string, string>,
219
+ allowEmpty = false,
220
+ stripWarning?: StripWarningLocation,
216
221
  ): {
217
222
  content: string;
218
223
  firstChangedLine: number | undefined;
@@ -224,7 +229,7 @@ export function applyEdit(
224
229
  if (precomputedHashes === undefined) {
225
230
  throw new Error("[E_BAD_SHAPE] applyEdit requires the file's allocated anchors; derive them via lineHashes(content, path) first.");
226
231
  }
227
- const planned = planEdit(content, edit, precomputedHashes, { filePath, servedHashes, signal });
232
+ const planned = planEdit(content, edit, precomputedHashes, { filePath, servedHashes, signal, stripWarning });
228
233
  const lineIndex = buildIdx(content);
229
234
  const warnings = planned.warnings;
230
235
  const resolved = planned.resolved;
@@ -240,7 +245,7 @@ export function applyEdit(
240
245
  }
241
246
 
242
247
  const result = assemble(content, spanResult, signal);
243
- assertNotEmpty(content, result);
248
+ if (!allowEmpty) assertNotEmpty(content, result);
244
249
  const range = changedRange(content, result);
245
250
 
246
251
  return {
@@ -18,7 +18,9 @@ export {
18
18
  export {
19
19
  parseHashRef,
20
20
  parseText,
21
+ parseTextWithSeparators,
21
22
  type Anchor,
23
+ type ParsedText,
22
24
  } from "./parse";
23
25
 
24
26
  export {
@@ -32,6 +34,7 @@ export {
32
34
  valEdit,
33
35
  stripBarePrefixes,
34
36
  stripDiffPrefixes,
37
+ type StripWarningLocation,
35
38
  swapReversedRanges,
36
39
  assertRangeServed,
37
40
  RangeStaleError,
@@ -4,6 +4,8 @@ import {
4
4
  } from "./hash";
5
5
  import { ALPH_RE } from "./alphabet";
6
6
  import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
7
+ import { splitWithEndings } from "../line-endings";
8
+ import type { LineEnding } from "../normalize";
7
9
 
8
10
  const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
9
11
 
@@ -68,15 +70,25 @@ function unwrapJsonEnvelope(line: string): string {
68
70
  }
69
71
  }
70
72
 
73
+ export interface ParsedText {
74
+ lines: string[];
75
+ separators: (LineEnding | undefined)[];
76
+ }
77
+
78
+ export function parseTextWithSeparators(edit: string[]): ParsedText {
79
+ if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
80
+ throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
81
+ }
82
+ const lines: string[] = [];
83
+ const separators: (LineEnding | undefined)[] = [];
84
+ for (const line of edit) {
85
+ const parsed = splitWithEndings(unwrapJsonEnvelope(line));
86
+ lines.push(...parsed.lines);
87
+ separators.push(...parsed.endings);
88
+ }
89
+ return { lines, separators };
90
+ }
91
+
71
92
  export function parseText(edit: string[]): string[] {
72
- if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
73
- throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
74
- }
75
- const out: string[] = [];
76
- for (const line of edit) {
77
- const unwrapped = unwrapJsonEnvelope(line);
78
- const normalized = unwrapped.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
79
- out.push(...normalized.split("\n"));
80
- }
81
- return out;
93
+ return parseTextWithSeparators(edit).lines;
82
94
  }
@@ -1,15 +1,20 @@
1
1
  import { abortIf, rejectUnknownFields, clipLine, decodeStringArray, assertNoNul } from "../utils";
2
- import { parseHashRef, parseText, type Anchor } from "./parse";
2
+ import { parseHashRef, parseTextWithSeparators, type Anchor } from "./parse";
3
3
  import { HASH_SEP, stripRowPrefix, lineChecksum, type RowPrefixKind } from "./hash";
4
4
  import { HASH_RUN } from "./alphabet";
5
5
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
6
+ import type { LineEnding } from "../normalize";
6
7
 
7
8
  export type RAnchor = {
8
9
  line: number;
9
10
  hash: string;
10
11
  };
11
12
 
12
- export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
13
+ export type HEdit = {
14
+ content_lines: string[];
15
+ hash_bounds: [Anchor, Anchor];
16
+ content_separators?: (LineEnding | undefined)[];
17
+ };
13
18
  export type RHEdit = {
14
19
  content_lines: string[];
15
20
  hash_bounds: [RAnchor, RAnchor];
@@ -158,7 +163,8 @@ export function stripAnchorRow(
158
163
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
159
164
  assertItem(edit as Record<string, unknown>);
160
165
 
161
- const replaceLines = parseText(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines);
166
+ const parsed = parseTextWithSeparators(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines);
167
+ const replaceLines = parsed.lines;
162
168
  assertNoNul(replaceLines);
163
169
  const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
164
170
  return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
@@ -166,6 +172,7 @@ export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
166
172
  return {
167
173
  content_lines: replaceLines,
168
174
  hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
175
+ ...(parsed.separators.some((separator) => separator !== undefined) ? { content_separators: parsed.separators } : {}),
169
176
  };
170
177
  }
171
178
 
@@ -180,12 +187,18 @@ function warnUnicodeEsc(
180
187
  }
181
188
  }
182
189
 
190
+ export interface StripWarningLocation {
191
+ label: string;
192
+ indexOffset: number;
193
+ }
194
+
183
195
  function stripRowPrefixes(
184
196
  edit: HEdit,
185
197
  warnings: string[],
186
198
  kinds: RowPrefixKind[],
187
199
  code: string,
188
200
  marker: string,
201
+ location: StripWarningLocation,
189
202
  ): HEdit {
190
203
  const stripped: number[] = [];
191
204
  const contentLines = edit.content_lines.map((line, lineIndex) => {
@@ -195,17 +208,19 @@ function stripRowPrefixes(
195
208
  return result.text;
196
209
  });
197
210
  if (stripped.length === 0) return edit;
198
- const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
211
+ const locations = stripped.map((i) => `${location.label} line ${i + 1 + location.indexOffset}`).join(", ");
199
212
  warnings.push(`${code} Stripped ${marker} from ${locations}.`);
200
213
  return { ...edit, content_lines: contentLines };
201
214
  }
202
215
 
203
- export function stripBarePrefixes(edit: HEdit, warnings: string[]): HEdit {
204
- return stripRowPrefixes(edit, warnings, ["bare"], "[W_BARE_HASH_PREFIX]", '"anchor│" prefix');
216
+ const DEFAULT_STRIP_WARNING_LOCATION: StripWarningLocation = { label: "replacement_lines", indexOffset: 0 };
217
+
218
+ export function stripBarePrefixes(edit: HEdit, warnings: string[], location: StripWarningLocation = DEFAULT_STRIP_WARNING_LOCATION): HEdit {
219
+ return stripRowPrefixes(edit, warnings, ["bare"], "[W_BARE_HASH_PREFIX]", '"anchor│" prefix', location);
205
220
  }
206
221
 
207
- export function stripDiffPrefixes(edit: HEdit, warnings: string[]): HEdit {
208
- return stripRowPrefixes(edit, warnings, ["plus", "minus"], "[W_INVALID_PATCH]", "diff-preview marker");
222
+ export function stripDiffPrefixes(edit: HEdit, warnings: string[], location: StripWarningLocation = DEFAULT_STRIP_WARNING_LOCATION): HEdit {
223
+ return stripRowPrefixes(edit, warnings, ["plus", "minus"], "[W_INVALID_PATCH]", "diff-preview marker", location);
209
224
  }
210
225
 
211
226
  export function swapReversedRanges(
package/src/insert.ts CHANGED
@@ -5,12 +5,13 @@ import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, pre
5
5
  import { commitEdit } from "./commit";
6
6
  import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
7
7
  import { readNormFile, type NormFile } from "./file-reader";
8
- import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
8
+ import { MAX_HASH_LINES, parseHashRef, parseTextWithSeparators, resEdit, resolveAnchorLine, type Anchor, type HEdit, type StripWarningLocation } from "./hashline";
9
+ import type { LineEnding } from "./normalize";
9
10
  import { stripAnchorRow } from "./hashline/resolve";
10
11
  import { withAnchorSession } from "./anchor-registry";
11
12
  import { loadP, loadGuide } from "./prompts";
12
13
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
13
- import { decodeStringArray, isRec, splitLines } from "./utils";
14
+ import { decodeStringArray, isRec, literalEscapeWarning, splitLines } from "./utils";
14
15
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
15
16
  import type { RPreview, RRState } from "./replace-render";
16
17
  export { assertInsertReq, type InsertReq };
@@ -27,7 +28,7 @@ const insertDirectionSchema = Type.Union(
27
28
 
28
29
  const insertLinesSchema = Type.Array(
29
30
  Type.String({
30
- description: "One line to insert; never embed \\n inside an element.",
31
+ description: "One line to insert. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings; escape sequences are not decoded.",
31
32
  }),
32
33
  {
33
34
  description: 'One string per line; [""] is a blank line; never include the anchor line.',
@@ -73,7 +74,8 @@ export function buildInsertEdit(
73
74
  preload: NormFile,
74
75
  ref: Anchor,
75
76
  path: string,
76
- ): { editParams: ReqParams; anchorLine: string | undefined } {
77
+ ): { editParams: ReqParams; anchorLine: string | undefined; contentSeparators: (LineEnding | undefined)[] } {
78
+ const parsed = parseTextWithSeparators(req.lines);
77
79
  const fileLines = splitLines(preload.normalized);
78
80
  const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, path);
79
81
  const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
@@ -82,12 +84,21 @@ export function buildInsertEdit(
82
84
  remove_to: ref.hash,
83
85
  replacement_lines:
84
86
  anchorLine === undefined
85
- ? [...req.lines]
87
+ ? [...parsed.lines]
86
88
  : req.direction === "after"
87
- ? [anchorLine, ...req.lines]
88
- : [...req.lines, anchorLine],
89
+ ? [anchorLine, ...parsed.lines]
90
+ : [...parsed.lines, anchorLine],
89
91
  };
90
- return { editParams, anchorLine };
92
+ const contentSeparators = anchorLine === undefined
93
+ ? parsed.separators
94
+ : req.direction === "after"
95
+ ? [undefined, ...parsed.separators]
96
+ : [...parsed.separators, undefined];
97
+ return { editParams, anchorLine, contentSeparators };
98
+ }
99
+
100
+ function insertStripWarning(anchorLine: string | undefined, direction: "before" | "after"): StripWarningLocation {
101
+ return { label: "lines", indexOffset: anchorLine !== undefined && direction === "after" ? -1 : 0 };
91
102
  }
92
103
 
93
104
  export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
@@ -114,12 +125,13 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
114
125
  allocation: "shadow",
115
126
  signal,
116
127
  });
117
- const { editParams } = buildInsertEdit(normalized, preload, ref, targetPath);
128
+ const { editParams, anchorLine } = buildInsertEdit(normalized, preload, ref, targetPath);
118
129
  const pipe = await execPipeline(targetPath, editParams, cwd, {
119
130
  accessMode: constants.R_OK,
120
131
  noPersist: true,
121
132
  preloadedNorm: preload,
122
133
  signal,
134
+ stripWarning: insertStripWarning(anchorLine, normalized.direction),
123
135
  });
124
136
  return previewFromPipe(pipe);
125
137
  } catch (error: unknown) {
@@ -180,6 +192,8 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
180
192
  }
181
193
  assertInsertReq(canonical);
182
194
  const req = canonical;
195
+ const literalEscape = literalEscapeWarning(req.lines, "lines");
196
+ if (literalEscape !== undefined) insertWarnings.push(literalEscape);
183
197
  const targetPath = await resolveEditTargetWithRequirement({
184
198
  anchor: req.anchor,
185
199
  providedPath: req.path,
@@ -224,6 +238,8 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
224
238
  hedit,
225
239
  extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
226
240
  foldedLines: built.anchorLine === undefined ? 0 : 1,
241
+ stripWarning: insertStripWarning(built.anchorLine, req.direction),
242
+ contentSeparators: built.contentSeparators,
227
243
  });
228
244
  }
229
245
  const preload = await readNormFile(targetPath, ctx.cwd, {
@@ -231,11 +247,12 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
231
247
  accessMode: constants.R_OK | constants.W_OK,
232
248
  maxLines: MAX_HASH_LINES,
233
249
  });
234
- const { editParams, anchorLine } = buildInsertEdit(req, preload, ref, targetPath);
250
+ const { editParams, anchorLine, contentSeparators } = buildInsertEdit(req, preload, ref, targetPath);
235
251
  const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
236
252
  accessMode: constants.R_OK | constants.W_OK,
237
253
  signal,
238
254
  preloadedNorm: preload,
255
+ stripWarning: insertStripWarning(anchorLine, req.direction),
239
256
  });
240
257
  return commitEdit(pipe, {
241
258
  path: pipe.path,
@@ -250,6 +267,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
250
267
  noopNoun: "Insertion",
251
268
  foldedAnchorLines: anchorLine === undefined ? 0 : 1,
252
269
  prefixWarnings: [...anchorWarnings, ...insertWarnings],
270
+ endingOverrides: contentSeparators,
253
271
  });
254
272
  });
255
273
  });
@@ -0,0 +1,151 @@
1
+ import type { LineEnding } from "./normalize";
2
+
3
+ export interface EndingSpan {
4
+ start: number;
5
+ end: number;
6
+ replacementCount: number;
7
+ }
8
+
9
+ export function countNewlines(content: string): number {
10
+ let count = 0;
11
+ for (let index = 0; index < content.length; index++) {
12
+ if (content.charCodeAt(index) === 10) count += 1;
13
+ }
14
+ return count;
15
+ }
16
+
17
+ export function splitSeparators(raw: string): LineEnding[] {
18
+ const separators: LineEnding[] = [];
19
+ for (let index = 0; index < raw.length; index++) {
20
+ const char = raw[index]!;
21
+ if (char === "\r") {
22
+ if (raw[index + 1] === "\n") {
23
+ separators.push("\r\n");
24
+ index += 1;
25
+ } else {
26
+ separators.push("\r");
27
+ }
28
+ } else if (char === "\n") {
29
+ separators.push("\n");
30
+ }
31
+ }
32
+ return separators;
33
+ }
34
+
35
+ export function joinSeparators(content: string, separators: readonly LineEnding[]): string {
36
+ let out = "";
37
+ let separatorIndex = 0;
38
+ for (const char of content) {
39
+ if (char === "\n") {
40
+ out += separators[separatorIndex] ?? "\n";
41
+ separatorIndex += 1;
42
+ } else {
43
+ out += char;
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function sourceLineMap(baseLineCount: number, spans: readonly EndingSpan[]): number[] {
50
+ const sorted = [...spans].sort((left, right) => left.start - right.start);
51
+ const map: number[] = [];
52
+ let baseLine = 0;
53
+ for (const span of sorted) {
54
+ while (baseLine < span.start && baseLine < baseLineCount) {
55
+ map.push(baseLine);
56
+ baseLine += 1;
57
+ }
58
+ const spanLength = span.end >= span.start ? span.end - span.start + 1 : 0;
59
+ for (let offset = 0; offset < span.replacementCount; offset++) {
60
+ if (offset < spanLength) map.push(span.start + offset);
61
+ else map.push(spanLength > 0 ? span.end : span.start - 1);
62
+ }
63
+ baseLine = span.end >= span.start ? span.end + 1 : span.start;
64
+ }
65
+ while (baseLine < baseLineCount) {
66
+ map.push(baseLine);
67
+ baseLine += 1;
68
+ }
69
+ return map;
70
+ }
71
+
72
+ export function separatorsForSpans(
73
+ baseSeparators: readonly LineEnding[],
74
+ baseLineCount: number,
75
+ spans: readonly EndingSpan[],
76
+ resultContent: string,
77
+ fallback: LineEnding,
78
+ ): LineEnding[] {
79
+ const mapped = sourceLineMap(baseLineCount, spans).map((source) =>
80
+ source >= 0 && source < baseSeparators.length ? baseSeparators[source]! : "",
81
+ );
82
+ const needed = countNewlines(resultContent);
83
+ const result: LineEnding[] = [];
84
+ for (let index = 0; index < needed; index++) {
85
+ const candidate = mapped[index];
86
+ if (candidate !== undefined && candidate !== "") result.push(candidate);
87
+ else result.push(result[index - 1] ?? fallback);
88
+ }
89
+ return result;
90
+ }
91
+
92
+ export function endingsForRange(separators: readonly LineEnding[], startLine: number, endLine: number): (LineEnding | undefined)[] {
93
+ const endings: (LineEnding | undefined)[] = [];
94
+ for (let line = startLine; line <= endLine; line += 1) {
95
+ endings.push(separators[line - 1]);
96
+ }
97
+ return endings;
98
+ }
99
+
100
+ export function splitWithEndings(text: string): { lines: string[]; endings: (LineEnding | undefined)[] } {
101
+ const lines: string[] = [];
102
+ const endings: (LineEnding | undefined)[] = [];
103
+ let current = "";
104
+ for (let index = 0; index < text.length; index += 1) {
105
+ const char = text[index]!;
106
+ if (char === "\r") {
107
+ const crlf = text[index + 1] === "\n";
108
+ endings.push(crlf ? "\r\n" : "\r");
109
+ lines.push(current);
110
+ current = "";
111
+ if (crlf) index += 1;
112
+ continue;
113
+ }
114
+ if (char === "\n") {
115
+ endings.push("\n");
116
+ lines.push(current);
117
+ current = "";
118
+ continue;
119
+ }
120
+ current += char;
121
+ }
122
+ lines.push(current);
123
+ endings.push(undefined);
124
+ return { lines, endings };
125
+ }
126
+
127
+ export function applyEndingOverrides(separators: LineEnding[], spanStart: number, endings: readonly (LineEnding | undefined)[] | undefined): void {
128
+ if (endings === undefined) return;
129
+ for (let index = 0; index < endings.length; index += 1) {
130
+ const ending = endings[index];
131
+ if (ending === undefined) continue;
132
+ const target = spanStart + index;
133
+ if (target >= 0 && target < separators.length) separators[target] = ending;
134
+ }
135
+ }
136
+
137
+ export function applySpanEndings(separators: LineEnding[], spans: readonly { start: number; end: number; replacementCount: number; endings?: (LineEnding | undefined)[] }[]): void {
138
+ const sorted = [...spans].sort((left, right) => left.start - right.start);
139
+ let offset = 0;
140
+ for (const span of sorted) {
141
+ if (span.endings !== undefined) {
142
+ for (let index = 0; index < span.endings.length; index += 1) {
143
+ const ending = span.endings[index];
144
+ if (ending === undefined) continue;
145
+ const target = span.start + offset + index;
146
+ if (target >= 0 && target < separators.length) separators[target] = ending;
147
+ }
148
+ }
149
+ offset += span.replacementCount - (span.end - span.start + 1);
150
+ }
151
+ }