ast_edit
Preview and apply structural rewrites over source files via native ast-grep.
Source
- Entry:
packages/coding-agent/src/tools/ast-edit.ts - Model-facing prompt:
packages/coding-agent/src/prompts/tools/ast-edit.md - Key collaborators:
crates/pi-natives/src/ast.rs— native rewrite planning and file mutationcrates/pi-ast/src/language/mod.rs— language aliases and extension inference used by the native wrapper.packages/coding-agent/src/tools/path-utils.ts— path/glob parsing and multi-path resolutionpackages/coding-agent/src/tools/resolve.ts— preview/apply queueingpackages/coding-agent/src/tools/render-utils.ts— parse-error dedupe and display capspackages/coding-agent/src/utils/file-display-mode.ts— hashline vs line-number diff referencespackages/hashline/src/format.ts— stable hashline header formatting for preview anchorspackages/natives/native/index.d.ts— JS-visible native binding contract
Inputs
| Field | Type | Required | Description |
|---|---|---|---|
ops |
{ pat: string; out: string }[] |
Yes | One or more rewrite rules. pat must be non-empty. Duplicate pat values fail before native execution. Empty out deletes the matched node. |
paths |
string[] |
Yes | One or more files, directories, globs, or internal URLs with backing files. Empty entries are rejected. Globs are forbidden for internal URLs. |
Shared AST pattern grammar and language catalog: see ast_grep.
ast_edituses the same$NAME,$_,$$$NAME, and$$$metavariable semantics.- The tool prompt adds rewrite-specific constraints:
- metavariable names must be uppercase and must stand for whole AST nodes,
- captures from
patare substituted intoout, - each rewrite is a 1:1 structural substitution; one capture cannot expand into multiple sibling nodes unless the grammar itself permits that expansion at that position.
Outputs
- Single-shot preview result from
ast_edititself. - Model-facing
contentis one text block showing proposed edits, grouped by file for directory/multi-file runs.- Each change renders as two lines. Hashline mode uses
-LINE:before/+LINE:afterunder a¶PATH#TAGheader; plain mode uses-LINE:COLUMN before/+LINE:COLUMN after. - Only the first line of each
before/aftersnippet is shown, truncated to 120 characters in the wrapper. Limit reached; narrow paths.and formatted parse issues are appended when applicable.
- Each change renders as two lines. Hashline mode uses
- If no rewrites match, text is
No replacements madeplus formatted parse issues when present. detailsincludes aggregate preview metadata:totalReplacements,filesTouched,filesSearched,applied,limitReached- optional
parseErrors,scopePath,files,fileReplacements,displayContent,meta
- The tool always previews first (
applied: falsein the direct result). Actual file writes happen only later throughresolve(action: "apply", ...). - When preview produced replacements,
ast_editalso queues a pendingresolveaction. Successful apply returns a separateresolveresult, not anotherast_editresult.
Flow
AstEditTool.execute()validates each op inpackages/coding-agent/src/tools/ast-edit.ts:- empty
patfails, - at least one op is required,
- duplicate
patvalues fail, - ops are converted to a
Record<pattern, replacement>.
- empty
- The wrapper reads
PI_MAX_AST_FILESvia$envpos(..., 1000)and uses that as the nativemaxFilescap for both preview and apply. - Path normalization, internal URL handling, missing-path partitioning, and multi-path resolution follow the same
path-utils.tsflow asast_grep. - The wrapper stats the resolved base path to decide whether to render grouped directory output.
runAstEditOnce(...)always runs nativeastEdit(...)withdryRun: trueandfailOnParseError: falseon the first pass.- Native
ast_editincrates/pi-natives/src/ast.rs:- normalizes the rewrite map and sorts rules by pattern string,
- resolves strictness (
smartby default), - collects candidate files from a file or gitignore-aware directory scan,
- infers a single language for the whole call unless
langwas supplied, - compiles every rewrite pattern for that language,
- parses each file, skips files with syntax-error trees, collects
replace_by(...)edits for every match, enforces replacement and file caps, and returns textual before/after slices plus source ranges.
- The TS wrapper deduplicates parse errors, groups changes by file, and renders preview diff lines.
- If preview found replacements and
appliedis false,queueResolveHandler(...)registers a forcedresolveaction and injects aresolve-remindersteering message. - On
resolve(action: "apply"), the queued callback reruns the same rewrite set withdryRun: false, recomputes counts, and returns an error result if the live result no longer matches the preview (stalePreview). The current implementation compares replacement totals and per-file counts after the rerun; if the new run has already written different counts, the result is marked error. - On a non-stale apply, the callback returns
Applied N replacements in M files.; on discard,resolvereturns a discard message without mutating files.
Modes / Variants
- Single file: preview or apply against one file.
- Directory + optional glob: native scan walks the directory, then filters by compiled glob.
- Multiple explicit paths/globs: wrapper unions them into one synthetic scope or runs per-target native calls when paths only meet at root.
- Internal URL inputs: only supported when the router resolves them to a backing file path.
- Preview mode: always the direct
ast_edittool result. - Apply mode: only reachable through the queued
resolvecallback after a preview. - Hashline output mode vs plain line/column mode: controlled by
resolveFileDisplayMode().
Side Effects
- Filesystem
- Preview reads files and scans directories.
- Apply rewrites files in place with
std::fs::write(...), but only when the computed output differs from the original source.
- Session state (transcript, memory, jobs, checkpoints, registries)
- Queues a one-shot forced
resolvetool choice throughqueueResolveHandler(...). - Adds a
resolve-remindersteering message.
- Queues a one-shot forced
- User-visible prompts / interactive UI
- Direct
ast_editresults are previews. - Follow-up apply/discard is exposed through the hidden
resolvetool.
- Direct
- Background work / cancellation
- Native preview/apply work runs on a blocking worker via
task::blocking(...). - Cancellation and optional native timeout are cooperative through
CancelToken::heartbeat().
- Native preview/apply work runs on a blocking worker via
Limits & Caps
- File cap exposed by the wrapper:
PI_MAX_AST_FILES, default1000, inpackages/coding-agent/src/tools/ast-edit.ts. - Native
maxFilesandmaxReplacementsare both clamped to at least1when provided incrates/pi-natives/src/ast.rs. - The wrapper never sets
maxReplacements; native behavior therefore defaults to effectively unbounded replacements for a run. - Parse issues are rendered with at most
PARSE_ERRORS_LIMIT = 20lines inpackages/coding-agent/src/tools/render-utils.ts;details.parseErrorsis deduplicated but not capped. - Directory scans use
include_hidden: true,use_gitignore: true, and skipnode_modulesunless the glob text explicitly mentionsnode_modulesincrates/pi-natives/src/ast.rs. - No separate glob-expansion count cap exists. Candidate count is whatever the resolved path/glob expands to after gitignore filtering, then native
maxFilesstops mutations after the configured number of touched files. - Preview text truncates each rendered
beforeandafterfirst line to 120 characters inpackages/coding-agent/src/tools/ast-edit.ts.
Errors
- TS wrapper throws
ToolErrorfor empty patterns, duplicate rewrite patterns, empty path entries, unsupported internal-URL globs, internal URLs withoutsourcePath, and missing paths. - Native code returns hard errors for:
- inability to infer one language across all candidates when
langis absent, - unsupported explicit
lang, - bad glob compilation or unreadable search roots,
- overlapping computed edits (
Overlapping replacements detected; refine pattern to avoid ambiguous edits), - out-of-bounds edit ranges or non-UTF-8 replacement text,
- write failures during apply,
- cancellation or timeout.
- inability to infer one language across all candidates when
- With
failOnParseError: false(the wrapper always uses this), pattern compile failures and file parse failures becomeparseErrorsinstead of aborting the whole run. - If every rewrite pattern fails to compile, native
ast_editreturns a successful zero-replacement result withparseErrorspopulated. - Files containing tree-sitter error nodes are skipped for rewriting; they do not get partial edits.
- Apply can fail after a successful preview if the preview becomes stale. The resolve callback compares replacement totals and per-file counts and returns an error result rather than silently reporting success for a mismatched preview.
Notes
ast_editdoes not expose the nativelang,strictness,selector,maxReplacements,failOnParseError, ortimeoutMsfields to the model. The runtime fixes the call shape to a preview-first, smart-strictness, best-effort parse mode.- Because the wrapper does not expose
lang, mixed-language rewrites only succeed when every candidate infers to the same canonical language. This is stricter thanast_grep. - Idempotency is not enforced syntactically. A rewrite like
foo($A) -> foo($A)previews zero changes because output equals input; a rewrite that keeps matching its own output may still produce replacements on repeated calls. - Rewrites are accumulated per file, then applied from the end of the file backward after an overlap check. Independent matches can coexist; overlapping matches abort the run.
- Native rewrite rule order is by pattern-string sort, not by the original
opsarray order, becausenormalize_rewrite_map(...)sorts the(pattern, rewrite)pairs. - Preview/apply parity is validated by totals and per-file counts after the apply rerun, not by a byte-for-byte diff of every replacement payload.