Skip to content

rust: run Markdown on the native engine - #2049

Open
simonklee wants to merge 66 commits into
track/m1-markdown-enginefrom
track/m2-rust-markdown-engine
Open

simonklee wants to merge 66 commits into
track/m1-markdown-enginefrom
track/m2-rust-markdown-engine

Conversation

@simonklee

Copy link
Copy Markdown
Member

Stacked on #2037 (the native Markdown engine) and #2030 (with #2025–#2027, highlight layers). Merge those first. The base is #2037's branch, so until #2025–#2030 merge, the diff also shows their changes.

The Rust Markdown widget parsed with its own line parser. Each append parsed the open tail again and a streamed
list parsed the whole list again (564 times the bytes of a 34 KB list, about 5,000 allocations per 30-byte
append), and its output drifted from Core's.

The widget now runs on the native engine through a new ot_markdown_* ABI. The engine reads each byte once,
reports the blocks that changed, and writes streamed paragraphs and headings to their text nodes itself; the
widget builds or updates only what changed. A list append takes 26 allocations, flat, and set_markdown with
text that extends a stream appends the new tail. The public Rust API is unchanged; output changes where the spec
decides (- - - rules, setext headings).

Tests: an ABI property test whose host learns blocks only from read_changes and must match a fresh document
after every call under every option set, with a depth-2 sweep and an allocation-failure row; seeded and
exhaustive Rust models against a fresh widget, on terminals with and without hyperlinks.

Each highlight add rebuilt its line's spans from all of the line's records, so k adds on one line cost O(k^2) (record 076: 4,000 adds took 5.95G instructions). Spans are now a cache stamped with span_generation and built at paint by one sweep with a priority heap. Byte-addressed layers of group-set runs, which follow edits and empty on whole-text changes, feed the same builder; the highlight transaction and dirty-line set go. Editor highlights no longer invalidate the Yoga measure.
A replacement re-seeked its insert cell in the deleted text, where a joined combining mark moved it, and a layer range edge inside a character cut a run into invalid UTF-8; both now hold, asserted after every layer change. Span entries were a dense list, so one painted row deep in a document allocated an entry per earlier line; they are now keyed by the rows read. Group-set name bounds report the limit error, a no-op layer call keeps the span stamp, and three dead stamp bumps go. The span model now covers styled text, wrap, scroll, bg, attributes and links, and checks every paint against a forced full repaint.
C3.1 built byte-addressed highlight layers and Context group sets, but
only tests could reach them. A host producer such as the Rust editor
still had to add one display-column highlight per syntax range.

ot_context_group_set interns an ordered list of style names as a
Context-wide id. ot_text_buffer_set_layer and ot_edit_buffer_set_layer
replace a byte range of one layer with sorted runs that name those ids,
in one transaction. A fifth layer or a group set past its bounds fails
with OT_OBJECT_LIMIT; malformed runs fail with OT_INVALID_ARGUMENT and
change nothing.

The ABI is additive: three calls and the 12-byte ot_highlight_run. The
source grows by the three entry points, their Context methods and two
status mappings; the next commit moves the Rust editor onto them and
deletes its per-range loop.

A seeded test drives both buffer kinds through the C ABI against a
byte model and the header's rules: 4,096 calls, every malformed class
rejected at least 64 times with the layers and span stamp unchanged.
Each syntax refresh of a Rust editor removed its highlights and added
one back per styled range, measuring every range's display width
through FFI first. On a 1,149-line Rust file that is 3,298 highlight
calls and 6,443 measure calls per refresh.

The editor now sends the ranges as byte runs of one highlight layer, in
one ot_edit_buffer_set_layer call. Each style becomes a one-name group
set, cached per style. Style names are now made of the style's fields,
because a group set name is limited to 64 bytes and the Debug form of a
style is longer. The per-range loop and its width conversion go.

The refresh outside tree-sitter drops from about 13.0 million to 5.0
million instructions on that file. Frames stay the same: a seeded twin
test drives the old per-range path and the layer through random edits,
filetype, theme and style-table changes, clear_highlights and extmarks,
and compares every frame cell for cell. tests/highlight.rs is unchanged.

