pi-hashline-edit-pro 6.4.7 → 6.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.
package/src/insert.ts CHANGED
@@ -12,7 +12,7 @@ import { stripAnchorRow } from "./hashline/resolve";
12
12
  import { withAnchorSession } from "./anchor-registry";
13
13
  import { loadP, loadGuidelines } from "./prompts";
14
14
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
15
- import { isRec, literalEscapeHints, splitLines } from "./utils";
15
+ import { anchorCarryIndex, isRec, literalEscapeHints, splitLines } from "./utils";
16
16
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
17
17
  import type { RPreview, RRState } from "./replace-render";
18
18
  import { editResultSchema, withStructuredErrors } from "./structured";
@@ -98,6 +98,11 @@ function insertStripWarning(anchorLine: string | undefined, direction: "before"
98
98
  return { label: "text", indexOffset: anchorLine !== undefined && direction === "after" ? -1 : 0 };
99
99
  }
100
100
 
101
+ function insertAnchorCarry(direction: "before" | "after", editParams: HTEdit, anchorLine: string | undefined): number | undefined {
102
+ if (anchorLine === undefined) return undefined;
103
+ return anchorCarryIndex(direction, editParams.text.length - 1);
104
+ }
105
+
101
106
  function planInsertBatchMember(input: BatchPlannerInput): BatchMemberPlan {
102
107
  const req = normReq(input.member.args);
103
108
  assertInsertReq(req);
@@ -149,6 +154,7 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
149
154
  signal,
150
155
  });
151
156
  const { editParams, anchorLine, contentSeparators } = buildInsertEdit(normalized, preload, ref, targetPath);
157
+ const anchorCarry = insertAnchorCarry(normalized.direction, editParams, anchorLine);
152
158
  const pipe = await execPipeline(targetPath, editParams, cwd, {
153
159
  accessMode: constants.R_OK,
154
160
  noPersist: true,
@@ -156,6 +162,7 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
156
162
  signal,
157
163
  stripWarning: insertStripWarning(anchorLine, normalized.direction),
158
164
  endingOverrides: contentSeparators,
165
+ ...(anchorCarry !== undefined ? { anchorCarry } : {}),
159
166
  });
160
167
  return previewFromPipe(pipe);
161
168
  } catch (error: unknown) {
@@ -241,21 +248,21 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
241
248
  maxLines: MAX_HASH_LINES,
242
249
  });
243
250
  const { editParams, anchorLine, contentSeparators } = buildInsertEdit(req, preload, ref, targetPath);
251
+ const anchorCarry = insertAnchorCarry(req.direction, editParams, anchorLine);
244
252
  const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
245
253
  accessMode: constants.R_OK | constants.W_OK,
246
254
  signal,
247
255
  preloadedNorm: preload,
248
256
  stripWarning: insertStripWarning(anchorLine, req.direction),
249
257
  endingOverrides: contentSeparators,
258
+ ...(anchorCarry !== undefined ? { anchorCarry } : {}),
250
259
  });
