pi-hashline-edit-pro 7.1.0 → 7.1.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/README.md CHANGED
@@ -88,7 +88,7 @@ pi install /path/to/pi-hashline-edit-pro
88
88
  | Built-in | Effect |
89
89
  | --- | --- |
90
90
  | `read` | overridden, returns `anchor│content` rows |
91
- | `outline` | new opt-in tool; returns one file's structural outline |
91
+ | `outline` | new opt-in tool; returns the structural outline of a file, a directory, or a glob |
92
92
  | `edit` | disabled |
93
93
  | `grep` | replaced by an anchored `grep`; pi's own `grep` returns when Anchor grep is off |
94
94
  | `copy`, `move` | disabled while Copy/move is off |
@@ -184,11 +184,13 @@ Edge cases:
184
184
 
185
185
  ### outline
186
186
 
187
- `outline` returns the structural outline of one file: a `=== path (language) — N lines ===` header, then one `anchor│content` row per symbol whose content is the first line of that symbol's declaration (annotations and attributes excluded). A `[limit N]` line under a multi-line symbol is the span to pass as `read`'s `limit` when reading the body from that anchor, and an `[N nested]` line marks children hidden by `depth`. Hiding children that way is a view choice rather than truncation, so `truncated` stays false and the section still counts as complete. The structured result reports `complete`, which is true only when no row or byte cap cut the outline, every rendered row was served, and no line was skipped for a missing anchor; a head-and-tail preview that omits lines is incomplete. Files the parser cannot outline fall back to the same head-and-tail preview auto-read all uses. A row whose line is longer than 200 characters is clipped and is not served for editing, so read that line first. The tool is offered only while Outline tool is on in `/hashline-config`.
187
+ `outline` returns the structural outline of a file, a directory, or a glob: a `=== path (language) — N lines ===` header per file, then one `anchor│content` row per symbol whose content is the first line of that symbol's declaration (annotations, decorators, and attributes excluded, and outside the symbol's span). Rows cover top-level declarations and the members of a type or module; a declaration inside a function body is never a row, whatever `depth` you pass. A `[limit N]` line under a multi-line symbol is the span to pass as `read`'s `limit` when reading the body from that anchor, and an `[N nested]` line marks children hidden by `depth`. Hiding children that way is a view choice rather than truncation, so `truncated` stays false and the section still counts as complete. The structured result reports `complete`, which is true only when no row or byte cap cut the outline, every rendered row was served, no line was skipped for a missing anchor, and no outlined file's anchors were reclaimed; a head-and-tail preview that omits lines is incomplete. Files the parser cannot outline, and supported files with no symbols, fall back to the same head-and-tail preview auto-read all uses, followed by a note that names the reason. Another note says when the parser could not read part of a file (a syntax error, or syntax the vendored grammar does not cover), since symbols in that part may be missing. A row whose line is longer than 200 characters is clipped and is not served for editing, even when an `E_RANGE_STALE` or `E_STALE_ANCHOR` error shows it again, so read that line first. The tool is offered only while Outline tool is on in `/hashline-config`.
188
+
189
+ A directory or glob outlines every supported source file it matches, sorted by path, with files separated by a blank line. Listing starts at the named folder (or the glob's literal prefix): inside a Git work tree it uses tracked and untracked-but-not-ignored files, otherwise a filesystem walk, and it skips the folders and files auto-read all excludes plus your `autoReadAllIgnore` list. `*` and `**` both cross folders, as in `grep`'s `glob`. All files share the 400-row cap and the 50KB budget; each file gets at least 20 rows, or an equal share of what is left, so one large file does not hide the rest. Files over 200KB are skipped before reading, and a batch stops once it has read 400,000 source lines so files the output would not show cannot exhaust the session's anchor pool. When the budget runs out, a footer names how many files were not outlined and how to narrow the request. Listed paths that are no longer regular files (deleted, or symlinks) are dropped silently; a file that fails to read (binary, oversized, or unreadable) is skipped with a notice. All notices count against the budget, and each outlined file's own notes are always kept. A path that exists is never read as a glob, so `app/[id].ts` names that file; otherwise `*`, `?`, `[...]`, or `{a,b}` makes it a glob. A pattern that matches no supported file returns `[E_NOT_FOUND]`, and an invalid glob returns `[E_BAD_SHAPE]`.
188
190
 
189
191
  | Parameter | Description |
190
192
  | --- | --- |
191
- | `path` | Path to the file to outline (relative or absolute). |
193
+ | `path` | A file (relative or absolute), a directory, or a glob such as `src/**/*.ts`, relative to the working directory. |
192
194
  | `depth` | Nesting depth: `0` lists top-level symbols only, `1` adds their members, and the default is `2` (maximum `10`). |
193
195
 
194
196
  Rows are the file's real lines, so an outline anchor is editable with `replace` or `insert` without a separate `read`, exactly like an auto-read-all outline row. The outline shows only each symbol's declaration line, so an edit inside a body still needs a `read` of that span.
@@ -273,7 +275,7 @@ Example: read served `hasux│old` in `a.ts` and `qwerx│top` in `b.ts`; to cop
273
275
  { "source_from": "hasux", "source_to": "hasux", "anchor": "qwerx", "direction": "after" }
274
276
  ```
275
277
 
276
- The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and keep their source line endings. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy. In a same-message batch, a same-file copy and a cross-file copy join the destination file's batch; the copy still duplicates the content its source anchors were served from, not the result of a sibling edit. Anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) duplicates the block right beside itself.
278
+ The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and carry the source's line endings. The destination's end-of-file state is preserved either way: a destination that ended without a newline still does, and one that ended with a newline still does. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy. In a same-message batch, a same-file copy and a cross-file copy join the destination file's batch; the copy still duplicates the content its source anchors were served from, not the result of a sibling edit. Anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) duplicates the block right beside itself.
277
279
 
278
280
  ### move
279
281
 
@@ -303,6 +305,10 @@ Patterns are evaluated by ripgrep's linear-time regex engine, so shapes that sta
303
305
 
304
306
  Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whichever comes first, with a note naming the caps that cut the results (the exact one is in `details.truncation`). A matched line whose `anchor│content` row exceeds 500 bytes is shown as a fragment around the match, with `...` marking the truncated sides; a context row over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable, and `replace` always replaces the whole line.
305
307
 
308
+ A path ripgrep cannot read does not abort the search. ripgrep reports the error and keeps scanning, so directory searches return every match from the readable files and add a `[grep: some paths could not be searched, so these results are partial: ...]` note naming the failure. `[E_GREP_FAILED]` is reserved for a search that produced nothing: an unusable pattern or glob, ripgrep failing to start, or an exit that left no matches at all.
309
+
310
+ Matching uses the same line model as `read`, so `^` and `$` address the file's own line boundaries: in a CRLF file `TWO$` matches the second line, and a byte order mark does not keep `^` from matching line 1. A file with lone-CR (classic Mac) endings is still read, displayed, and anchored by its real lines, and every match is attributed to the line it is on, but ripgrep only breaks lines at `\n` there, so `^` and `$` see such a file as a single line: unanchored patterns work, `^` matches only at the start of the file, and `$` only at its end. Use `read` when line anchoring matters for a lone-CR file.
311
+
306
312
  ### undo_last_change
307
313
 
308
314
  `undo_last_change` reverts the most recent successful `replace`, `replace_match`, `insert`, `copy`, or `move` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
@@ -315,6 +321,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
315
321
  - A successful `write` clears the history for that file.
316
322
  - If the file was modified since the last edit, the undo is refused with `[E_UNDO_STALE]` rather than overwriting those changes, and the record is kept. Once the file matches the edited state again, `undo_last_change` succeeds.
317
323
  - If the file was deleted since the last edit, `undo_last_change` restores it from the recorded pre-edit content.
324
+ - A path with no undo record reports `[E_UNDO_NONE]`, and `[E_NOT_FOUND]` when that path does not exist either. The record is looked up first, so a file deleted since its last edit is still restored.
318
325
  - Missing-file cleanup never touches the undo record. The per-session prune removes snapshots and served records of files that no longer exist (both are recomputed on the next read), but the undo history survives, even when the file is temporarily absent during a branch switch.
319
326
  - Records are keyed by file path, not by session, so in a multi-conversation host any conversation that names the file can revert its most recent edit, even one made by another conversation.
320
327
 
@@ -394,7 +401,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
394
401
  | `anchorGrepEnabled` | Anchor grep | `true` | Register an anchored `grep` in place of pi's own `grep` while it is on. |
395
402
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
396
403
  | `replaceMatchEnabled` | Replace match | `true` | Offer the `replace_match` tool; when off, it is removed from the active tools. |
397
- | `outlineEnabled` | Outline tool | `false` | Register the `outline` tool for one file's structure on demand; when off, it is removed from the active tools. |
404
+ | `outlineEnabled` | Outline tool | `false` | Register the `outline` tool for the structure of a file, directory, or glob on demand; when off, it is removed from the active tools. |
398
405
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
399
406
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`, `[W_ANCHOR_CASE]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
400
407
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
@@ -420,7 +427,9 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
420
427
  | Grep output | 2000 rows and 50KB | `grep` |
421
428
  | Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
422
429
  | Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
423
- | Outline rows | 400 symbol rows and 50KB of content per file (a truncation footer may follow) | `outline`, auto-read all `outline` mode |
430
+ | Outline rows | 400 symbol rows and 50KB of content per file, shared across files for a directory or glob (a truncation footer may follow) | `outline`, auto-read all `outline` mode |
431
+ | Outline directory file | 200KB per file | `outline` directory/glob; larger files are skipped with a notice |
432
+ | Outline source lines | 400,000 lines | `outline` directory/glob; stops early to protect the session anchor pool |
424
433
  | Auto-read all budget | 200KB floor; 2MB ceiling in `full` | total injection size derived from the model context window; `outline` is not capped at 2MB |
425
434
  | Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
426
435
  | Session anchors | 1,353,139 anchors in use | all tools; the least recently read or edited files are freed when the quota is exhausted (`[W_ANCHOR_RECLAIMED]`) |
@@ -438,7 +447,7 @@ All nine tools also declare an `outputSchema` and return `structuredContent`, so
438
447
 
439
448
  `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change` return `{ kind: "edit", verb, classification, path, text, diff, warnings, hints, firstChangedLine, anchors, anchorsOmitted }`, where `verb` is `"replaced"`, `"inserted"`, `"copied"`, `"moved"`, or `"undone"`, `anchors` are the live rows of the post-edit diff, and `anchorsOmitted` is true when the diff was truncated or carried no rows, meaning a fresh read is needed. A cross-file move reports the destination file and its anchors.
440
449
 
441
- `outline` returns `{ kind: "outline", path, text, totalLines, depth, lines, truncated, complete, notices }`, where `text` is the outline body without the notices, `lines` is the anchored rows it served, `complete` is true only when no cap cut the outline, every rendered row was served, and no line was skipped for a missing anchor; `notices` carries non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`; in a codemode session `tools.outline()` resolves to the served outline text (with notices) unless `anchors: true` is passed.
450
+ `outline` returns `{ kind: "outline", path, text, totalLines, depth, lines, truncated, complete, notices }`, where `text` is the outline body without the notices, `lines` is the anchored rows it served, `complete` is true only when no cap cut the outline, every rendered row was served, and no line was skipped for a missing anchor; `notices` carries notes such as the preview reason, the UTF-8 rewrite note, and `[W_ANCHOR_RECLAIMED]`; for a directory or glob, `lines` is replaced by `files: [{ path, lines, totalLines, truncated, complete }]`, one entry per outlined file, and `totalLines` is their sum; in a codemode session `tools.outline()` resolves to the served outline text (with notices) unless `anchors: true` is passed.
442
451
 
443
452
  `grep` returns `{ kind: "grep", text, matches, files, truncated, results }`, where each entry of `results` is `{ path, matchLines, lines, hadUtf8DecodeErrors }` and `lines` uses the same `{ line, text, anchor, rendered }` shape as `read`.
444
453
 
@@ -504,7 +513,7 @@ Full reference:
504
513
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
505
514
  | `[E_BATCH_OVERLAP]` | Batched edit calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
506
515
  | `[E_OP_ABORTED]` | The edit was aborted before writing: the file changed or was deleted after it was read (a single edit or a cross-file move reports the file; a batch reports the batch and the failing call), the base read failed, a cross-file member could not be prepared, or the composed result exceeded a limit. Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The batch abort names the failing call and its error code when one is known. |
507
- | `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences, look-around, or invalid regex syntax, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
516
+ | `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences, look-around, or invalid regex syntax, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. A path ripgrep cannot read is not one of these: the search returns the readable matches with a partial-results note. |
508
517
  | `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
509
518
 
510
519
  ## Troubleshooting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 5-character lowercase tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -88,7 +88,7 @@ export const AUTO_READ_ALL_EXCLUDED_NAMES = [
88
88
  ];
89
89
  const EXCLUDED_NAME_SET = new Set(AUTO_READ_ALL_EXCLUDED_NAMES);
90
90
  const EXCLUDED_SEGMENT_SET = new Set(AUTO_READ_ALL_EXCLUDED_SEGMENTS);
91
- function isExcludedBySegment(path: string): boolean {
91
+ export function isExcludedBySegment(path: string): boolean {
92
92
  const lower = path.toLowerCase();
93
93
  const vendorViews = lower.startsWith("resources/views/vendor/");
94
94
  for (const segment of lower.split("/")) {
@@ -103,7 +103,7 @@ const EXCLUDED_PATTERN_SUFFIXES = [
103
103
  ];
104
104
  const EXCLUDED_PATTERN_INFIXES = [".bundle.", ".chunk.", ".generated.", ".gen."];
105
105
 
106
- function isExcludedByPattern(baseLower: string): boolean {
106
+ export function isExcludedByPattern(baseLower: string): boolean {
107
107
  return EXCLUDED_PATTERN_SUFFIXES.some((suffix) => baseLower.endsWith(suffix))
108
108
  || EXCLUDED_PATTERN_INFIXES.some((infix) => baseLower.includes(infix))
109
109
  || baseLower.startsWith("coreui-icons.")
@@ -256,7 +256,7 @@ const GLOB_CHARS_RE = /[*?[\]{}]/;
256
256
  const GLOB_CACHE_LIMIT = 256;
257
257
  const globRegexCache = new Map<string, RegExp | null>();
258
258
 
259
- function globRegexFor(entry: string): RegExp | null {
259
+ export function globRegexFor(entry: string): RegExp | null {
260
260
  const cached = globRegexCache.get(entry);
261
261
  if (cached !== undefined) return cached;
262
262
  let compiled: RegExp | null;
@@ -354,7 +354,7 @@ function runCommand(command: string, args: string[], cwd: string): Promise<{ std
354
354
  });
355
355
  }
356
356
 
357
- async function listFromGit(cwd: string): Promise<string[] | undefined> {
357
+ export async function listFromGit(cwd: string): Promise<string[] | undefined> {
358
358
  const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd);
359
359
  if (result.code !== 0) return undefined;
360
360
  return result.stdout.split("\0").filter((entry) => entry.length > 0);
@@ -403,7 +403,7 @@ async function listFromRg(cwd: string): Promise<string[] | undefined> {
403
403
  return result.stdout.split("\0").filter((entry) => entry.length > 0);
404
404
  }
405
405
 
406
- async function walkDir(dir: string, base: string, out: string[], customIgnore: readonly string[] = []): Promise<void> {
406
+ export async function walkDir(dir: string, base: string, out: string[], customIgnore: readonly string[] = []): Promise<void> {
407
407
  let entries;
408
408
  try {
409
409
  entries = await readdir(dir, { withFileTypes: true });
@@ -69,7 +69,7 @@ const SHARED_SCRIPT_OPS = ["read", "outline", "replace", "replace_match", "inser
69
69
 
70
70
  function resultContractGuideline(flags: EditToolFlags): string {
71
71
  const tools = operationNames(SHARED_SCRIPT_OPS, flags);
72
- return `${tools}: when called from a codemode script, failures resolve to \`{ ok: false, kind: "error", error: { code, message } }\`; branch on \`ok\` instead of \`try\`/\`catch\`.`;
72
+ return `${tools}: when called from a codemode script, failures resolve to \`{ ok: false, kind: "error", error: { code, message } }\`; branch on \`ok\` instead of \`try\`/\`catch\`. Arguments that fail the tool's schema still throw before the tool runs.`;
73
73
  }
