RFD0019 - Riot Fix Syntax-Directed Rewrites
- Feature Name:
riot_fix_syntax_directed_rewrites - Start Date:
2026-03-23 - Status:
implemented
Summary
Section titled “Summary”This RFD proposes changing the public fixme fix authoring surface from
raw text edits to syntax-directed rewrite operations.
Rules should describe rewrites in terms of CST-backed syntax objects:
- delete this node
- replace this node with that node
- insert this syntax before or after that syntax
- swap these two syntax objects
The runtime may still lower those operations into text edits internally, but that lowering should be an implementation detail, not the API that rules write against.
Motivation
Section titled “Motivation”Today many fixes are authored as spans plus replacement text. That works, but it is the wrong abstraction level for rule authors.
Rules do not reason in byte offsets. They reason in syntax:
- “replace the outer
List.rev (List.rev x)call withx” - “replace this operator token with
!=” - “delete this wrapper node”
When a rule has already matched a CST node, forcing it to drop back down into manual span plumbing is unnecessary ceremony and a source of bugs. It makes rule code longer, more fragile, and less obviously correct.
The fix API should reflect the syntax-directed model that the CST already gives us.
Guide-level explanation
Section titled “Guide-level explanation”Rule authors should think of a fix as a small list of syntax operations, not a list of hand-written text edits.
The core public operations should be:
DeleteReplaceInsert_beforeInsert_afterSwap
Replace is intentionally broad. It already covers common derived patterns:
- unwrap: replace outer node with one of its children
- wrap: replace a node with some larger node, once node construction exists
The payload for a replacement or insertion should initially come from syntax that already exists in the current tree:
- existing node
- existing token
Literal replacement text may remain as an escape hatch while the system is still growing, but it should not be the conceptual center of the API.
Example: List.rev (List.rev xs)
Section titled “Example: List.rev (List.rev xs)”Instead of constructing a text edit manually, the rule should say:
- target: the outer apply expression
- replacement: the inner payload expression
Conceptually:
Fix.make ~title:"Replace List.rev (List.rev xs) with xs" ~operations: [ Fix.replace_node ~target:(Syn.Cst.Expression.syntax_node outer) ~replacement:(Syn.Cst.Expression.syntax_node inner); ]That is much closer to the rule’s actual meaning than “replace span X..Y with these bytes.”
Reference-level explanation
Section titled “Reference-level explanation”1. Public API shape
Section titled “1. Public API shape”The public fixme surface should move toward a shape like:
module Fix : sig type target = | Node of Syn.Cst.syntax_node | Token of Syn.Cst.syntax_token
type replacement = | Source_of_node of Syn.Cst.syntax_node | Source_of_token of Syn.Cst.syntax_token | Text of string
type operation = | Delete of { target : target; } | Replace of { target : target; replacement : replacement; } | Insert_before of { anchor : target; content : replacement; } | Insert_after of { anchor : target; content : replacement; } | Swap of { left : target; right : target; }
type fix = { title : string; operations : operation list; }
val source_of_node : Syn.Cst.syntax_node -> replacement val source_of_token : Syn.Cst.syntax_token -> replacement val text : string -> replacement
val delete : target:target -> operation val replace : target:target -> replacement:replacement -> operation val insert_before : anchor:target -> content:replacement -> operation val insert_after : anchor:target -> content:replacement -> operation val swap : left:target -> right:target -> operation
val replace_node : target:Syn.Cst.syntax_node -> replacement:Syn.Cst.syntax_node -> operationend2. Internal lowering
Section titled “2. Internal lowering”This RFD does not require changing the execution backend immediately.
The runtime may still lower operations into validated text edits internally:
Deletelowers to a zero-text replacement over the target spanReplacelowers to a span replacement using the source slice of the replacement payloadInsert_beforeandInsert_afterlower to zero-width insertions at anchor boundariesSwaplowers to two non-overlapping replacements
The important change is not how fixes are applied. The important change is how rules express them.
3. Scope of this RFD
Section titled “3. Scope of this RFD”This RFD is intentionally limited to rewrite operations.
It does not propose a full synthetic-node construction or rendering system. That is a separate problem.
For now, replacements and insertions can primarily reuse syntax that already exists in the current tree. That is enough for a large class of useful rules:
- simplifications
- deletions
- unwraps
- operator replacements
- source-preserving subtree moves
If Riot later wants fixes that synthesize fresh syntax, that should be designed as a separate node-construction layer instead of being smuggled into the core rewrite API.
4. Why Wrap and Unwrap are not primitives
Section titled “4. Why Wrap and Unwrap are not primitives”Wrap and Unwrap are useful conceptual patterns, but they do not need to be
primitive operations.
They are both specific cases of Replace:
- unwrap: replace outer syntax with existing child syntax
- wrap: replace syntax with a newly constructed larger syntax node
Keeping the core operation set small makes the API easier to learn and keeps the runtime simpler.
5. Authoring conventions
Section titled “5. Authoring conventions”Once this API exists, fix-producing rules should:
- prefer node/token operations over manual span edits
- reuse existing source via
Source_of_node/Source_of_tokenwhenever possible - keep edits paired with diagnostics so the rewrite remains understandable
- treat literal text replacement as an escape hatch, not the normal path
Drawbacks
Section titled “Drawbacks”The runtime still has to lower syntax-directed operations into text edits for now, so the implementation becomes slightly more layered.
This also means the API can only be as expressive as the current replacement payload model. Until Riot adds a proper synthetic node construction story, insertions and replacements with entirely new syntax will still need either literal text or a later builder layer.
Rationale and alternatives
Section titled “Rationale and alternatives”Keep raw text edits as the public API
Section titled “Keep raw text edits as the public API”Rejected. That keeps rule authors working below the syntax level they already matched, which is exactly the mismatch this RFD is trying to remove.
Add many more primitive operations
Section titled “Add many more primitive operations”Rejected for now. A small operation vocabulary with a broad Replace
primitive is easier to keep consistent.
Design synthetic node construction in the same RFD
Section titled “Design synthetic node construction in the same RFD”Rejected for now. That is a distinct design problem and would make this RFD much larger and harder to land incrementally.
Prior art
Section titled “Prior art”The most relevant prior art here is Tree-sitter-based tooling and Rust’s linting/refactoring ecosystem.
Tree-sitter
Section titled “Tree-sitter”Tree-sitter-based tools usually operate in three steps:
- match on a structured tree
- describe a structural rewrite
- lower to source edits at the backend boundary
That is close to what Riot wants as well, with one important difference: Tree-sitter itself usually stops at nodes plus byte ranges, so many downstream tools still make users author edits in terms of raw text ranges.
This RFD deliberately goes one level higher. Riot already has CST nodes backed by exact Ceibo source ranges, so rule authors should be able to express rewrites in terms of syntax objects instead of manually reconstructing spans.
Rust linting and rewriting
Section titled “Rust linting and rewriting”Rust offers a useful split across several tools:
rustcdiagnostics can attach machine-applicable suggestionsrustfixcan apply those suggestions in bulkrust-analyzerexposes syntax-directed assists and refactorings
The lesson is not that Riot should copy any one API exactly. The lesson is that rewrites are strongest when:
- they are authored at the syntax level
- they stay attached to diagnostics or refactoring intent
- text edits are treated as an execution format, not the authoring model
The specific operation vocabulary in this RFD stays intentionally small because Riot already has exact source slices available through the lossless Ceibo tree and does not yet need a larger synthetic rewrite language.
Unresolved questions
Section titled “Unresolved questions”- Should
Textstay in the public replacement type, or move behind a more explicit escape hatch API once node-based rewriting is established? - Should
targetremainNode | Token, or should token-targeted rewrites eventually become a thin derived helper over node operations? - How should future synthetic node construction integrate with this rewrite
model without overloading
Fixitself?