Source additions: EditBuffer::set_layer and Context::group_set wrap the
new ABI; style_name keeps names within the group set limit.
ot_text_buffer_set_layer had no consumer: no Rust wrapper, no Core
caller, and the Markdown engine sets its layers inside native code. The
design lands no ABI without one, so the call goes until its first
consumer needs it.

The C ABI fuzz replaced C3.1's rows for run ends and range ends inside
a character but only moved starts, so dropping either end check passed.
It now moves any of the four edges, passes NULL with no runs as a valid
clear, and has directed rows on one three-byte character.

A group-set name over 64 bytes that is also not UTF-8 failed with
OT_INVALID_ARGUMENT after a full scan. The length bound is now checked
first, and the UTF-8 check moves into the per-name loop.
Every new theme color interns a group set that is never freed, and a
failed intern went to App::report, which ends the app. A Rust editor
that kept switching to themes with new colors ended after about 200
switches; main never ended an app on a theme change. The editor now
interns without reporting: past the bound, runs of new styles paint in
the default style and the app keeps running.

The frame twin skips, by rule, frames where the old path's piece widths
do not add up to the text's width: a grapheme cluster across two
captures made the old columns drift, and the layer paints them right. A
separate row checks those colors, and the twin's pieces now include such
clusters. The public layer setter says the editor widgets own layer 1.
A syntax style had no strikethrough field, and every path that turned
a style into attributes copied bold, italic, underline and dim only. A
theme entry { strikethrough: true } was dropped without an error, so
Markdown and Code hid the tildes of ~~x~~ and drew x plain, while the
Rust widget strikes it through.

StyleDefinition, StyleDefinitionInput and theme token styles gain
strikethrough. Registration, mergeStyles, fromTheme, the tree-sitter
cascade and Markdown's chunks now pass the whole definition to
createTextAttributes, and the attribute copies loop over one list of
names, so the next attribute cannot be dropped in one place only.

Tests exceed the budget by the first paint check of strikethrough: the
Markdown theme switch now draws ~~x~~ in prose and in a table cell.

Issue: 578
A group set ORed the attribute bits of its names, so a later name could
add bold but never take it away: { bold: false } and {} composed the
same over a bold name, and the Markdown engine's nested sets (strong
inside a link, a theme that unsets one attribute) could not be drawn.

Registration now records which of the eight attribute bits a style
supplies. With the new OT_EDITOR_STYLE_ATTRIBUTE_MASK flag, bits 16..23
of attributes carry that mask; old callers send no mask, and a style
with attributes then supplies all eight bits, so its stored definition
and its paint by style id are what they were. Composition takes fg, bg
and each supplied bit from the later name. A value bit outside the mask
fails.

A seeded test registers random partial themes through the C ABI, half
of them as old callers, and checks 10,000 compositions of up to eight
names against a fold written from the design's rule, over all eight
bits; it runs in about 0.12 s in a debug build.

Source additions: the mask's flag, field, decode and fold step (the
ABI gains one flag; no call changes).
Core keeps a style's attributes as optional booleans, but registration
sent native one attribute byte, so { bold: false } and {} arrived the
same. Group sets composed natively need the difference: an unset
attribute lets an earlier name show through, a false one clears it.

registerStyle now sends which attributes the style sets, through the
new mask flag. Paint by style id and every Core renderable draw as
before; only native group-set composition reads the mask.

Source additions: the optional mask field and its encoding (one flag
and one shifted byte in the existing record).
The tree-sitter cascade built a one-field object for every defined
field of every matching capture to pass to Object.assign; it now sets
the field on the local style directly. createTextAttributes already
treats a missing style as no attributes, so its callers need no guard.
The editor style encoder packs the attribute byte and the supplied
mask into one word, but took any number for either. A value of 0x10001
with an empty mask reached native as bold both set and supplied, and a
mask of 65536 or 1.5 became a different, valid mask, so native could
not reject it. With a mask, both values must now be integers from 0 to
255.

The registration test now checks the C record that reaches native,
flags and packed word, rather than the object Core passes to its
binding: dropping the mask or always sending 0xff passed before and
changes a painted cell.

Source additions: the byte check (the encoder had none for the mask).
With a theme that strikes markup.strikethrough, Markdown still drew
some struck text plain where Code on the same document struck it. A
table header cell replaced its inline attributes with the heading's,
so ~~head~~ lost its strike, and the children of ~~...~~ that have a
style of their own (~~**x**~~) took only that style.

