Desugar
Note
Status: implemented. The third
pipeline stage: an ordered list of
local rewrite rules over the Dialogue AST — assemble jumps, recognize control
lines, fill the default speaker — returning a DesugaredScriptDocument.
Table of contents
- Goal and scope
- Ubiquitous language
- The rules
- The rewriter
- Key design decisions
- Error and boundary cases
- Testability
Goal and scope
The transpiler is a local tokenizer and leaves three things in pieces. Desugar composes them, still with local information only:
| Before | After |
|---|---|
Line(Alice, [Text "Go ", JumpIndicator, Text " ", Link([Text "on"], "#next")]) |
Line(Alice, [Text "Go ", Jump([Text "on"], "#next")]) |
Line(null, [JumpIndicator, Link(…)]) |
ControlLine([Jump(…)]) |
Line(null, [CustomCommand GiveGold("5")]) |
ControlLine([CustomCommand …]) |
Line(null, [Text "The wind blows."]) |
Line(DefaultSpeaker, [Text "The wind blows."]) |
Anything that needs the whole document — which speaker is the default, merging a partial declaration's tags, nesting scenes, resolving targets — is the semantic analyzer.
Ubiquitous language
| Term | Meaning |
|---|---|
| Rule | One normalization over the whole tree, an IDesugarRule. |
| Rewriter | DialogueAstRewriter: an immutable, clone-by-default tree transform. |
| Jump | A Link folded with its preceding => (and any condition before it): label, unresolved target, optional condition. |
| Dangling arrow | A => with no link after it; degraded to Text("=>"). |
| Control line | An effect-only line with no speaker (see Control Line). |
| DefaultSpeaker | The sentinel for "whatever the default speaker is", resolved by analysis. |
| DesugaredScriptDocument | The desugared tree, typed so analysis cannot receive a raw one. |
The rules
ScriptDesugarer builds a Desugarer per compilation through
DesugarerFactory.CreateDefault(sink) and runs the rules in order, each feeding the
next:
| # | Rule | Does | Needs |
|---|---|---|---|
| 1 | JumpAssemblyRule (via JumpAssembler) |
In every fragment list, fold [Condition] JumpIndicator (same-line whitespace) Link into a Jump; degrade a dangling arrow and report DLG1113. |
— |
| 2 | ControlLineRecognitionRule |
A speaker-less line whose speech is only jumps, commands, breaks, and blank text becomes a ControlLine. |
assembled jumps |
| 3 | DefaultSpeakerRule (via DefaultSpeakerFiller) |
A remaining speaker-less Line gets DefaultSpeaker at a zero-width span at the line start. |
control lines already removed |
After desugar, no JumpIndicator survives and no Line has a null speaker.
The rewriter
DialogueAstRewriter rebuilds each node from its rewritten children, an identity
transform by default. It has a protected virtual hook for every node kind that
holds children — blocks, lines, control lines, speakers, scene headings, choices,
random choices and options, control blocks and branches, every fragment list, each
fragment, and tags — so a rule overrides only the hook it changes and never repeats
the traversal. A null speaker is handled at the Line, so RewriteSpeaker only sees
a present one. DesugarRule is the base: a rewriter whose Apply rewrites the whole
document.
Key design decisions
D1 — Local rewrites only
Every rule decides from adjacent fragments or one line, so desugar is a total
ScriptDocument → ScriptDocument function and never needs the document-wide
tables analysis builds.
D2 — An ordered rule list
Each normalization is one independently tested rule; a new one is one entry in the factory's list. The order is a dependency: control-line recognition needs assembled jumps, and must run before the default-speaker fill so an effect is never attributed to a speaker.
D3 — A jump is single-line, and a dangling arrow degrades
Only blank, same-line text may sit between => and its link; a LineBreak ends
the scan. A dangling arrow becomes Text("=>") at the arrow's own span, and the
warning is reported here, the one place that still knows it was an arrow (see
Dangling Arrow Diagnostic). The
rule list is built per compilation because JumpAssemblyRule holds that
compilation's sink while ScriptDesugarer is a singleton.
D4 — The default speaker is a zero-width sentinel
DefaultSpeaker has no source text, so it carries SourceSpan.EmptyAt(line start):
a tool renders a caret there rather than underlining the whole line.
D5 — Same node types, a document-level stage marker
Desugar adds no parallel "normalized AST"; Jump, ControlLine, and
DefaultSpeaker are ordinary Dialogue AST nodes that appear only after it.
DesugaredScriptDocument wraps the result so the pipeline type-checks in order: the
marker attests that desugar ran, and tests uphold the invariants.
Error and boundary cases
| Case | Behavior |
|---|---|
the => arrow |
=> degrades to Text at its own span; DLG1113 warning. |
=> x [a](#b) |
Dangling: only whitespace may separate => from the link; the link stays bare. |
=> then a line break then a link |
Dangling; the link stays bare. |
=>[a](#b) |
Assembled; no whitespace to fold. |
| Several jumps in one speech | Each pair assembled; DLG1003 later flags content after the first. |
=> inside StyledText, a choice, or a branch |
Assembled or degraded in place. |
| A line with a speaker | Unchanged. |
| A speaker-less line with spoken content | Gets DefaultSpeaker. |
| Declarations, references, partial declarations | Unchanged; resolved by analysis. |
| Empty document | Unchanged. |
Testability
- Rules are tested alone on hand-built fragments and lines, asserting with
DialogueAstAsserthelpers such asAssertJump. - Rewriter: an identity subclass leaves every node kind equal, and small subclasses that change one kind prove the hooks reach everywhere.
- Desugarer: end to end on transpiled scripts, asserting the invariants.