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 +17 -8
- package/package.json +1 -1
- package/src/auto-read-all.ts +5 -5
- package/src/edit-common.ts +1 -1
- package/src/grep.ts +78 -10
- package/src/hashline/resolve.ts +16 -5
- package/src/outline/index.ts +5 -2
- package/src/outline/parsers.ts +2 -1
- package/src/outline/render.ts +2 -0
- package/src/outline/vendor/parsers/blade.ts +6 -1
- package/src/outline/vendor/parsers/index.ts +3 -2
- package/src/outline/vendor/parsers/manager.ts +280 -259
- package/src/outline/vendor/parsers/sass.ts +15 -9
- package/src/outline-tool.ts +205 -7
- package/src/prompts.ts +1 -1
- package/src/read.ts +2 -1
- package/src/replace-undo.ts +13 -0
- package/tool-prompts/outline-guidelines.md +2 -2
- package/tool-prompts/outline-snippet.md +1 -1
- package/tool-prompts/outline.md +6 -2
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
|
|
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
|
|
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` |
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|
package/src/auto-read-all.ts
CHANGED
|
@@ -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 });
|
package/src/edit-common.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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
|
? {
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
:
|
|
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);
|
package/src/outline/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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";
|
package/src/outline/parsers.ts
CHANGED
|
@@ -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) {
|
package/src/outline/render.ts
CHANGED
|
@@ -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}:`,
|