251
260
  return commitEdit(pipe, {
252
261
  path: pipe.path,
253
262
  absolutePath,
254
263
  mutationTargetPath,
255
264
  editAnchors: [editParams.remove_from, editParams.remove_to],
256
- ...(anchorLine === undefined
257
- ? {}
258
- : { anchorCarry: req.direction === "after" ? 0 : editParams.text.length - 1 }),
265
+ ...(anchorCarry !== undefined ? { anchorCarry } : {}),
259
266
  signal,
260
267
  verb: "inserted",
261
268
  noopNoun: "Insertion",
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./utils";
3
3
  import { TEXT_ENDING_NOTE, TEXT_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
4
+ import { toLF } from "./normalize";
4
5
 
5
6
  const textSchema = Type.String({
6
7
  description:
@@ -166,25 +167,44 @@ export function assertTransferReq(request: unknown): asserts request is Transfer
166
167
  }
167
168
  }
168
169
 
169
- const MATCH_KS = new Set(["path", "replace_from", "replace_to", "old_string", "new_string"]);
170
+ const MATCH_KS = new Set(["path", "replace_from", "replace_to", "match", "replacement"]);
170
171
 
171
172
  export interface ReplaceMatchReq {
172
173
  path?: string;
173
174
  replace_from: string;
174
175
  replace_to: string;
175
- old_string: string;
176
- new_string: string;
176
+ match: string;
177
+ replacement: string;
178
+ }
179
+
180
+ export function singleLineFragment(field: "match" | "replacement", raw: string): string {
181
+ const text = toLF(raw);
182
+ const fragment = text.endsWith("\n") ? text.slice(0, -1) : text;
183
+ const lineBreak = fragment.indexOf("\n");
184
+ if (lineBreak >= 0) {
185
+ const column = [...fragment.slice(0, lineBreak)].length + 1;
186
+ throw new Error(
187
+ `[E_BAD_SHAPE] ${field} must stay within one line, but the value contains a line break at column ${column}. A trailing line break is accepted as the line's own terminator; text on another line belongs to replace.`,
188
+ );
189
+ }
190
+ if (field === "match" && fragment.length === 0) {
191
+ throw new Error('[E_BAD_SHAPE] Replace-match request field "match" must be a non-empty string holding the exact text to find.');
192
+ }
193
+ return fragment;
177
194
  }
178
195
 
179
196
  export function assertReplaceMatchReq(request: unknown): asserts request is ReplaceMatchReq {
180
197
  if (!isRec(request)) {
181
198
  throw new Error("[E_BAD_SHAPE] Replace-match request must be an object.");
182
199
  }
200
+ if ("old_string" in request || "new_string" in request) {
201
+ throw new Error('[E_BAD_SHAPE] Replace-match request no longer accepts "old_string"/"new_string"; use "match" for the text to find and "replacement" for the new text.');
202
+ }
183
203
  rejectUnknownFields(request, MATCH_KS, "Replace-match request");
184
204
  if (request.path !== undefined && typeof request.path !== "string") {
185
205
  throw new Error('[E_BAD_SHAPE] Replace-match request field "path" must be a string when provided.');
186
206
  }
187
- for (const key of ["replace_from", "replace_to", "old_string", "new_string"] as const) {
207
+ for (const key of ["replace_from", "replace_to", "match", "replacement"] as const) {
188
208
  if (typeof (request as Record<string, unknown>)[key] !== "string") {
189
209
  throw new Error(`[E_BAD_SHAPE] Replace-match request requires a "${key}" string.`);
190
210
  }
@@ -193,13 +213,12 @@ export function assertReplaceMatchReq(request: unknown): asserts request is Repl
193
213
  if (within.replace_from.length === 0 || within.replace_to.length === 0) {
194
214
  throw new Error('[E_BAD_SHAPE] Replace-match request requires non-empty "replace_from" and "replace_to" anchors.');
195
215
  }
196
- if (within.old_string.length === 0) {
197
- throw new Error('[E_BAD_SHAPE] Replace-match request field "old_string" must be a non-empty string holding the exact text to find.');
198
- }
199
- assertNoNul([within.new_string]);
216
+ singleLineFragment("match", within.match);
217
+ singleLineFragment("replacement", within.replacement);
218
+ assertNoNul([within.replacement]);
200
219
  }
201
220
 
202
- export function getReplaceMatchInput(args: unknown): { path?: string; replace_from: string; replace_to: string; old_string: string; new_string: string } | null {
221
+ export function getReplaceMatchInput(args: unknown): { path?: string; replace_from: string; replace_to: string; match: string; replacement: string } | null {
203
222
  let normalized: unknown;
204
223
  try {
205
224
  normalized = normalizeRequest(args, "replace");
@@ -210,8 +229,8 @@ export function getReplaceMatchInput(args: unknown): { path?: string; replace_fr
210
229
  if (
211
230
  typeof normalized.replace_from !== "string" ||
212
231
  typeof normalized.replace_to !== "string" ||
213
- typeof normalized.old_string !== "string" ||
214
- typeof normalized.new_string !== "string"
232
+ typeof normalized.match !== "string" ||
233
+ typeof normalized.replacement !== "string"
215
234
  ) {
216
235
  return null;
217
236
  }
@@ -219,8 +238,8 @@ export function getReplaceMatchInput(args: unknown): { path?: string; replace_fr
219
238
  ...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
220
239
  replace_from: normalized.replace_from,
221
240
  replace_to: normalized.replace_to,
222
- old_string: normalized.old_string,
223
- new_string: normalized.new_string,
241
+ match: normalized.match,
242
+ replacement: normalized.replacement,
224
243
  };
225
244
  }
226
245
 
@@ -230,15 +249,15 @@ const replaceMatchFromSchema = Type.String({
230
249
  });
231
250
  const replaceMatchToSchema = Type.String({
232
251
  description:
233
- "4-char anchor of the LAST line of the range searched; same as replace_from for a single line.",
252
+ "4-char anchor of the LAST line of the range searched; same as `replace_from` for a single line.",
234
253
  });
235
- const replaceMatchOldStringSchema = Type.String({
254
+ const replaceMatchMatchSchema = Type.String({
236
255
  description:
237
- "The exact text to find inside the selected line(s), copied from the served row: only the fragment that changes, never text that `new_string` leaves unchanged. If `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line match: use `replace` instead. Every occurrence inside the range is replaced; the text around each match is left untouched. Matching uses LF line breaks and excludes the last line's terminator.",
256
+ "The exact text to find inside the selected lines, copied from the served row: only the fragment that changes, never text that `replacement` leaves unchanged. It must stay within one line and hold text: a single trailing line break is accepted as the line's own terminator, a line break anywhere else is refused, and a value that is only a line break leaves nothing to find and is refused. Every occurrence inside the range is replaced; the text around each match is left untouched. If `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line match: use `replace` instead.",
238
257
  });
239
- const replaceMatchNewStringSchema = Type.String({
258
+ const replaceMatchReplacementSchema = Type.String({
240
259
  description:
241
- `The exact replacement for every matched occurrence. "" deletes the matches. ${TEXT_ENDING_NOTE} The rest of the range is kept byte-for-byte.`,
260
+ `The exact replacement for every matched occurrence within the range. "" deletes the matches. It must stay within one line: a single trailing line break is accepted and dropped (a value that is only a line break counts as ""), and a line break anywhere else is refused. The rest of each line is kept byte-for-byte.`,
242
261
  });
243
262
  const replaceMatchPathRequiredSchema = Type.String({
244
263
  description:
@@ -249,8 +268,8 @@ export const replaceMatchToolSchema = Type.Object(
249
268
  {
250
269
  replace_from: replaceMatchFromSchema,
251
270
  replace_to: replaceMatchToSchema,
252
- old_string: replaceMatchOldStringSchema,
253
- new_string: replaceMatchNewStringSchema,
271
+ match: replaceMatchMatchSchema,
272
+ replacement: replaceMatchReplacementSchema,
254
273
  },
255
274
  { additionalProperties: true },
256
275
  );
@@ -262,8 +281,8 @@ export function buildReplaceMatchToolSchema(requirePath: boolean): typeof replac
262
281
  path: replaceMatchPathRequiredSchema,
263
282
  replace_from: replaceMatchFromSchema,
264
283
  replace_to: replaceMatchToSchema,
265
- old_string: replaceMatchOldStringSchema,
266
- new_string: replaceMatchNewStringSchema,
284
+ match: replaceMatchMatchSchema,
285
+ replacement: replaceMatchReplacementSchema,
267
286
  },
268
287
  { additionalProperties: true },
269
288
  ) as typeof replaceMatchToolSchema;
package/src/read.ts CHANGED
@@ -23,7 +23,7 @@ import { withAnchorSession, adoptAnchors, ownerOf, ownersDifferingOnlyByCase, fo
23
23
  import { serveRows } from "./served";
24
24
  import { snapshotCache } from "./hash-store/cache";
25
25
  import { Text } from "@earendil-works/pi-tui";
26
- import { anchoredLine, readResultSchema, withStructuredErrors, type AnchoredLine, type ReadResult } from "./structured";
26
+ import { anchoredLine, readResultSchema, withStructuredErrors, type AnchoredLine, type ReadResult, type ReadTextResult } from "./structured";
27
27
  const R_DESC = loadP("../tool-prompts/read.md");
28
28
  const R_SNIPPET = loadP("../tool-prompts/read-snippet.md");
29
29
  function readGuide(): Guideline[] {
@@ -241,7 +241,6 @@ function anchorRefOf(args: unknown): { anchor: string; limit: number | undefined
241
241
  const { offset, limit, path } = args;
242
242
  if (typeof offset !== "string" || offset.length === 0 || /^\d+$/.test(offset)) return undefined;
243
243
  const validLimit = typeof limit === "number" && Number.isInteger(limit) && limit >= 1 ? limit : undefined;
244
- if (limit !== undefined && validLimit === undefined) return undefined;
245
244
  return { anchor: offset, limit: validLimit, path: typeof path === "string" ? path : undefined };
246
245
  }
247
246
 
@@ -263,28 +262,79 @@ function anchorLineOf(anchor: string, path: string | undefined, cwd: unknown): n
263
262
  return index < 0 ? undefined : index + 1;
264
263
  }
265
264
 
265
+ const ANSI_ESC = String.fromCharCode(27);
266
+ const CALL_ANSI_RE = new RegExp(`${ANSI_ESC}\\[[0-9;]*m`, "g");
267
+ const CALL_ANSI_AT_RE = new RegExp(`^${ANSI_ESC}\\[[0-9;]*m`);
268
+
269
+ function rawIndexForPlainIndex(text: string, plainIndex: number): number {
270
+ let raw = 0;
271
+ let plain = 0;
272
+ while (raw < text.length && plain < plainIndex) {
273
+ const escape = CALL_ANSI_AT_RE.exec(text.slice(raw));
274
+ if (escape !== null) {
275
+ raw += escape[0].length;
276
+ continue;
277
+ }
278
+ raw += 1;
279
+ plain += 1;
280
+ }
281
+ return raw;
282
+ }
283
+
284
+ export function insertBeforeExpandHint(text: string, suffix: string): string {
285
+ const plain = text.replace(CALL_ANSI_RE, "");
286
+ if (!plain.endsWith(" to expand)")) return `${text}${suffix}`;
287
+ const plainIndex = plain.lastIndexOf(" (");
288
+ const insertAt = plainIndex < 0 ? text.length : rawIndexForPlainIndex(text, plainIndex);
289
+ return `${text.slice(0, insertAt)}${suffix}${text.slice(insertAt)}`;
290
+ }
291
+
292
+ function anchorRangeLabel(ref: { anchor: string; limit: number | undefined }, line: number | undefined): string {
293
+ if (line === undefined) return ref.limit === undefined ? `:${ref.anchor}` : `:${ref.anchor} +${ref.limit}`;
294
+ return ref.limit === undefined ? `:${line} (${ref.anchor})` : `:${line}-${line + ref.limit - 1} (${ref.anchor})`;
295
+ }
296
+
266
297
  function renderReadCall(args: unknown, theme: any, context: any): Text {
267
298
  const normalized = normalizeReadCallArgs(args);
268
- const rendered = builtinReadRenderCall(normalized, theme, context);
269
299
  const ref = anchorRefOf(normalized);
270
- if (ref === undefined) return rendered;
271
- const marker = `:${ref.anchor}`;
272
- const text = (rendered as unknown as { text: string }).text;
273
- const index = text.lastIndexOf(marker);
274
- if (index < 0) return rendered;
300
+ if (ref === undefined) return builtinReadRenderCall(normalized, theme, context);
301
+ const rendered = builtinReadRenderCall(isRec(normalized) ? { ...normalized, offset: undefined, limit: undefined } : normalized, theme, context);
275
302
  const line = anchorLineOf(ref.anchor, ref.path, context?.cwd);
276
- if (line === undefined) {
277
- if (ref.limit === undefined) return rendered;
278
- const suffix = theme.fg("warning", ` +${ref.limit}`);
279
- rendered.setText(`${text.slice(0, index + marker.length)}${suffix}${text.slice(index + marker.length)}`);
280
- return rendered;
281
- }
282
- const range = ref.limit === undefined ? `${line}` : `${line}-${line + ref.limit - 1}`;
283
- const replacement = theme.fg("warning", `:${range} (${ref.anchor})`);
284
- rendered.setText(`${text.slice(0, index)}${replacement}${text.slice(index + marker.length)}`);
303
+ const suffix = theme.fg("warning", anchorRangeLabel(ref, line));
304
+ const text = (rendered as unknown as { text: string }).text;
305
+ rendered.setText(insertBeforeExpandHint(text, suffix));
285
306
  return rendered;
286
307
  }
287
308
 
309
+ function buildReadToolSchema(codemode: boolean) {
310
+ return Type.Object({
311
+ path: Type.String({
312
+ description: "Path to the file to read (relative or absolute)",
313
+ }),
314
+ offset: Type.Optional(
315
+ Type.Union([
316
+ Type.Integer({ minimum: 1 }),
317
+ Type.String(),
318
+ ], { description: "Line number (1-indexed) or a served anchor to start reading from" }),
319
+ ),
320
+ limit: Type.Optional(
321
+ Type.Integer({
322
+ minimum: 1,
323
+ description: "Maximum number of lines to read from offset",
324
+ }),
325
+ ),
326
+ ...(codemode
327
+ ? {
328
+ anchors: Type.Optional(
329
+ Type.Boolean({
330
+ description: "Return { text, lines, notices } with editable anchors instead of the plain file text to a codemode script",
331
+ }),
332
+ ),
333
+ }
334
+ : {}),
335
+ });
336
+ }
337
+
288
338
  export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
289
339
  const prompted = withReadPrompts({ description: R_DESC, snippet: R_SNIPPET, guidelines: readGuide() }, flags);
290
340
  pi.registerTool({
@@ -294,23 +344,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
294
344
  promptSnippet: prompted.snippet,
295
345
  promptGuidelines: prompted.guidelines,
296
346
  prepareArguments: makePrepareArguments(),
297
- parameters: Type.Object({
298
- path: Type.String({
299
- description: "Path to the file to read (relative or absolute)",
300
- }),
301
- offset: Type.Optional(
302
- Type.Union([
303
- Type.Integer({ minimum: 1 }),
304
- Type.String(),
305
- ], { description: "Line number (1-indexed) or a served anchor to start reading from" }),
306
- ),
307
- limit: Type.Optional(
308
- Type.Integer({
309
- minimum: 1,
310
- description: "Maximum number of lines to read from offset",
311
- }),
312
- ),
313
- }),
347
+ parameters: buildReadToolSchema(flags.codemode),
314
348
  outputSchema: readResultSchema,
315
349
  executionMode: "sequential",
316
350
  renderCall: renderReadCall,
@@ -370,7 +404,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
370
404
  const servedRawText = preview.anchoredLines.map((row) => fileLines[row.line - 1] ?? "").join("\n");
371
405
  const rawTruncation = servedRawText.length > 0 ? truncateHead(servedRawText, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES }) : undefined;
372
406
 
373
- const structuredContent: ReadResult = {
407
+ const readResult: ReadTextResult = {
374
408
  ok: true,
375
409
  kind: "read",
376
410
  path: rawPath,
@@ -384,6 +418,8 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
384
418
  hadUtf8DecodeErrors,
385
419
  notices,
386
420
  };
421
+ const anchorsRequested = isRec(params) && params.anchors === true;
422
+ const structuredContent: ReadResult = flags.codemode && !anchorsRequested ? readResult.text : readResult;
387
423
  return {
388
424
  content: [{ type: "text", text: previewText }],
389
425
  details: {
@@ -159,6 +159,40 @@ function placeSpans(
159
159
  return placed;
160
160
  }
161
161
 
162
+ function splitPlacedSpan(
163
+ span: PlacedSpan,
164
+ oldLines: string[],
165
+ newLines: string[],
166
+ oldHashes: string[],
167
+ newIndexByHash: ReadonlyMap<string, number>,
168
+ ): PlacedSpan[] {
169
+ if (span.oldStart > span.oldEnd || span.newStart > span.newEnd) return [span];
170
+ const pairs: Array<[number, number]> = [];
171
+ let lastNew = span.newStart - 1;
172
+ for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd; oldIdx++) {
173
+ const newIdx = newIndexByHash.get(oldHashes[oldIdx]!);
174
+ if (newIdx === undefined || newIdx < span.newStart || newIdx > span.newEnd || newIdx <= lastNew) continue;
175
+ if (oldLines[oldIdx] !== newLines[newIdx]) continue;
176
+ pairs.push([oldIdx, newIdx]);
177
+ lastNew = newIdx;
178
+ }
179
+ if (pairs.length === 0) return [span];
180
+ const out: PlacedSpan[] = [];
181
+ let oldCursor = span.oldStart;
182
+ let newCursor = span.newStart;
183
+ for (const [oldIdx, newIdx] of pairs) {
184
+ if (oldCursor <= oldIdx - 1 || newCursor <= newIdx - 1) {
185
+ out.push({ oldStart: oldCursor, oldEnd: oldIdx - 1, newStart: newCursor, newEnd: newIdx - 1 });
186
+ }
187
+ oldCursor = oldIdx + 1;
188
+ newCursor = newIdx + 1;
189
+ }
190
+ if (oldCursor <= span.oldEnd || newCursor <= span.newEnd) {
191
+ out.push({ oldStart: oldCursor, oldEnd: span.oldEnd, newStart: newCursor, newEnd: span.newEnd });
192
+ }
193
+ return out;
194
+ }
195
+
162
196
  function trimPlacedSpans(
163
197
  placed: PlacedSpan[],
164
198
  oldLines: string[],
@@ -166,22 +200,11 @@ function trimPlacedSpans(
166
200
  oldHashes: string[],
167
201
  newHashes: string[],
168
202
  ): PlacedSpan[] {
203
+ const newIndexByHash = new Map<string, number>();
204
+ for (let index = 0; index < newHashes.length; index++) newIndexByHash.set(newHashes[index]!, index);
169
205
  const out: PlacedSpan[] = [];
170
206
  for (const span of placed) {
171
- let oldStart = span.oldStart;
172
- let oldEnd = span.oldEnd;
173
- let newStart = span.newStart;
174
- let newEnd = span.newEnd;
175
- while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart] && oldHashes[oldStart] === newHashes[newStart]) {
176
- oldStart += 1;
177
- newStart += 1;
178
- }
179
- while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd] && oldHashes[oldEnd] === newHashes[newEnd]) {
180
- oldEnd -= 1;
181
- newEnd -= 1;
182
- }
183
- if (oldStart > oldEnd && newStart > newEnd) continue;
184
- out.push({ oldStart, oldEnd, newStart, newEnd });
207
+ for (const piece of splitPlacedSpan(span, oldLines, newLines, oldHashes, newIndexByHash)) out.push(piece);
185
208
  }
186
209
  return out;
187
210
  }
@@ -364,7 +387,7 @@ export function genDiff(
364
387
  ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
365
388
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
366
389
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
367
- if (spans && newContentHashes && oldContentHashes) {
390
+ if (spans && spans.length > 0 && newContentHashes && oldContentHashes) {
368
391
  const anchored = genSpanDiff(oldContent, newContent, contextLines, newContentHashes, oldContentHashes, maxLineBytes, maxBytes, spans);
369
392
  if (anchored) {
370
393
  const diff = disambiguateDuplicateAnchors(anchored.diff);