74
74
  const READ_SCRIPT_GUIDELINE =
75
75
  '`read` in a codemode script resolves to the file\'s text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }` with the raw file content and `lines[].anchor` for chained edits. An image read resolves to an image block.';
package/src/grep.ts CHANGED
@@ -15,6 +15,7 @@ import { loadP, loadGuidelines } from "./prompts";
15
15
  import { normReq } from "./payload-contract";
16
16
  import { DEFAULT_EDIT_FLAGS, withGrepPrompts, type EditToolFlags } from "./edit-common";
17
17
  import { abortIf, clipLine, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines, gutterWidth } from "./utils";
18
+ import type { LineEnding } from "./normalize";
18
19
  import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
19
20
  import { serveRows } from "./served";
20
21
  import { Text } from "@earendil-works/pi-tui";
@@ -139,6 +140,62 @@ function grepHeadFragment(line: string): string {
139
140
  return head.length < line.length ? `${head}...` : head;
140
141
  }
141
142
 
143
+ function lineStartsOf(text: string): number[] {
144
+ const starts = [0];
145
+ for (let index = 0; index < text.length; index += 1) {
146
+ if (text.charCodeAt(index) === 10 && index + 1 < text.length) starts.push(index + 1);
147
+ }
148
+ return starts;
149
+ }
150
+
151
+ function physicalStartsOf(text: string, separators: readonly LineEnding[]): number[] {
152
+ const starts = [0];
153
+ let separatorIndex = 0;
154
+ for (let index = 0; index < text.length; index += 1) {
155
+ if (text.charCodeAt(index) !== 10) continue;
156
+ const separator = separators[separatorIndex] ?? "\n";
157
+ separatorIndex += 1;
158
+ if (separator === "\n" || separator === "\r\n") starts.push(index + 1);
159
+ }
160
+ return starts;
161
+ }
162
+
163
+ function lineIndexOf(starts: readonly number[], position: number): number {
164
+ let low = 0;
165
+ let high = starts.length - 1;
166
+ while (low < high) {
167
+ const mid = (low + high + 1) >>> 1;
168
+ if (starts[mid]! <= position) low = mid;
169
+ else high = mid - 1;
170
+ }
171
+ return low;
172
+ }
173
+
174
+ function toNormalizedMatches(
175
+ norm: { normalized: string; endingSeparators: readonly LineEnding[] },
176
+ matches: RgMatchLine[],
177
+ ): RgMatchLine[] {
178
+ if (!norm.endingSeparators.includes("\r")) return matches;
179
+ const text = norm.normalized;
180
+ const starts = lineStartsOf(text);
181
+ const physical = physicalStartsOf(text, norm.endingSeparators);
182
+ const lines = visLines(text);
183
+ return matches.map((match) => {
184
+ const base = physical[match.line - 1] ?? 0;
185
+ const physicalLine = text.slice(base, physical[match.line] ?? text.length);
186
+ const position = base + byteOffsetToCharIndex(physicalLine, match.start);
187
+ const index = lineIndexOf(starts, position);
188
+ const lineText = lines[index] ?? "";
189
+ const columnStart = Math.min(position - starts[index]!, lineText.length);
190
+ const columnEnd = Math.min(base + byteOffsetToCharIndex(physicalLine, match.end) - starts[index]!, lineText.length);
191
+ return {
192
+ line: index + 1,
193
+ start: Buffer.byteLength(lineText.slice(0, columnStart), "utf-8"),
194
+ end: Buffer.byteLength(lineText.slice(0, Math.max(columnStart, columnEnd)), "utf-8"),
195
+ };
196
+ });
197
+ }
198
+
142
199
  function makeHitFromIndices(
143
200
  norm: { normalized: string; fileHashes: string[]; absolutePath: string; hadUtf8DecodeErrors: boolean },
144
201
  displayPath: string,
@@ -247,6 +304,11 @@ async function insideGitRepo(start: string): Promise<boolean> {
247
304
  }
248
305
  }
