pi-hashline-edit-pro 6.2.0 → 6.3.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/copy-move.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  lineHashes,
10
10
  MAX_HASH_LINES,
11
11
  parseHashRef,
12
+ planEdit,
13
+ preserveDeletionSeparators,
12
14
  resEdit,
13
15
  resolveAnchorLine,
14
16
  stripAnchorRow,
@@ -17,7 +19,7 @@ import {
17
19
  type HTEdit,
18
20
  } from "./hashline";
19
21
  import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths, withAnchorSession } from "./anchor-registry";
20
- import { batchMemberFor, batchServedFor, ensureBatchBase, executeBatchMember, noteBatchFailure, pendingBatchMemberFor, type BatchBase, type PlannedMember } from "./batch";
22
+ import { batchMemberFor, batchServedFor, ensureBatchBase, executeBatchMember, noteBatchFailure, pendingBatchMemberFor, registerBatchPlanner, type BatchBase, type BatchMemberPlanner, type PlannedMember } from "./batch";
21
23
  import { loadP, loadGuide } from "./prompts";
22
24
  import { assertTransferReq, normReq, type TransferReq } from "./payload-contract";
23
25
  import { abortIf, assertByteLimit, isRec, splitLines } from "./utils";
@@ -332,13 +334,13 @@ async function commitMovePair(input: {
332
334
  const destinationSpan = input.destination.pipe.spans?.[0];
333
335
  const sourceSpan = input.source.pipe.spans?.[0];
334
336
  const destinationSeparators = destinationSpan
335
- ? separatorsForSpans(input.destination.pipe.originalSeparators, input.destination.pipe.originalHashes.length, [destinationSpan], input.destination.pipe.result, input.destination.pipe.originalEnding)
337
+ ? separatorsForSpans(input.destination.pipe.originalSeparators, input.destination.pipe.originalHashes.length, [destinationSpan], input.destination.pipe.result, input.destination.pipe.originalEnding, splitLines(input.destination.pipe.originalNormalized))
336
338
  : undefined;
337
339
  if (destinationSeparators !== undefined && destinationSpan !== undefined) {
338
340
  applyEndingOverrides(destinationSeparators, destinationSpan.start, input.destination.endingOverrides);
339
341
  }
340
342
  const sourceSeparators = sourceSpan
341
- ? separatorsForSpans(input.source.pipe.originalSeparators, input.source.pipe.originalHashes.length, [sourceSpan], input.source.pipe.result, input.source.pipe.originalEnding)
343
+ ? separatorsForSpans(input.source.pipe.originalSeparators, input.source.pipe.originalHashes.length, [sourceSpan], input.source.pipe.result, input.source.pipe.originalEnding, splitLines(input.source.pipe.originalNormalized))
342
344
  : undefined;
343
345
  const destinationBytes = input.destination.pipe.bom + (destinationSeparators !== undefined
344
346
  ? joinSeparators(input.destination.pipe.result, destinationSeparators)
@@ -600,50 +602,38 @@ function preloadFromBase(base: BatchBase): NormFile {
600
602
  };
601
603
  }
602
604
 
603
- async function executeBatchTransfer(
604
- kind: TransferKind,
605
- member: PlannedMember,
606
- refs: TransferRefs,
607
- warnings: string[],
608
- targetPath: string,
609
- mutationTargetPath: string,
610
- cwd: string,
611
- signal?: AbortSignal,
612
- ): Promise<TResult> {
613
- const base = await ensureBatchBase({ member, targetPath, mutationTargetPath, cwd, signal });
614
- const displayPath = toDisplayPath(cwd, base.absolutePath, targetPath);
615
- let plan: TransferPlan;
616
- try {
617
- plan = buildTransferEdit({ kind, refs, preload: preloadFromBase(base), displayPath, served: batchServedFor(member) });
618
- } catch (error) {
619
- await noteAnchorError(base.absolutePath, error);
620
- noteBatchFailure(member, error);
621
- throw error;
622
- }
623
- const resWarnings: string[] = [];
624
- let hedit: HEdit;
625
- try {
626
- hedit = resEdit(plan.editParams, resWarnings);
627
- } catch (error) {
628
- noteBatchFailure(member, error);
629
- throw error;
630
- }
631
- return executeBatchMember({
632
- kind,
633
- member,
634
- targetPath,
635
- mutationTargetPath,
636
- cwd,
637
- signal,
638
- hedit,
639
- extraWarnings: [...warnings, ...resWarnings],
640
- foldedLines: plan.foldedAnchorLines,
641
- ...(plan.anchorCarry !== undefined ? { carryIndex: plan.anchorCarry } : {}),
642
- ...(plan.servedOverride !== undefined ? { servedOverride: plan.servedOverride } : {}),
643
- ...(plan.endingOverrides !== undefined ? { contentSeparators: plan.endingOverrides } : {}),
644
- });
605
+ function planTransferBatchMember(kind: TransferKind): BatchMemberPlanner {
606
+ return (input) => {
607
+ if (input.member.sourceTarget !== undefined && input.member.sourceTarget !== input.member.target) return undefined;
608
+ const req = normReq(input.member.args);
609
+ assertTransferReq(req);
610
+ const { refs, warnings } = parseTransferAnchors(req);
611
+ const preload = preloadFromBase(input.base);
612
+ const transferPlan = buildTransferEdit({ kind, refs, preload, displayPath: input.displayPath, served: input.served });
613
+ const resWarnings: string[] = [];
614
+ const hedit = resEdit(transferPlan.editParams, resWarnings);
615
+ const effectiveHedit = preserveDeletionSeparators(hedit, input.base.baseLines, input.base.hashes);
616
+ const planned = planEdit(input.base.content, effectiveHedit, input.base.hashes, {
617
+ filePath: input.displayPath,
618
+ servedHashes: transferPlan.servedOverride ?? input.served,
619
+ signal: input.signal,
620
+ baseFileLines: input.base.baseLines,
621
+ });
622
+ return {
623
+ hedit,
624
+ warnings: [...warnings, ...resWarnings],
625
+ planned,
626
+ foldedLines: transferPlan.foldedAnchorLines,
627
+ ...(transferPlan.anchorCarry !== undefined ? { carryIndex: transferPlan.anchorCarry } : {}),
628
+ ...(transferPlan.servedOverride !== undefined ? { servedOverride: transferPlan.servedOverride } : {}),
629
+ ...(transferPlan.endingOverrides !== undefined ? { contentSeparators: transferPlan.endingOverrides } : {}),
630
+ };
631
+ };
645
632
  }
646
633
 
634
+ registerBatchPlanner("copy", planTransferBatchMember("copy"));
635
+ registerBatchPlanner("move", planTransferBatchMember("move"));
636
+
647
637
  async function executeBatchCrossCopy(
648
638
  member: PlannedMember,
649
639
  refs: TransferRefs,
@@ -687,17 +677,18 @@ async function executeBatchCrossCopy(
687
677
  throw error;
688
678
  }
689
679
  return executeBatchMember({
690
- kind: "copy",
691
680
  member,
692
681
  targetPath: destinationPath,
693
682
  mutationTargetPath,
694
683
  cwd,
695
684
  signal,
696
- hedit,
697
- extraWarnings: [...warnings, ...resWarnings],
698
- foldedLines: prepared.destinationFolded,
699
- ...(prepared.destinationFolded === 1 ? { carryIndex: 0 } : {}),
700
- contentSeparators: prepared.endingOverrides,
685
+ plan: {
686
+ hedit,
687
+ warnings: [...warnings, ...resWarnings],
688
+ foldedLines: prepared.destinationFolded,
689
+ ...(prepared.destinationFolded === 1 ? { carryIndex: 0 } : {}),
690
+ contentSeparators: prepared.endingOverrides,
691
+ },
701
692
  });
702
693
  }
703
694
 
@@ -751,21 +742,22 @@ async function executeBatchCrossMove(
751
742
  ...(sourcePipe.hadUtf8DecodeErrors ? ["Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8."] : []),
752
743
  ];
753
744
  return executeBatchMember({
754
- kind: "move",
755
745
  member,
756
746
  targetPath: destinationPath,
757
747
  mutationTargetPath,
758
748
  cwd,
759
749
  signal,
760
- hedit,
761
- extraWarnings: [...warnings, ...resWarnings, ...sourceWarnings],
762
- foldedLines: prepared.destinationFolded,
763
- ...(prepared.destinationFolded === 1 ? { carryIndex: 0 } : {}),
764
- contentSeparators: prepared.endingOverrides,
765
- sourceMove: {
766
- displayPath: prepared.sourceDisplay,
767
- mutationTargetPath: prepared.sourcePreload.absolutePath,
768
- pipe: sourcePipe,
750
+ plan: {
751
+ hedit,
752
+ warnings: [...warnings, ...resWarnings, ...sourceWarnings],
753
+ foldedLines: prepared.destinationFolded,
754
+ ...(prepared.destinationFolded === 1 ? { carryIndex: 0 } : {}),
755
+ contentSeparators: prepared.endingOverrides,
756
+ sourceMove: {
757
+ displayPath: prepared.sourceDisplay,
758
+ mutationTargetPath: prepared.sourcePreload.absolutePath,
759
+ pipe: sourcePipe,
760
+ },
769
761
  },
770
762
  });
771
763
  }
@@ -932,20 +924,20 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
932
924
  const canonical = normReq(params);
933
925
  assertTransferReq(canonical);
934
926
  const req = canonical;
927
+ const member = batchMemberFor(_toolCallId);
935
928
  let refs: TransferRefs;
936
929
  let warnings: string[];
937
930
  try {
938
931
  ({ refs, warnings } = parseTransferAnchors(req));
939
- await throwIfStrictInput(warnings);
932
+ const sameFileMember = member !== undefined && (member.sourceTarget === undefined || member.sourceTarget === member.target);
933
+ if (!sameFileMember) await throwIfStrictInput(warnings);
940
934
  } catch (error) {
941
- const member = batchMemberFor(_toolCallId);
942
935
  if (member) noteBatchFailure(member, error);
943
936
  throw error;
944
937
  }
945
938
  let sourcePath: string;
946
939
  let destinationPath: string;
947
940
  try {
948
- const member = batchMemberFor(_toolCallId);
949
941
  if (member) {
950
942
  await assertTransferPathOption(req, ctx.cwd, member);
951
943
  sourcePath = member.sourceTarget ?? member.target;
@@ -954,15 +946,13 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
954
946
  ({ sourcePath, destinationPath } = await resolveTransferTargets(req, ctx.cwd));
955
947
  }
956
948
  } catch (error) {
957
- const member = batchMemberFor(_toolCallId);
958
949
  if (member) noteBatchFailure(member, error);
959
950
  throw error;
960
951
  }
961
952
  if (sourcePath === destinationPath) {
962
953
  return queuedEdit(sourcePath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
963
- const member = batchMemberFor(_toolCallId);
964
954
  if (member) {
965
- return executeBatchTransfer(kind, member, refs, warnings, sourcePath, mutationTargetPath, ctx.cwd, signal);
955
+ return executeBatchMember({ member, targetPath: sourcePath, mutationTargetPath, cwd: ctx.cwd, signal });
966
956
  }
967
957
  const preload = await readNormFile(sourcePath, ctx.cwd, {
968
958
  signal,
@@ -999,7 +989,6 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
999
989
  });
1000
990
  });
1001
991
  }
1002
- const member = batchMemberFor(_toolCallId);
1003
992
  if (member) {
1004
993
  return queuedEdit(destinationPath, ctx.cwd, signal, async (_absolutePath, mutationTargetPath) => {
1005
994
  return kind === "copy"
@@ -66,7 +66,7 @@ function batchGuideline(flags: EditToolFlags): string {
66
66
  const tools = operationNames(SHARED_EDIT_OPS, flags);
67
67
  const outcome = flags.autoRead ? "diff" : "result";
68
68
  const script = flags.codemode ? ` ${SCRIPT_BATCH_GUIDELINE}` : "";
69
- return `${tools}: same-file calls in one message are grouped into one batch; earlier calls reply \`In batch N (queued)\` and the last call shows the combined ${outcome}, with one undo for the whole batch.${script}`;
69
+ return `${tools}: same-file calls in one message are grouped into one batch; earlier calls reply \`In batch N (queued)\` and the last applied call shows the combined ${outcome}, with one undo for the whole batch. A call whose own request is invalid (anchors, shape, or \`old_string\`) fails on its own and is left out of the batch; the rest still commits.${script}`;
70
70
  }
71
71
 
72
72
  function diffGuideline(flags: EditToolFlags): string {
@@ -233,6 +233,7 @@ export interface PathRequirementInput {
233
233
  anchor?: string;
234
234
  providedPath?: unknown;
235
235
  cwd: string;
236
+ anchorTarget?: string;
236
237
  }
237
238
 
238
239
  export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
@@ -243,9 +244,10 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
243
244
  if (requirePath && (typeof input.providedPath !== "string" || input.providedPath.length === 0)) {
244
245
  throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the file the anchors were served for.');
245
246
  }
246
- const anchorTarget = typeof input.anchor === "string"
247
- ? resolveEditTarget(input.anchor)
248
- : resolveEditTarget(input.removeFrom as string, input.removeTo);
247
+ const anchorTarget = input.anchorTarget
248
+ ?? (typeof input.anchor === "string"
249
+ ? resolveEditTarget(input.anchor)
250
+ : resolveEditTarget(input.removeFrom as string, input.removeTo));
249
251
  if (requirePath) {
250
252
  const { resolved } = await resolveInCwd(input.providedPath as string, input.cwd);
251
253
  if (resolved !== anchorTarget) {
package/src/grep.ts CHANGED
@@ -66,104 +66,22 @@ function compileGrepGlob(glob: string): RegExp {
66
66
  }
67
67
  }
68
68
 
69
- function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
70
- if (!literal) assertSafeRegex(pattern);
71
- const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
69
+ function regexSource(pattern: string, literal: boolean): string {
70
+ return literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
71
+ }
72
+
73
+ function assertPattern(pattern: string, literal: boolean, ignoreCase: boolean): void {
72
74
  try {
73
- return new RegExp(source, ignoreCase ? "ui" : "u");
75
+ new RegExp(regexSource(pattern, literal), ignoreCase ? "ui" : "u");
74
76
  } catch {
75
77
  throw new Error(`[E_BAD_SHAPE] Invalid pattern: ${pattern}`);
76
78
  }
77
79
  }
78
80
 
79
- interface RegexGroupRisk {
80
- hasQuantifier: boolean;
81
- hasAlternation: boolean;
82
- }
83
-
84
- function unsafeRegex(pattern: string): never {
85
- throw new Error(
86
- `[E_UNSAFE_REGEX] Refusing potentially exponential regex: ${pattern}. Use literal: true or simplify the expression.`,
87
- );
88
- }
89
-
90
- function assertSafeRegex(pattern: string): void {
91
- if (pattern.length > 4096) unsafeRegex(pattern);
92
- const groups: RegexGroupRisk[] = [];
93
- let inClass = false;
94
- let escaped = false;
95
- let variableQuantifiers = 0;
96
- let lastAtom: { groupRisky: boolean; quantified: boolean } | undefined;
97
- for (let i = 0; i < pattern.length; i++) {
98
- const ch = pattern[i]!;
99
- if (escaped) {
100
- if (!inClass && (/[1-9]/.test(ch) || (ch === "k" && pattern[i + 1] === "<"))) {
101
- unsafeRegex(pattern);
102
- }
103
- escaped = false;
104
- lastAtom = { groupRisky: false, quantified: false };
105
- continue;
106
- }
107
- if (ch === "\\") {
108
- escaped = true;
109
- continue;
110
- }
111
- if (inClass) {
112
- if (ch === "]") {
113
- inClass = false;
114
- lastAtom = { groupRisky: false, quantified: false };
115
- }
116
- continue;
117
- }
118
- if (ch === "[") {
119
- inClass = true;
120
- continue;
121
- }
122
- if (ch === "(") {
123
- groups.push({ hasQuantifier: false, hasAlternation: false });
124
- lastAtom = undefined;
125
- continue;
126
- }
127
- if (ch === ")") {
128
- const group = groups.pop();
129
- if (group) {
130
- lastAtom = {
131
- groupRisky: group.hasQuantifier || group.hasAlternation,
132
- quantified: false,
133
- };
134
- }
135
- continue;
136
- }
137
- if (ch === "|") {
138
- const group = groups.at(-1);
139
- if (group) group.hasAlternation = true;
140
- lastAtom = undefined;
141
- continue;
142
- }
143
- let quantifierLength = 0;
144
- if (ch === "*" || ch === "+" || ch === "?") {
145
- quantifierLength = 1;
146
- } else if (ch === "{") {
147
- quantifierLength = /^\{\d+(?:,\d*)?\}/.exec(pattern.slice(i))?.[0].length ?? 0;
148
- }
149
- if (ch === "{" && quantifierLength > 0) {
150
- const quant = pattern.slice(i, i + quantifierLength);
151
- const m = /^\{(\d+)/.exec(quant);
152
- if (m && Number(m[1]) > 1000) unsafeRegex(pattern);
153
- }
154
- if (quantifierLength > 0 && lastAtom) {
155
- if (ch === "?" && lastAtom.quantified) continue;
156
- const variable = ch !== "{" || pattern.slice(i, i + quantifierLength).includes(",");
157
- if (variable && ++variableQuantifiers > 1) unsafeRegex(pattern);
158
- if (lastAtom.groupRisky) unsafeRegex(pattern);
159
- const group = groups.at(-1);
160
- if (group) group.hasQuantifier = true;
161
- lastAtom.quantified = true;
162
- i += quantifierLength - 1;
163
- continue;
164
- }
165
- lastAtom = { groupRisky: false, quantified: false };
166
- }
81
+ interface RgMatchLine {
82
+ line: number;
83
+ start: number;
84
+ end: number;
167
85
  }
168
86
 
169
87
  interface FileHit {
@@ -199,11 +117,23 @@ function snapCharBoundaries(line: string, start: number, end: number): [number,
199
117
  return [s, e];
200
118
  }
201
119
 
202
- function grepMatchFragment(line: string, regex: RegExp): string {
203
- const m = regex.exec(line);
204
- const matchStart = m?.index ?? 0;
205
- const matchLen = m?.[0].length ?? 0;
120
+ function byteOffsetToCharIndex(line: string, byteOffset: number): number {
121
+ if (byteOffset <= 0) return 0;
122
+ let bytes = 0;
123
+ let index = 0;
124
+ for (const char of line) {
125
+ if (bytes >= byteOffset) return index;
126
+ const codePoint = char.codePointAt(0)!;
127
+ bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4;
128
+ index += char.length;
129
+ }
130
+ return index;
131
+ }
132
+
133
+ function grepMatchFragment(line: string, startByte: number, endByte: number): string {
206
134
  const budget = GREP_ROW_CONTENT_BYTES - 6;
135
+ const matchStart = byteOffsetToCharIndex(line, startByte);
136
+ const matchLen = Math.max(0, byteOffsetToCharIndex(line, endByte) - matchStart);
207
137
  const half = Math.floor((budget - Math.min(matchLen, budget)) / 2);
208
138
  const [start, end] = snapCharBoundaries(line, Math.max(0, matchStart - half), Math.min(line.length, matchStart + matchLen + half));
209
139
  const content = truncateToBytes(line.slice(start, end), budget);
@@ -220,20 +150,21 @@ function grepHeadFragment(line: string): string {
220
150
  function makeHitFromIndices(
221
151
  norm: { normalized: string; fileHashes: string[]; absolutePath: string; hadUtf8DecodeErrors: boolean },
222
152
  displayPath: string,
223
- matchIndices: number[],
153
+ matches: RgMatchLine[],
224
154
  context: number,
225
- regex: RegExp | undefined,
226
155
  totalMatchCount: number,
227
156
  keptMatchCount: number,
228
157
  ): FileHit {
229
158
  const lines = visLines(norm.normalized);
230
159
  const shown = new Set<number>();
231
- const kept = matchIndices.slice(0, keptMatchCount);
232
- for (const i of kept) {
233
- for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
160
+ const kept = matches.slice(0, keptMatchCount);
161
+ for (const match of kept) {
162
+ const lineIndex = match.line - 1;
163
+ for (let j = Math.max(0, lineIndex - context); j <= Math.min(lines.length - 1, lineIndex + context); j++) shown.add(j);
234
164
  }
235
165
  const sorted = [...shown].sort((a, b) => a - b);
236
- const matchSet = new Set(matchIndices);
166
+ const matchByLine = new Map<number, RgMatchLine>();
167
+ for (const match of matches) matchByLine.set(match.line - 1, match);
237
168
  const rows: string[] = [];
238
169
  const hashes: string[] = [];
239
170
  const lineNumbers: number[] = [];
@@ -244,7 +175,8 @@ function makeHitFromIndices(
244
175
  const line = lines[idx]!;
245
176
  const row = fmtRow(hash, line);
246
177
  if (Buffer.byteLength(row, "utf-8") > MAX_GREP_LINE_BYTES) {
247
- const content = matchSet.has(idx) && regex ? grepMatchFragment(line, regex) : grepHeadFragment(line);
178
+ const match = matchByLine.get(idx);
179
+ const content = match !== undefined ? grepMatchFragment(line, match.start, match.end) : grepHeadFragment(line);
248
180
  rows.push(fmtRow(hash, content));
249
181
  hashes.push(hash);
250
182
  lineNumbers.push(idx + 1);
@@ -270,7 +202,7 @@ function makeHitFromIndices(
270
202
  totalMatchCount,
271
203
  fragmented,
272
204
  texts,
273
- matchLines: kept.map((index) => index + 1),
205
+ matchLines: kept.map((match) => match.line),
274
206
  hadUtf8DecodeErrors: norm.hadUtf8DecodeErrors,
275
207
  };
276
208
  }
@@ -330,7 +262,7 @@ async function collectRgMatches(
330
262
  req: GrepReq,
331
263
  signal: AbortSignal | undefined,
332
264
  repoRooted: boolean,
333
- ): Promise<Map<string, number[]>> {
265
+ ): Promise<Map<string, RgMatchLine[]>> {
334
266
  const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
335
267
  if (!repoRooted) args.push("--no-require-git");
336
268
  const wanted = req.limit ?? 100;
@@ -342,8 +274,8 @@ async function collectRgMatches(
342
274
  if (req.ignoreCase) args.push("--ignore-case");
343
275
  if (req.literal) args.push("--fixed-strings");
344
276
  args.push("--", pattern, searchPath);
345
- const result = new Map<string, number[]>();
346
- return await new Promise<Map<string, number[]>>((resolve, reject) => {
277
+ const result = new Map<string, RgMatchLine[]>();
278
+ return await new Promise<Map<string, RgMatchLine[]>>((resolve, reject) => {
347
279
  const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
348
280
  const rl = createInterface({ input: child.stdout });
349
281
  let stderr = "";
@@ -367,7 +299,7 @@ async function collectRgMatches(
367
299
  };
368
300
  rl.on("line", (line) => {
369
301
  if (!line.trim()) return;
370
- let event: { type?: string; data?: { path?: { text?: string }; line_number?: number } };
302
+ let event: { type?: string; data?: { path?: { text?: string }; line_number?: number; submatches?: Array<{ start?: number; end?: number }> } };
371
303
  try {
372
304
  event = JSON.parse(line);
373
305
  } catch {
@@ -383,8 +315,11 @@ async function collectRgMatches(
383
315
  } catch {
384
316
  abs = filePath;
385
317
  }
318
+ const submatch = event.data?.submatches?.[0];
319
+ const start = typeof submatch?.start === "number" ? submatch.start : 0;
320
+ const end = typeof submatch?.end === "number" ? submatch.end : start;
386
321
  const list = result.get(abs) ?? [];
387
- list.push(lineNumber);
322
+ list.push({ line: lineNumber, start, end });
388
323
  result.set(abs, list);
389
324
  }
390
325
  }
@@ -481,8 +416,7 @@ export function fmtGrepCall(args: { pattern?: unknown; path?: unknown; glob?: un
481
416
  function highlightRegex(args: unknown): RegExp | undefined {
482
417
  if (!isRec(args) || typeof args.pattern !== "string" || args.pattern.length === 0) return undefined;
483
418
  try {
484
- const validated = buildRegex(args.pattern, args.literal === true, args.ignoreCase === true);
485
- return new RegExp(validated.source, validated.flags.includes("i") ? "giu" : "gu");
419
+ return new RegExp(regexSource(args.pattern, args.literal === true), args.ignoreCase === true ? "giu" : "gu");
486
420
  } catch {
487
421
  return undefined;
488
422
  }
@@ -584,7 +518,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
584
518
  const globPath = toDisplayPath(globRoot, absPath);
585
519
  return globRegex.test(globPath) || globRegex.test(displayPath);
586
520
  };
587
- const validatedRegex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
521
+ assertPattern(req.pattern, req.literal === true, req.ignoreCase === true);
588
522
  const rgPath = await resolveRgPath();
589
523
  const hits: FileHit[] = [];
590
524
  let matches = 0;
@@ -615,15 +549,13 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
615
549
  for (let f = 0; f < sortedFiles.length; f++) {
616
550
  abortIf(signal);
617
551
  const absPath = sortedFiles[f]!;
618
- const allNums = rgMatches.get(absPath) ?? [];
619
- const totalForFile = allNums.length;
620
- const sortedNums = [...allNums].sort((a, b) => a - b);
621
- const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
552
+ const fileMatches = [...(rgMatches.get(absPath) ?? [])].sort((a, b) => a.line - b.line);
553
+ const totalForFile = fileMatches.length;
622
554
  if (countOnly) {
623
555
  if (!matchesGlob(absPath)) continue;
624
556
  const norm = await readGrepFileShadow(absPath);
625
557
  if (!norm) continue;
626
- const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), indices, context, validatedRegex, totalForFile, indices.length);
558
+ const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), fileMatches, context, totalForFile, fileMatches.length);
627
559
  const display = displayRowsForHit(hit);
628
560
  totalRows += display.length;
629
561
  for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
@@ -645,7 +577,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
645
577
  if (!matchesGlob(absPath)) continue;
646
578
  const norm = await readGrepFile(absPath);
647
579
  if (!norm) continue;
648
- const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
580
+ const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), fileMatches, context, totalForFile, Math.min(totalForFile, remaining));
649
581
  const display = displayRowsForHit(hit);
650
582
  const keptRows: string[] = [];
651
583
  const keptHashes: string[] = [];
package/src/hash-store.ts CHANGED
@@ -161,6 +161,15 @@ function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
161
161
  }
162
162
  }
163
163
 
164
+ function addColumnIfMissing(db: RawDb, statement: string): void {
165
+ try {
166
+ db.exec(statement);
167
+ } catch (error) {
168
+ if (error instanceof Error && /duplicate column/i.test(error.message)) return;
169
+ throw error;
170
+ }
171
+ }
172
+
164
173
  function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
165
174
  db.exec("PRAGMA journal_mode = WAL");
166
175
  db.exec("PRAGMA synchronous = NORMAL");
@@ -192,18 +201,10 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
192
201
  "updated_at INTEGER NOT NULL" +
193
202
  ")"
194
203
  );
195
- try {
196
- db.exec("ALTER TABLE snapshots ADD COLUMN line_checksums TEXT");
197
- } catch {}
198
- try {
199
- db.exec("ALTER TABLE undo ADD COLUMN mode INTEGER");
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 {}
204
+ addColumnIfMissing(db, "ALTER TABLE snapshots ADD COLUMN line_checksums TEXT");
205
+ addColumnIfMissing(db, "ALTER TABLE undo ADD COLUMN mode INTEGER");
206
+ addColumnIfMissing(db, "ALTER TABLE undo ADD COLUMN separators TEXT");
207
+ addColumnIfMissing(db, "ALTER TABLE undo ADD COLUMN result_separators TEXT");
207
208
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
208
209
  if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
209
210
  db.exec("DELETE FROM snapshots");
@@ -93,7 +93,12 @@ export function fmtMismatchWithHashes(
93
93
  const out: string[] = [];
94
94
  const hashes: string[] = [];
95
95
  const servedMap = new Map<string, string>();
96
- const notFound = mismatches;
96
+ const seen = new Set<string>();
97
+ const notFound = mismatches.filter((mismatch) => {
98
+ if (seen.has(mismatch.ref.hash)) return false;
99
+ seen.add(mismatch.ref.hash);
100
+ return true;
101
+ });
97
102
  if (notFound.length > 0) {
98
103
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
99
104
  out.push(
@@ -194,7 +199,13 @@ export function stripAnchorRow(
194
199
  return match[2]!;
195
200
  }
196
201
 
197
- export function resEdit(edit: HTEdit | HTPayloadEdit, warnings?: string[]): HEdit {
202
+ export type HTextInput = {
203
+ text: string | string[];
204
+ remove_from: string;
205
+ remove_to: string;
206
+ };
207
+
208
+ export function resEdit(edit: HTextInput, warnings?: string[]): HEdit {
198
209
  if (typeof edit.text === "string") {
199
210
  assertPayloadItem(edit as unknown as Record<string, unknown>);
200
211
  return resolveParsedEdit(edit.remove_from, edit.remove_to, parsePayloadText(edit.text), warnings);