A header cell now keeps an inline strike unless the heading style sets
strikethrough itself, and a struck span marks all its children struck.
No other attribute or color changes.

Source additions: one mask for the header and one loop for the span's
children (no attribute reached them before).

Issue: 578
Rust's Markdown widget strikes ~~x~~ through unless its styles say
otherwise; Core drew it plain unless the theme asked. OpenCode's theme
gives markup.strikethrough only a muted color, so users saw deleted
text that looked like any other muted text.

SyntaxStyle.getStyle now adds strikethrough to markup.strikethrough
when the theme leaves it unset, so Markdown, Code on Markdown and
mergeStyles strike it. A theme that sets strikethrough: false keeps it
plain. With no style for the scope at all, the text keeps the default
colors it had. Other scopes that share a theme rule, such as unchecked
task boxes, do not change.

Source additions: the default (Core had no Markdown style defaults).
mergeStyles cached its result under the names joined with ":", so
mergeStyles("a:b") and mergeStyles("a", "b") shared one entry, and so
did no names and one empty name; whichever ran first answered both. The
key is now the list itself, serialized, which no two lists share.
Native group sets now take each attribute bit from the later name that
supplies it, and a style registered without a mask supplies all eight
bits. A Rust style has no attribute that says "off", so a later name
cleared the bold or italic of an earlier one, where `Style::patch` adds
them.

`SyntaxStyle::register` now sends the attribute mask with the bits the
style sets. A group set then composes as `Style::patch`: fg and bg from
the last name with one, attributes added. One-name lookups are
unchanged. Source +3 lines: the mask flag, the mask bits, and the doc.
A filetyped `Textarea` named each run's style by its value, so a theme
with new colors interned new group sets that are never freed, and every
theme or style table change ran tree-sitter on the whole text again.
After about 200 themes with new colors, new styles painted plain.

Each run now names the capture names whose styles it merges, as one
group set: `default`, then the covering captures after the last `none`.
The editor registers each name as `opentui.<capture>` with its syntax
theme's style, and native paint composes the set. A theme or table
change only registers the names again: no new group sets and no
tree-sitter run. On a 1,149-line Rust file a theme switch takes 4.7M
instructions instead of 164M, and a keystroke refresh 163M instead of
167M: styles and group sets are looked up once per distinct capture
list, not once per piece.

Rust behavior changes: textareas that share one style table share the
`opentui.<capture>` names, so each name has the style the last editor
registered; give them one syntax theme. After `clear_highlights`, a
theme or table change no longer brings syntax back; the next edit does.
Extmark styles keep their `opentui.{style:?}` names from main.

Source +43 lines: the capture lists and their per-list styles, the
registration on theme and table changes, and the table lookup that
syntax and extmarks now share.
The Rust editor gives every piece of a filetyped text a layer run, also
pieces the syntax theme leaves plain, so its runs do not depend on the
theme. Such a run composed to an empty style but still won its columns,
so an app's own priority-0 highlight under plain text disappeared; on
main it showed.

A layer run without a link whose group set supplies no field (no fg, no
bg, no attribute bit) under the bound style is now left out of the
span build where another input covers its columns, so the column falls
through as if the run were absent. Legacy records keep their rule: an
empty record still wins. Spans of such lines record the binding's
change count and rebuild after a registration or a style swap; lines
with only layer runs rebuild nothing on a theme change.

Source +31 lines: the supplied-bits union in composition, the change
count on style borrowers, and the skip in the span build.
A group set held at most 8 names. Core's nvim-treesitter Markdown
queries stack 10 capture names on one piece (a heading with italic,
strikethrough, link, bold and code text), and the Rust editor names a
set after its capture stack, so such pieces lost their style.

The bound is now 16 names, and the header defines both name bounds,
OT_GROUP_SET_NAME_COUNT_MAX and OT_GROUP_SET_NAME_BYTE_COUNT_MAX, which
native asserts against. The ABI only gains the two constants; no
symbol or record changes.
Both wrappers repeated the tree-sitter feature gate around the same
`highlights` call. `highlight_ranges` now merges the styles of
`highlight_names`' lists, and `spans` uses the same merge, so one
wrapper and one merge remain.
Editors that share a style table registered capture names each from its
own syntax theme: when an edit found a new capture list, the editor
registered the whole list again, so typing in one editor could repaint
the other with its own theme. A theme change also touched only the
names that editor had seen. And on a textarea without a filetype, a
theme change made and bound the editor's own table, unbinding a table
the app had set on the buffer.