249
306
 
307
+ interface RgSearch {
308
+ matches: Map<string, RgMatchLine[]>;
309
+ skipped?: string;
310
+ }
311
+
250
312
  async function collectRgMatches(
251
313
  rgPath: string,
252
314
  pattern: string,
@@ -254,8 +316,8 @@ async function collectRgMatches(
254
316
  req: GrepReq,
255
317
  signal: AbortSignal | undefined,
256
318
  repoRooted: boolean,
257
- ): Promise<Map<string, RgMatchLine[]>> {
258
- const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
319
+ ): Promise<RgSearch> {
320
+ const args = ["--json", "--line-number", "--color=never", "--hidden", "--crlf", "--glob", "!.git"];
259
321
  if (!repoRooted) args.push("--no-require-git");
260
322
  const wanted = req.limit ?? 100;
261
323
  args.push("--max-count", String(wanted + 1));
@@ -267,7 +329,7 @@ async function collectRgMatches(
267
329
  if (req.literal) args.push("--fixed-strings");
268
330
  args.push("--", pattern, searchPath);
269
331
  const result = new Map<string, RgMatchLine[]>();
270
- return await new Promise<Map<string, RgMatchLine[]>>((resolve, reject) => {
332
+ return await new Promise<RgSearch>((resolve, reject) => {
271
333
  const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
272
334
  const rl = createInterface({ input: child.stdout });
273
335
  let stderr = "";
@@ -331,12 +393,16 @@ async function collectRgMatches(
331
393
  }
332
394
  if (code !== 0 && code !== 1) {
333
395
  const detail = clipLine(stderr.trim(), 300);
334
- reject(new Error(detail
335
- ? `[E_GREP_FAILED] ripgrep exited with code ${code}: ${detail}`
336
- : `[E_GREP_FAILED] ripgrep exited with code ${code}.`));
396
+ if (result.size === 0) {
397
+ reject(new Error(detail
398
+ ? `[E_GREP_FAILED] ripgrep exited with code ${code}: ${detail}`
399
+ : `[E_GREP_FAILED] ripgrep exited with code ${code}.`));
400
+ return;
401
+ }
402
+ resolve({ matches: result, ...(detail ? { skipped: detail } : {}) });
337
403
  return;
338
404
  }
339
- resolve(result);
405
+ resolve({ matches: result });
340
406
  });
341
407
  });
342
408
  }
@@ -541,7 +607,8 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
541
607
  const readGrepFile = makeGrepReader("real");
542
608
  const readGrepFileShadow = makeGrepReader("shadow");
543
609
  const repoRooted = await insideGitRepo(globRoot);
544
- const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
610
+ const rgSearch = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
611
+ const rgMatches = rgSearch.matches;
545
612
  const sortedFiles = [...rgMatches.keys()].sort(cmp);
546
613
  for (let f = 0; f < sortedFiles.length; f++) {
547
614
  abortIf(signal);
@@ -552,7 +619,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
552
619
  if (!matchesGlob(absPath)) continue;
553
620
  const norm = await readGrepFileShadow(absPath);
554
621
  if (!norm) continue;
555
- const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), fileMatches, context, totalForFile, fileMatches.length);
622
+ const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), toNormalizedMatches(norm, fileMatches), context, totalForFile, fileMatches.length);
556
623
  const display = displayRowsForHit(hit);
