pi-hashline-edit-pro 6.2.0 → 6.3.1

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"
@@ -13,6 +13,7 @@ export interface EditToolFlags {
13
13
  strictInput: boolean;
14
14
  autoRead: boolean;
15
15
  autoReadAllActive: boolean;
16
+ autoReadAllOutline: boolean;
16
17
  replaceMatchEnabled: boolean;
17
18
  copyMoveEnabled: boolean;
18
19
  codemode: boolean;
@@ -23,6 +24,7 @@ export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
23
24
  strictInput: false,
24
25
  autoRead: true,
25
26
  autoReadAllActive: false,
27
+ autoReadAllOutline: false,
26
28
  replaceMatchEnabled: true,
27
29
  copyMoveEnabled: true,
28
30
  codemode: false,
@@ -35,6 +37,7 @@ export async function currentEditFlags(codemode = false): Promise<EditToolFlags>
35
37
  strictInput: config.strictInput === true,
36
38
  autoRead: config.autoRead !== false,
37
39
  autoReadAllActive: (config.autoReadAll ?? "off") !== "off",
40
+ autoReadAllOutline: (config.autoReadAll ?? "off") === "outline",
38
41
  replaceMatchEnabled: config.replaceMatchEnabled !== false,
39
42
  copyMoveEnabled: config.copyMoveEnabled !== false,
40
43
  codemode
@@ -66,7 +69,7 @@ function batchGuideline(flags: EditToolFlags): string {
66
69
  const tools = operationNames(SHARED_EDIT_OPS, flags);
67
70
  const outcome = flags.autoRead ? "diff" : "result";
68
71
  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}`;
72
+ 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; any failure once the batch runs aborts the whole batch.${script}`;
70
73
  }
71
74
 
72
75
  function diffGuideline(flags: EditToolFlags): string {
@@ -83,7 +86,7 @@ function pathGuideline(flags: EditToolFlags): string {
83
86
 
84
87
  function payloadGuideline(flags: EditToolFlags): string {
85
88
  const tools = operationNames(SHARED_PAYLOAD_OPS, flags);
86
- return `${tools}: JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — \`\\uXXXX\` is the character, \`\\\\uXXXX\` the literal text.`;
89
+ return `${tools}: write the character itself, not an escape; the tool writes \`text\` verbatim and never decodes escapes — \`\\uXXXX\` is the character, \`\\\\uXXXX\` the literal text.`;
87
90
  }
88
91
 
89
92
  function strictInputGuideline(flags: EditToolFlags): string {
@@ -115,27 +118,34 @@ export function withReplacePrompts(base: { description: string; snippet: string;
115
118
  return finalizePrompts(description, base.snippet, guidelines, flags);
116
119
  }
117
120
 
121
+ function readGuidelineApplies(guideline: string, flags: EditToolFlags): boolean {
122
+ if (!flags.autoReadAllOutline && guideline.includes("[limit N]")) return false;
123
+ if (!flags.replaceMatchEnabled && guideline.includes("`replace_match`")) return false;
124
+ return true;
125
+ }
126
+
118
127
  export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
119
128
  const preference = preferenceGuideline(flags);
120
129
  const script = flags.codemode ? [RESULT_CONTRACT_GUIDELINE] : [];
130
+ const baseGuidelines = base.guidelines.filter((guideline) => readGuidelineApplies(guideline, flags));
121
131
  if (flags.autoReadAllActive) {
122
- const rewritten = base.guidelines
132
+ const rewritten = baseGuidelines
123
133
  .filter((guideline) => !guideline.includes("call again after an edit"))
124
134
  return { description: base.description, snippet: base.snippet, guidelines: [preference, ...rewritten, ...script] };
125
135
  }
126
- if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [preference, ...base.guidelines, ...script] };
127
- const guidelines = [preference, ...base.guidelines, ...script];
136
+ if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [preference, ...baseGuidelines, ...script] };
137
+ const guidelines = [preference, ...baseGuidelines, ...script];
128
138
  const mapped = guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
129
139
  return { description: base.description, snippet: base.snippet, guidelines: mapped };
130
140
  }
131
141
 
132
142
  export function withInsertPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
133
- const guidelines = [...base.guidelines];
143
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines];
134
144
  return finalizePrompts(base.description, base.snippet, guidelines, flags);