The table now owns its capture palette (the syntax theme set last on
it) and the capture names registered from it. Setting a theme or a
table restyles every name the table has seen; an edit registers its new
lists' names from the table's palette, never from the editor's own
theme. A theme change without a table touches nothing. A list past the
group-set bounds keeps its innermost 16 names, and a capture name over
56 bytes is left out instead of the whole piece painting plain.

Source +38 lines: the palette on the table and its two calls, the
bounds in `group_set`, and their docs.
The twin test gets its pieces from the same capture lists as the
editor, so a wrong stack rule passed on both sides, and its zero-parse
row only watched the layer, so a parse moved before the content check
went unseen.

A table row now checks hand-written colors: a repeated capture keeps
its last place, the last `none` clears what lies under it, and a
three-part capture takes its longest styled prefix or, on request, its
first part. The theme-churn row swaps its grammar for a lazy one that
counts loads: theme and table switches load nothing, the next edit
loads it once.
A line's spans depended on style definitions whenever the buffer had
two layers, so one layer run off screen made every painted line
rebuild after each registration or style swap: 20 needless builds in
the counter row. Lines without any run paid for it too.

A layer run is now checked against the inputs already on its line:
only an unlinked run over an earlier input can be left out, and only
then do the line's spans record the binding's change count. The
two-layer counter row builds nothing on a theme change, and a row with
two overlapping layers and no record shows the lower one while the
upper supplies nothing.
An edit that found a new capture list registered every name in it
again, also names the table already had. An app that redefined
`opentui.string` in its table lost that color as soon as typing added
an escape, with no theme or table change, and every such registration
cleared the table's composition cache.

An edit now registers only names the table has not seen; setting a
palette still restyles all of them.
With the name pool full, the theme-churn row only typed text whose
capture lists already had sets, so an editor that panicked on a refused
set passed it. The row's grammar now also captures strings, and the
first string comes after the pool is full: it paints plain while the
keyword keeps its color.
# Conflicts:
#	packages/core/src/native-abi.generated.ts
#	packages/rust/crates/opentui-sys/src/generated.rs
# Conflicts:
#	packages/core/src/native-abi.generated.ts
#	packages/core/src/renderables/Markdown.ts
#	packages/rust/crates/opentui-sys/src/generated.rs
…ure-sets

# Conflicts:
#	packages/core/src/native-abi.generated.ts
#	packages/rust/crates/opentui-sys/src/generated.rs
The Markdown engine streams a leaf's display into its text node from the
first byte that changed. A text buffer could only replace its whole text
or append through a new registry slot, which runs out after 255 appends
and then copies the whole document each time.

replaceTail keeps the text in one growable slot the buffer owns: a write
from byte b drops the rope leaves past b, lays out only the new bytes
(and a grapheme they continue), and clips layers at b. The rope is
rebuilt into a fresh arena when its arena outgrows twice the live rope.
8,192 one-byte writes copy 8,192 bytes and keep one slot.

Source +158: C4's internal tail primitive, which M2's bound Markdown
nodes need (design: native-markdown-parser.md, "Text nodes").
A hooked leaf's flag is off while its pending line is parsed again
(the provisional layout is dropped before the hook latches anew). A
binding that never skipped a write, because the leaf latched after
the doc's last write, then took a tail write over the host's text,
and any binding took the whole write of a closing leaf. A binding now
also notes a text change since the doc's last write (the buffer's
content epoch), and only a bind or an option change writes a node
that skipped: closing writes no longer do.

A table header that takes a hooked paragraph's last line can leave the
paragraph without its hook. Its node held the host's text, and the
doc never wrote it; it does now.

The ABI property test's host writes the nodes it owns and checks
after each call that the doc left them alone.
Each append to an open list refreshed it: every item's children were
collected again to decide tightness (items times blocks), and every
marker was formatted and set again. A 30-byte append to list.md took
830 allocations, rising to 1,230 in the second half.