557
624
  totalRows += display.length;
558
625
  for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
@@ -574,7 +641,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
574
641
  if (!matchesGlob(absPath)) continue;
575
642
  const norm = await readGrepFile(absPath);
576
643
  if (!norm) continue;
577
- const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), fileMatches, context, totalForFile, Math.min(totalForFile, remaining));
644
+ const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), toNormalizedMatches(norm, fileMatches), context, totalForFile, Math.min(totalForFile, remaining));
578
645
  const display = displayRowsForHit(hit);
579
646
  const keptRows: string[] = [];
580
647
  const keptHashes: string[] = [];
@@ -624,6 +691,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
624
691
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
625
692
  if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
626
693
  if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
694
+ if (rgSearch.skipped !== undefined) notes.push(`[grep: some paths could not be searched, so these results are partial: ${rgSearch.skipped}]`);
627
695
  const truncated = limitTruncated || rowTruncated;
628
696
  const truncation: TruncationResult | undefined = rowTruncated
629
697
  ? {
@@ -113,9 +113,12 @@ export function fmtMismatchWithHashes(
113
113
  for (let ln = from; ln <= to; ln++) {
114
114
  const h = fileHashes[ln - 1]!;
115
115
  const c = fileLines[ln - 1] ?? "";
116
- hashes.push(h);
117
- servedMap.set(h, lineChecksum(c));
118
- rows.push(` ${ln}: ${h}│${clipLine(c)}`);
116
+ const shown = clipLine(c);
117
+ if (shown === c) {
118
+ hashes.push(h);
119
+ servedMap.set(h, lineChecksum(c));
120
+ }
121
+ rows.push(` ${ln}: ${h}│${shown}`);
119
122
  }
120
123
  out.push("");
121
124
  out.push(` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`);
@@ -441,12 +444,18 @@ export function assertRangeServed(
441
444
  const rows: string[] = [];
442
445
  const shownHashes: string[] = [];
443
446
  const shownMap = new Map<string, string>();
447
+ const clippedLines: number[] = [];
444
448
  for (let line = startLine; line < startLine + shownLength; line++) {
445
449
  const hash = fileHashes[line - 1]!;
446
450
  const content = fileLines[line - 1]!;
451
+ const shown = clipLine(content);
452
+ rows.push(fmtRow(hash, shown));
453
+ if (shown !== content) {
454
+ clippedLines.push(line);
455
+ continue;
456
+ }
447
457
  shownHashes.push(hash);
448
458
  shownMap.set(hash, lineChecksum(content));
449
- rows.push(fmtRow(hash, clipLine(content)));
450
459
  }
451
460
  const location = filePath ? ` in ${filePath}` : "";
452
461
  const rangeDescription = `the replaced range (lines ${startLine}-${endLine})${location}`;
@@ -457,7 +466,9 @@ export function assertRangeServed(
457
466
  const capHint =
458
467
  rangeLength > shownLength
459
468
  ? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read()${filePath ? ` on ${filePath}` : ""} with offset=${startLine + shownLength} to see the rest.]`
460
- : "\n\nRetry with the fresh anchors above without a read.";
469
+ : clippedLines.length > 0
470
+ ? `\n\n[${clippedLines.length === 1 ? `Line ${clippedLines[0]} is` : `Lines ${clippedLines.join(", ")} are`} clipped above and not editable from here; call read()${filePath ? ` on ${filePath}` : ""} before editing.]`
471
+ : "\n\nRetry with the fresh anchors above without a read.";
461
472
  const message =
462
473
  `[E_RANGE_STALE] ${mismatchText}. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
463
474
  throw new RangeStaleError(message, shownHashes, shownMap);
@@ -18,7 +18,7 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
18
18
  const parser = await loadOutlineParser();
19
19
  const parsed = parser === undefined ? undefined : await parser.parse(input.displayPath, input.content);
20
20
  if (parsed !== undefined && parsed.symbols.length > 0) {
21
- return renderSymbolOutline({
21
+ const outline = renderSymbolOutline({
22
22
  displayPath: input.displayPath,
23
23
  languageName: parsed.languageName,
24
24
  totalLines: lines.length,
@@ -29,8 +29,9 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
29
29
  ...(input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}),
30
30
  ...(input.maxBytes !== undefined ? { maxBytes: input.maxBytes } : {}),
31
31
  });
32
+ return parsed.hasSyntaxErrors ? { ...outline, hasSyntaxErrors: true } : outline;
32
33
  }
33
- return renderPreviewOutline({
34
+ const preview = renderPreviewOutline({
34
35
  displayPath: input.displayPath,
35
36
  lines,
36
37
  hashes: input.hashes,
@@ -38,6 +39,8 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
38
39
  ...(input.previewTailLines !== undefined ? { tailLines: input.previewTailLines } : {}),
39
40
  ...(input.maxBytes !== undefined ? { maxBytes: input.maxBytes } : {}),
40
41
  });
42
+ if (lines.length === 0) return preview;
43
+ return { ...preview, preview: parsed === undefined ? "unsupported" : "no-symbols", ...(parsed?.hasSyntaxErrors ? { hasSyntaxErrors: true } : {}) };
41
44
  }
42
45
 
43
46
  export { loadOutlineParser } from "./parsers";
@@ -9,6 +9,7 @@ export interface OutlineSymbol {
9
9
  export interface OutlineParse {
10
10
  symbols: OutlineSymbol[];
11
11
  languageName: string;
12
+ hasSyntaxErrors: boolean;
12
13
  }
13
14
 
14
15
  export interface OutlineParser {
@@ -24,7 +25,7 @@ async function load(): Promise<OutlineParser | undefined> {
24
25
  parse: async (path, content) => {
25
26
  if (parsers.detectLanguage(path) === null) return undefined;
26
27
  const parsed = await parsers.parseSourceFile(path, content);
27
- return { symbols: parsed.symbols, languageName: parsed.languageName };
28
+ return { symbols: parsed.symbols, languageName: parsed.languageName, hasSyntaxErrors: parsed.hasError === true };
28
29
  },
29
30
  };
30
31
  } catch (error) {
@@ -25,6 +25,8 @@ export interface OutlineRender {
25
25
  servedHashes: string[];
26
26
  truncated: boolean;
27
27
  complete: boolean;
28
+ preview?: "unsupported" | "no-symbols";
29
+ hasSyntaxErrors?: boolean;
28
30
  }
29
31
 
30
32
  export interface SymbolOutlineInput {
@@ -62,8 +62,13 @@ function bodyStart(node: Node): number {
62
62
  }
63
63
 
64
64
  function blockSymbol(node: Node, source: string): SymbolInfo | null {
65
+ const directive = node.namedChildren[0];
66
+ if (directive?.type === "directive_start" && (node.type === "conditional" || node.type === "loop")) {
67
+ const [startLine, endLine] = rangeOf(node);
68
+ return { name: parameterName(node, source), type: textOf(directive, source).slice(1), startLine, endLine };
69
+ }
65
70
  const label = BLOCK_LABELS[node.type];
66
- if (label === undefined) return null;
71
+ if (label === undefined || (node.type === "php_statement" && directive?.type !== "directive_start")) return null;
67
72
  const [startLine, endLine] = rangeOf(node);
68
73
  return { name: parameterName(node, source), type: label, startLine, endLine };
69
74
  }
@@ -21,6 +21,7 @@ export interface ParseFileResult {
21
21
  symbols: SymbolInfo[];
22
22
  language: string | null;
23
23
  languageName: string;
24
+ hasError?: boolean;
24
25
  }
25
26
 
26
27
  /**
@@ -38,8 +39,8 @@ export async function parseSourceFile(
38
39
  }
39
40
 
40
41
  try {
41
- const symbols = await parseCode(lang, content);
42
- return { symbols, language: lang, languageName: languageLabel(lang) };
42
+ const { symbols, hasError } = await parseCode(lang, content);
43
+ return { symbols, language: lang, languageName: languageLabel(lang), hasError };
43
44
  } catch (err) {
44
45
  console.warn(
45
46
  `[codebase-reader] Parse failed for ${filePath}:`,