135
145
  }
136
146
 
137
147
  export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
138
- const guidelines = [...base.guidelines];
148
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines];
139
149
  return finalizePrompts(base.description, base.snippet, guidelines, flags);
140
150
  }
141
151
 
@@ -157,8 +167,8 @@ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/"
157
167
  }
158
168
 
159
169
  export function withGrepPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
160
- if (!flags.codemode && flags.copyMoveEnabled) return base;
161
- const guidelines = flags.codemode ? [...base.guidelines, RESULT_CONTRACT_GUIDELINE] : base.guidelines;
170
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines];
171
+ if (flags.codemode) guidelines.push(RESULT_CONTRACT_GUIDELINE);
162
172
  if (flags.copyMoveEnabled) return { ...base, guidelines };
163
173
  return { ...base, guidelines, description: base.description.replaceAll("replace, insert, copy, or move", joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags))) };
164
174
  }
@@ -168,9 +178,9 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
168
178
  let description = base.description;
169
179
  let snippet = base.snippet;
170
180
  const script = flags.codemode ? [RESULT_CONTRACT_GUIDELINE, SCRIPT_UNDO_GUIDELINE] : [];
171
- let guidelines = [...base.guidelines, ...script];
181
+ let guidelines = [preferenceGuideline(flags), ...base.guidelines, ...script];
172
182
  if (!flags.autoRead) {
173
- guidelines = guidelines.map((guideline) => guideline.includes("bad diff") ? "`undo_last_change`: only the last `replace`/`replace_match`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad edit — review what you're restoring." : guideline);
183
+ guidelines = guidelines.map((guideline) => guideline.includes("a `write` clears the history") ? "`undo_last_change`: only the last `replace`/`replace_match`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo before any other edit or write." : guideline);
174
184
  }
175
185
  if (ops.length !== 5) {
176
186
  description = description.replaceAll("replace, replace_match, insert, copy, or move", joinOps(ops));
@@ -184,7 +194,7 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
184
194
  }
185
195
 
186
196
  export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
187
- const guidelines = flags.codemode ? [...base.guidelines, SCRIPT_TRANSFER_GUIDELINE] : [...base.guidelines];
197
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines, ...(flags.codemode ? [SCRIPT_TRANSFER_GUIDELINE] : [])];
188
198
  return finalizePrompts(base.description, base.snippet, guidelines, flags, { stringPayload: false });
189
199
  }
190
200
 
@@ -233,6 +243,7 @@ export interface PathRequirementInput {
233
243
  anchor?: string;
234
244
  providedPath?: unknown;
235
245
  cwd: string;
246
+ anchorTarget?: string;
236
247
  }
237
248
 
238
249
  export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
@@ -243,9 +254,10 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
243
254
  if (requirePath && (typeof input.providedPath !== "string" || input.providedPath.length === 0)) {
244
255
  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
256
  }
246
- const anchorTarget = typeof input.anchor === "string"
247
- ? resolveEditTarget(input.anchor)
248
- : resolveEditTarget(input.removeFrom as string, input.removeTo);
257
+ const anchorTarget = input.anchorTarget
258
+ ?? (typeof input.anchor === "string"
259
+ ? resolveEditTarget(input.anchor)
260
+ : resolveEditTarget(input.removeFrom as string, input.removeTo));
249
261
  if (requirePath) {
250
262
  const { resolved } = await resolveInCwd(input.providedPath as string, input.cwd);
251
263
  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[] = [];