Tightness is now one pass over the list's blocks in preorder, and a
marker is set only when its text changes; the text is formatted into
one reused buffer. The list is still walked once per refresh, but
without allocations: list.md takes 26 allocations per append, flat.
The pattern lost its rust-blocks and rust-stream tags in the Rust engine commit and now fits on one line, as oxfmt writes it.
d8dc9fc styled the space before a concealed link's ` (url)` and
the `]` of a link's source as `markup.link`, as Core's marked preview
does. The frames OpenCode shows keep both as text (oc-rust's U.4a and
U.4c fixtures pin it, and the Rust widget drew them so before), and
the marked preview goes in M3. Both are text again.
adopt freed the old document and moved the new one in before it wrote
the bound nodes. A failed node write then returned an error, and the
caller's cleanup freed the storage the document had just taken: a
replace or option change that ran out of memory there freed live
memory. A rejected append or finish also left the nodes it had
written showing text the rolled-back document no longer had, and
listed the pending line's blocks again.

The new state now becomes the document's by a swap that a failed
write undoes. The nodes a rejected call wrote are written whole again
from the document as it is; one that fails again is written at the
next call. A rejected bind leaves the bindings as they were, and the
pending line's blocks keep their listing across a rejected call.

A test fails each allocation of an option change, an append, a
replace and a finish in turn and checks the source, the pending
changes and both bound nodes.
Each write to a bound paragraph computed the runs of every live
token, from the first. Behind an opener that never closes the tokens
are never folded, so a paragraph of `*open ` and 512 code spans
(2,054 bytes) took 262,656 token visits, four times as many for twice
the bytes.

The live leaf now keeps the styles in force before each token and
restyles from the first token whose style may have changed: the first
changed token, or the first opener a new match took. The parser
reports that opener, since an opener drawn as closed all along does
not change its own display when it matches. The same paragraph takes
about one visit per token.

The repeated-construct model gates the run token visits at 2n plus
one per call and gains this family, and a row streams bigboi's
paragraph.
A host writes hooked prose, table cells and every leaf in source mode
from read_leaf, which resolves colors through the document's style.
set_options with another style changed that output but listed no
block, so the host kept the old colors. A style swap now lists every
leaf the document does not write itself.

The seeded ABI test now always runs seed 647, which finds a hooked
leaf written over by the document, so the separate host-ownership
test that replayed it goes.
Three regressions against the old widget, all in the inline hook:

- A hook that read the document with `markdown()` panicked: a change
  held the source's exclusive borrow while it showed its blocks. The
  change now holds it only while native code changes the document,
  and a shared one while hooks and renderers run.
- A hook that shows the inline source kept `*hi*` after
  `set_markdown("_hi_")`: the widget skipped the hook when read_leaf
  was unchanged. It now compares the inline source too.
- Where native code writes the text (terminals with hyperlinks), a
  removed hook's targets stayed clickable. They now go once a frame
  paints the engine's text.

TestApp::with_hyperlinks makes a headless app whose terminal shows
hyperlinks, so tests reach the path that native code writes.
Ending the stream ran again only the renderers whose last call returned a widget. A renderer that kept the default widget while streaming and has its own for the finished document (a preview that waits for the end) was never asked again when the closed fence did not change. Every code block runs its renderer again now.
set_markdown always parsed the whole text again. oc-rust streams
reasoning, groups, compaction and mini's scrollback by setting the
whole text each time, so each call cost the size of the document: on
an 18 KB document, 788k instructions per call against 57k to append.
While the document streams, text that extends the content now appends
its new tail; the engine's streamed parse equals the fresh one.

The Rust model gains that command, and checks click targets with or
without a hook. Its theme commands set only what they change, and it
runs a second time on a terminal with hyperlinks, where native code
writes the text: no Rust test reached that path before, so dropping
the bind of a reused paragraph passed CI. An exhaustive run of every
three hook, rerun, finish, replace, append and conceal changes covers
who writes the text and who owns the targets.
A removal refreshed the table or list around each removed block, which the engine lists itself, and taking back a removed widget checked that the block's parent stays, which holds once taking back stops at the first container. Both survive every test and opus's twelve removal probes.
The inline parser leaves the last word of an open paragraph to B's
pending drawing, which conceals `*`, `_` and `~` runs but styled none
of the text between them. A stream that never ends, such as
OpenCode's compaction summary `The user is fixing the **parser**.`,
showed `parser` without bold for good, and a complete line showed it
so until the next word arrived.

