The internals of Rill's streaming engine: the AST, the match that locates and reuses unchanged content, and how the same identity gates rendering. Claims below are cited at
file:line.
Rill is a streaming Markdown engine. The design answers one question: when text
is appended, how is re-parsing and re-rendering the whole document avoided? The
approach is to do work proportional to the dirty tail (the unfinished part at
the end) rather than the whole buffer — O(tail) instead of O(document).
The package is four layered products, but the AST and matching logic live almost
entirely in RillCore:
RillAnalytics Foundation only · metrics, protocol, sinks
RillMath Foundation only · LaTeX tokenizer + math box layout
RillCore Foundation only · AST + incremental parser → depends on RillAnalytics
RillUI SwiftUI · renders AST → views, theming, math, syntax highlight
→ depends on RillCore, RillMath, RillAnalytics
No module except RillUI imports SwiftUI/UIKit (compiler- and test-enforced).
The parsed document is a tree of pure value types. The top of it is Block
(Sources/RillCore/Model/Block.swift) — an indirect enum with one case per
Markdown construct:
public indirect enum Block: Sendable, Hashable {
case heading(Heading)
case paragraph(Paragraph)
case blockQuote(BlockQuote)
case alert(Alert)
case list(List)
case codeBlock(CodeBlock)
case table(Table)
case thematicBreak
case mathBlock(MathBlock)
case htmlBlock(HTMLBlock)
case footnoteDefinition(FootnoteDefinition)
}It is Sendable + Hashable, pure data, with no view-layer types anywhere.
Each Block exposes a content-derived identity, NodeID
(Sources/RillCore/Model/NodeID.swift). Everything downstream depends on it:
- It is a seedless FNV-1a 64-bit digest of the block's case tag plus
payload — deliberately not
Swift.Hasher, whose seed is randomized per process. Rill needs the digest to be identical across launches and machines so identity is reproducible. - Each enum case folds in a per-case discriminator tag first, so
.code("x")and.text("x")can never collide (Block.hash(into:),Block.swift:61). - Strings are length-framed before their bytes, so concatenation
ambiguities (
"ab" + "c"vs"a" + "bc") cannot alias (ContentHasher.combine(_:),NodeID.swift:56). - Optionals fold a tag that distinguishes
nilfrom""/0/false.
The single guarantee that everything downstream relies on:
Equal content → equal
NodeID.
Block intentionally does not conform to Identifiable: two --- rules
share a NodeID, so it is a content key, not a position key. Render a
[Block] with a position-based key, e.g.
ForEach(Array(blocks.enumerated()), id: \.offset).
The streaming engine is IncrementalParser
(Sources/RillCore/Streaming/IncrementalParser.swift). It holds the buffer plus
a frozen prefix:
| State | Meaning |
|---|---|
buffer: [UInt8] |
All text consumed so far, as UTF-8 bytes |
commitIndex: Int |
Byte offset past the last definitively-closed block |
committedBlocks: [Block] |
The frozen, fully-resolved blocks for [0, commitIndex) |
memo: [NodeID: Block] |
Content-keyed cache of resolved blocks |
A delta arrives via consume(delta:at:) (append-delta wire format) or
consume(snapshot:) (full growing snapshot). Locating the work is two matches
stacked:
commonPrefixLength(old, new) (IncrementalParser.swift:204) walks both byte
arrays until they diverge, returning dirtyStart — the first byte that actually
changed:
private static func commonPrefixLength(_ a: [UInt8], _ b: [UInt8]) -> Int {
let n = min(a.count, b.count)
var i = 0
while i < n, a[i] == b[i] { i += 1 }
return i
}For append-only streaming dirtyStart is simply the old buffer length, so
nothing before it is touched. It also makes re-emitting the same (delta, offset) pair idempotent.
CommitBoundary.lastClosedIndex(in:)
(Sources/RillCore/Parser/CommitBoundary.swift) scans the dirty tail
line-by-line and asks: what is the last point I can freeze forever? A position
is a safe boundary only when nothing arriving later could change it:
- paragraph closed by a blank line,
- fenced code closed by its matching closing fence (at the same indent),
- math block closed by
$$/\], - heading / thematic break — single-line atoms that cannot be extended.
Constructs that block committing keep the tail open:
- an unterminated fence or math block — nothing past it commits,
- the trailing paragraph / list / table — a future line could extend it via lazy continuation or a new table row.
The subtle rules all exist to preserve streaming equivalence (see §4). For
example, the scanner will not commit across a blank line if the next non-blank
line starts a list marker, because that blank may be a loose-list separator
binding two sibling items into one list — committing there would split the list
and diverge from a one-shot parse (CommitBoundary.swift:85,
nextNonBlankStartsList). Indented-code interior blanks, fences opened inside a
list item, and unterminated trailing lines are handled with the same
conservative discipline.
parse(dirtyStart:) (IncrementalParser.swift:117) ties the two matches
together:
- Roll back if needed. If
dirtyStart < commitIndex(an edit landed inside frozen territory), drop the committed prefix and recommit from the change point. Thememosurvives — it is content-keyed, so unchanged blocks re-encountered during recommit reuse their cached values (rollBack(to:),IncrementalParser.swift:191). - Commit newly-closed blocks. Lex only
[commitIndex, newBoundary), and for each closed block checkmemo[block.id]before resolving its inlines. Identical bytes ⇒ cache hit ⇒InlineParsernever runs again for that block (IncrementalParser.swift:144). - Re-lex the open tail. Lex
[commitIndex, end)fresh — this is the only part that is actuallyO(tail). - Publish + measure. Set
document = committedBlocks + tailBlocks, fireonUpdate, and emitParseMetrics(dirtyTailBytes,totalBytes,blocksCommitted,blocksReused).
So the mechanism is a byte-prefix match to locate the change, plus a NodeID
content match to reuse parse work for anything unchanged.
The identity that saved parse work also gates SwiftUI re-rendering.
EquatableBlockView (Sources/RillUI/Blocks/EquatableBlockView.swift) declares
two views equal iff their blocks share a NodeID:
public nonisolated static func == (lhs: EquatableBlockView, rhs: EquatableBlockView) -> Bool {
lhs.block.id == rhs.block.id
}Wrapped in an EquatableView, SwiftUI elides body evaluation for any
committed block whose identity is unchanged. Only the live tail block — whose
content, and therefore NodeID, changed — re-renders. The render environment
(theme/config/analytics) is deliberately excluded from equality so it does
not defeat skipping.
DocumentRenderPlan (Sources/RillUI/Blocks/DocumentRenderPlan.swift) mirrors
this decision purely from the model — diffing the current document's block ids
against the previous pass's using a Multiset<NodeID> so two identical sibling
blocks match one-for-one rather than collapsing to a single skip. This is what
the analytics HUD reports as "blocks reused vs re-rendered"; the counts are
derived from the model, not from the view layer.
delta ─▶ commonPrefixLength ─▶ dirtyStart
─▶ (rollBack if dirtyStart < commitIndex)
─▶ CommitBoundary.lastClosedIndex ─▶ newBoundary
─▶ commit [commitIndex, newBoundary):
BlockLexer.lex → memo[NodeID] (hit?) → InlineParser (miss only)
─▶ re-lex open tail [commitIndex, end) ← the only O(tail) work
─▶ Document(committedBlocks + tailBlocks)
─▶ SwiftUI: EquatableBlockView gated by NodeID → tail-only re-render
For any input and any chunking, the final Document is byte-identical
to a one-shot parse:
IncrementalParser(streamed in any chunks) == InlineParser.resolveInlines(in: BlockLexer.lex(...))
Every conservative rule in CommitBoundary exists to preserve this property:
the engine would rather keep something in the live tail than commit it wrong.
The invariant is property-tested over a corpus plus hundreds of
randomly-assembled documents, each fed byte-by-byte, in random chunks, and as
growing snapshots, with UTF-8-boundary fuzz.
A full-reparse renderer does O(document) work per delta, so the per-delta cost
climbs as the response grows and finished content re-renders on every token.
Rill's per-delta cost is O(dirty tail), and the committed prefix is both
parse-memoized (memo[NodeID]) and render-skipped (EquatableBlockView), so the
per-delta cost does not grow with the length of the committed document.
See ../Benchmarks/RESULTS.md for measurements and
COMPARISON.md for the comparison with
microsoft/SwiftStreamingMarkdown.