Skip to content
Riot Docs

Search is only available in production builds. Try building and previewing the site to test it out locally.

Install Riot GitHub

RFD0019 - Riot Fix Syntax-Directed Rewrites

  • Feature Name: riot_fix_syntax_directed_rewrites
  • Start Date: 2026-03-23
  • Status: implemented

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.

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 with x”
  • “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.

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:

  1. Delete
  2. Replace
  3. Insert_before
  4. Insert_after
  5. Swap

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.

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.”

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 ->
operation
end

This RFD does not require changing the execution backend immediately.

The runtime may still lower operations into validated text edits internally:

  • Delete lowers to a zero-text replacement over the target span
  • Replace lowers to a span replacement using the source slice of the replacement payload
  • Insert_before and Insert_after lower to zero-width insertions at anchor boundaries
  • Swap lowers to two non-overlapping replacements

The important change is not how fixes are applied. The important change is how rules express them.

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.

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.

Once this API exists, fix-producing rules should:

  1. prefer node/token operations over manual span edits
  2. reuse existing source via Source_of_node / Source_of_token whenever possible
  3. keep edits paired with diagnostics so the rewrite remains understandable
  4. treat literal text replacement as an escape hatch, not the normal path

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.

Rejected. That keeps rule authors working below the syntax level they already matched, which is exactly the mismatch this RFD is trying to remove.

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.

The most relevant prior art here is Tree-sitter-based tooling and Rust’s linting/refactoring ecosystem.

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 offers a useful split across several tools:

  • rustc diagnostics can attach machine-applicable suggestions
  • rustfix can apply those suggestions in bulk
  • rust-analyzer exposes 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.

  1. Should Text stay in the public replacement type, or move behind a more explicit escape hatch API once node-based rewriting is established?
  2. Should target remain Node | Token, or should token-targeted rewrites eventually become a thin derived helper over node operations?
  3. How should future synthetic node construction integrate with this rewrite model without overloading Fix itself?