B now notes the runs it conceals, and the runs that close one another
style the text between them, as process emphasis matches them; an
opener that waits still styles nothing. read_leaf and the live node
take the same spans, and under a hold cut neither counts a run at the
cut.
…ument

A host that commits rows (mini's scrollback) needs the committed count to leave out a block whose last line is pending. The ABI property test now compares the count every call returns with a fresh document's, and checks that no committed top-level block is open.
The live node counted a pending run under a held pipe by display offset, read_leaf by the run's end in the content, so a run that reached the pipe (`$_ses_ | ` with math on, model seed 467) styled the node and not read_leaf. Both now drop the runs that reach the pipe. The repeated-construct model also fails on a node that differs from read_leaf; it only printed it before.
…rack/m2-rust-markdown-engine

# Conflicts:
#	packages/native/src/tests/markdown_test.zig
Rows C, E and F of the tear prototype close a setext leaf with conceal or hyperlinks off; they aborted in liveRestyle before f86891f and need M2's options, so they live here. M1's filter takes at most sixteen items now: the header says so, and set_options and create reject a seventeenth.
Bytes that only grow the pending line extend its layout without reading it again. In a list item a fence opener's info string is the rest of that line, so the code block's info range stayed where the layout put it and the host was not told the block changed (seeded ABI test, seed 965, after M1's soup gained the tear bytes). A line whose layout opens a fence now takes a new layout for new bytes.
@simonklee
simonklee requested a review from kommander as a code owner November 12, 2026 12:57
…lity reply

change_options read the engine flags before change took in a new hyperlink capability, then sent those stale flags after the capability's own option call. Removing a hook just after the terminal reported hyperlinks left native code thinking the widget wrote the text and the widget thinking native code did, so later appends never showed. The flags are now read inside the change. (bigboi-r2-3)
Ending the stream showed the blocks the finish listed, which ran their renderers for the finished document, and then ran every renderer again. An open fence's renderer was asked twice with the same finished state, and a renderer that answered differently the second time changed what showed. The widget now notes the blocks the finish already rendered and leaves them out of the second pass. (bigboi-r2-4)
Nested closers (`begin `, k times `*a `, then k times `b* `, one piece
per call) took 561, 2,145 and 8,385 run token visits at k = 16, 32 and
64: each closer's match restyled from its opener, through the inner
matches' interiors, which were italic already. At k = 64 that is 390
bytes, where the work property allows 973. The bound was right; the
restyle was quadratic.

When a call's new matches share one kind, the walk now jumps over the
interior of an older match of that kind, whose styles cannot change,
and writes the runs before and after it as separate segments (a node
write may now end its runs before the text's end). The skipped tokens'
noted styles are marked stale, so a later write that would start in
them starts before them. Matches nest and do not cross links, so the
styles at the inner closer are those after its opener. The same
stream takes about one visit per token.

The repeated-construct model generates this shape (several marks,
prefixes and interiors), and a row streams bigboi's k = 64 stream.
(bigboi-r2-2)
An option change that turns the live display on takes over the nodes
the host wrote and writes them whole. If the call then ran out of
memory it was rejected, and the source and changes were as before,
but a node already written showed the doc's text, and the host's text
was gone: the doc cannot rebuild it, so the rejected-call resync could
not restore it.

During a call, a write that would take a node over from the host now
only marks it; the call writes those nodes once everything else in it
succeeded, and a rejected call drops the marks. A take-over write that
fails after that leaves the node stale for the next call to write.

The allocation-failure row also starts from host-written nodes and
fails each allocation of the live option change. (bigboi-r2-1)
51a955c let a restyle walk jump from an opener to the closer of an older
match of the call's one kind, and go on with the styles it had after the
opener. A `***` opens two matches with different closers, and a closer
can close matches opened inside the gap, so that state was wrong:
`begin *out ***a** b* tail q* e` streamed a byte at a time left bold on
after its closer. bigboi's prefix-frame differential found 20 of 240
streams differing from a fresh document; none did before 51a955c.

The rule now follows from the matcher's own result instead of a guess
about the call's kinds. The walk keeps how many of the call's matches of
each kind cover the token it is past. It jumps from an opener to its
innermost closer only when that match is older than the call (so no
match of the call starts or ends inside: matches nest), every kind the
call's matches add there is one the opener's matches give already, and
no bytes or link there changed. Before the closer, the styles are those
after the opener plus the closer's own matches whose openers lie in the
gap; the walk goes on from there with them. The parser reports the
first bracket a link took, the one style change without new bytes.

The differential is now a seeded test (60 documents of mixed and shared
delimiters, links and code, at strides 1, 3, 5 and 8: 240 streams), with
bigboi's two strings as its first rows, and the repeated-construct
model's nested family mixes `*`, `**`, `***`, `_`, `__` and `~~`.
(bigboi-r3-1)
64 nested `***` pairs, closed one per call, still took 8,385 run token
visits where the work property allows 1,485. Each closer marked its
opener's display changed (a three-star run is not matched by one kind
at once), so the text was rewritten from the opener, and no jump can
pass written bytes.

A match never changes its opener's bytes: matched or still waiting
with what is left, a run is concealed. The style change the mark
stood for is what `matched_from` reports, so the mark goes. The same
stream now takes about one visit per token, inside the stated bound:
run token visits ≤ 2n + calls + 64, as before. A walk passes a token
when the token is new or written again, or when the call's matches add
a kind to it that no older match around it gives; the jump passes the
rest.

A row streams bigboi's 64 `***` pairs. (bigboi-r3-2)
8cb56f3 dropped the pending runs that reach the leaf's held pipe, but read_leaf holds a pipe only in a paragraph's last line, where it may start a table header. In a heading the live node then left `*e*` after a code span with a pipe unstyled (mixed-fragment differential, seed 601 at a wide budget). Only a paragraph's runs stop at a pipe now; the nested differential carries the row.
ec11923 wrote a host's nodes at the end of the call that made the doc
write them, and a write that failed there left the node stale for the
next call. That next call wrote it before it checked its own input, so
an invalid UTF-8 append was rejected but changed the node from the
host's text to the doc's (bigboi-r3-3).

A doc now never writes a node the host wrote, in any call: when a call
would (options that turn the live display on, a leaf that loses its
hook), it unbinds the node and lists the leaf once the call succeeds,
and the host binds the node again to hand it over. A text change since
the doc's last write counts as the host's. The bind's own write cannot
fail once the text changes: the layer's storage is reserved first, so
a rejected bind leaves the node and the bindings as they were. There
is no state in which a node shows text the doc wanted to replace.

The Rust widget binds a node again when native code writes a leaf the
widget wrote. The allocation-failure row checks the accepted hand-off
and an invalid append after it; a new row fails each allocation of a
bind over host text.
The repeated-construct model at 1,000 seeds (seed 81) took more run
token visits than 2n + calls once its nested family mixed `*`, `_`,
`**`, `__`, `***` and `~~`. Two things were wrong.

The jump asked the opener at hand for every kind the call's matches
add. When one call closes a `~~` and the `**` around it, and the older
levels inside give the two kinds by two matches, no single opener
gives both, so the walk went down every level on every call. The jump
now asks all the older matches around the position: the walk's styles
count every match there and `new_active` the call's, so an older one
gives a kind when its count is the larger.

And 2n + calls holds only for one kind. A walk passes a token when it
arrives and once more for each kind it gains: the bound proved is
(1 + k)n + calls, where k is the number of match kinds the doc's
closers made (at most 3). The counters record those kinds, and the work
property uses that bound. The `***` row still checks 2n + calls itself,
since a `***` closer adds its two kinds in one call. A row streams
64 `**a ~~b ` levels closed `c~~ d** ` per call. (bigboi-r3-2)
18b8d37 bounded run token visits by (1 + k)n + calls, k the match kinds the doc's closers made. With no match at all (seed 247 at 1,000 seeds: openers that never close, code spans split across appends) that left n + calls, but a token is walked again when a later call writes it again (a code span that closes, a hold that moves), which the old 2n covered. The bound is (1 + max(1, k))n + calls: 2n for one kind or none, as before, and n more for each further kind.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant