Line-anchored edit patches are precise but brittle against reformatting. Structural edits operate on the AST instead: the same pattern matches foo( x ), foo(x), and foo(/* comment */ x). For symbol-aware renames that touch every importer (TypeScript-flavored, language-server backed), reach for lsp rename instead — it understands scope and re-exports in a way ast-grep does not.
ast_edit
ast_edit rewrites code structurally with ast-grep. Each op is { pat, out }: pat matches an AST shape, out is the replacement template. Patterns match structure, not text — whitespace and comments are ignored.
| Metavariable | Matches |
|---|---|
$A | One AST node, captured as $A for the template. |
$_ | One AST node, not captured. |
$$$ARGS | Zero or more nodes, captured as $$$ARGS for the template. |
$$$ | Zero or more nodes, not captured. |
Reusing the same metavariable forces identity: $A == $A matches x == x but not x == y. Language is inferred from the file extensions in paths; narrow each call to one language for deterministic rewrites.
# rename every callsite of legacyFn to newFn, preserving args
ast_edit ops=[{ pat: "legacyFn($$$ARGS)", out: "newFn($$$ARGS)" }] \
paths=["src/**/*.ts"]
# delete every console.log, regardless of argument shape
ast_edit ops=[{ pat: "console.log($$$)", out: "" }] paths=["src/"]
# rewrite a CommonJS require to a const binding
ast_edit ops=[{ pat: "$F = require($M)", out: "const $F = require($M)" }] \
paths=["src/"]
# modernize to optional chaining; identity enforced by $A
ast_edit ops=[{ pat: "$A && $A()", out: "$A?.()" }] paths=["src/"]Every ast_edit run stages as a preview. The TUI shows the diff and the replacement count; nothing hits disk until the model calls resolve with { action: "apply" } (see below). Gate the tool with astEdit.enabled; when on, it auto-enables alongside edit.
For one-off local text edits, prefer edit. For read-only structural search, reach for ast_grep: same pattern grammar, no rewrite, returns anchor-prefixed match lines with metavariable captures inlined.
conflict://
When a file contains git merge markers, read registers each <<<<<<< / ======= / >>>>>>> block as a virtual conflict://N URL. The agent picks a side and writes back; the splice happens in place.
| URL | Effect |
|---|---|
conflict://N | The Nth conflict block in the file. Write content to splice it in. |
conflict://* | Bulk form; resolves every block with the same content or shorthand. |
read path:conflicts | One-line-per-block index of every unresolved conflict in path. |
The shorthands @ours, @theirs, and @base stand in for the three sides of the merge. One line of @theirs is the whole edit when that side is the right one wholesale.
# 1. see what's unresolved
read src/session.ts:conflictsconflicts
# 2. pick a side for one block
write path="conflict://1" content="@theirs"
# 3. or resolve every block in the file the same way
write path="conflict://*" content="@ours"
# 4. mixed: read the block, write a hand-merged splice
read conflict://2
write path="conflict://2" content="const merged = { ...base, ...theirs, ...ours };
"No merge UI, no dedicated tool. The same read and write pair handles the whole flow.
resolve
resolve finalizes a pending action. Tools that stage changes (ast_edit, extension-provided previews, plan approval) queue a callback that must be applied or discarded before anything is written. The contract is { action: "apply" | "discard", reason }; the producer’s callback runs and its result becomes the tool response.
# 1. stage an AST rewrite (returns a (proposed) preview card)
ast_edit ops=[{ pat: "console.log($X)", out: "" }] paths=["src/auth.ts"]
# → 3 replacements in 1 file (proposed)
# 2. accept the preview
resolve action="apply" reason="redundant logging in auth path"
# → Applied 3 replacements in 1 file.
# 3. or reject it
resolve action="discard" reason="keep logs until after the release"Calling resolve with no pending action errors out, so the agent can’t accidentally accept a stale preview from a previous turn. Discards are single-call and atomic; there are no partial applies.
When to reach for which
ast_edit
Rewriting a syntactic shape across many files: renaming a call, swapping an API, deleting matching
statements. Ignores formatting.
lsp rename
Symbol-aware rename across every importer, respecting scope and re-exports. The right call when
ast-grep’s text-shape match would catch a same-named symbol from another module.
conflict://Merge resolution. Read to enumerate, write to splice, repeat until the file has no markers.resolve
Accept or reject any staged preview. One contract for ast_edit, plan approvals, and
custom-tool previews.
edit
Line-anchored patches against a specific slice you just read. Use when the change is local and the
surrounding text is stable.\n