Skip to content

Phase 23: pinned reparse windows; serialize without a whole-output parse (v0.1.20) - #23

Merged
lr00rl merged 1 commit into
mainfrom
claude/magical-archimedes-py86ba
Sep 30, 2026
Merged

lr00rl merged 1 commit into
mainfrom
claude/magical-archimedes-py86ba

Conversation

@lr00rl

@lr00rl lr00rl commented Sep 30, 2026

Copy link
Copy Markdown
Member

What was wrong

reparseBlocks / reparseFromText reparsed a window around an edit and then appended the old blocks after it, shifted, without checking that the edit had not run into them. An edit that opens a code fence, a display-math block or an HTML comment and does not close it was read as one short block followed by the old suffix. A whole parse runs it to the end of the text.

prior:  a / b / c / d / e      (paragraphs)
edit:   insert "```\n" before b
before: [paragraph a] [fenced-code b..c] [paragraph d] [paragraph e]
whole:  [paragraph a] [fenced-code b..e]

Noto calls this from replaceMarkdown: leaving Source Mode, taking an external change on reload, and plugin transforms.

The fix: pin both edges of the window (src/window.ts)

The scanner decides where a top-level block starts from that block's first line and everything above it. So a window can stand in for the whole parse when:

  • Left. It opens at offset 0, or at the start of an untouched block whose first line, and everything before it, read as they did before. That block is re-read, so a line that now continues it (lazy continuation) is picked up.
  • Right. It closes at the end of the text, or with an anchor: the untouched block after the dirty ordinals, which must come out exactly as it was (kind and offsets). If it does not, the edit ran into it, and the reparse reads on to the end.

Frontmatter is the one block decided by unbounded lookahead: --- on line 1 is frontmatter if a close comes anywhere later, and a thematic break otherwise. A window that opens the note with one and stops short of its close is refused.

neighborSlack now only trades window size against how often the fallback is needed. It no longer decides whether the result is correct.

Serialize without a whole-output parse

serializeDocument used to parse its entire output again to build the next document. It now records where each unit landed and builds the document from the blocks it moved. It reparses, with the same pinned windows, only around changed units, around seams where blocks were deleted (two lists can merge), and at the ends of the note when those change. If any window does not hold, it falls back to the whole parse, as before. Line-ending detection now uses two native scans instead of a character loop.

On an 8 MB note (43,970 blocks), one edited paragraph:

before after
serializeDocument 130-203 ms 41-77 ms

Tests

  • reparse-window.test.ts covers:

    • unclosed fence, math, comment and <script> at slack 0/1/2;
    • a closing fence that ends a fence which used to run to the end;
    • lazy continuation into the block before the window;
    • identity of untouched spans;
    • 600 generated edits per slack compared field by field with parseBlocks.

    Five of the seven tests fail on main.

  • serialize-incremental.test.ts runs 500 generated saves each over LF, CRLF, BOM, no final newline and no frontmatter. Every document is compared with parseDocument(outputBytes) (envelope, text, leading, gaps, trailing, every block). It also covers the unclosed fence, list merge, first and last block deleted, final newline changed, and frontmatter inserted.

  • A local stress run (6,000 × 5 saves, 8,000 × 2 edits) passes.

  • pnpm verify passes: 132 tests.

  • Noto's unit suite passes against this build (2,116 tests), including its own equivalence test for the save path.

Version 0.1.20; CHANGELOG, README, contract and roadmap are updated.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Zsubk1jzejiCfkbLeWVQd


Generated by Claude Code

…arse

reparseBlocks stitched the old blocks after its window without checking that
the edit had not run into them, so an unclosed fence, display-math block or
HTML comment was read as one short block followed by the old suffix, where a
whole parse has it run to the end of the text. A window now opens at the start
of the untouched block before it and must close with the untouched block after
it, exactly as that block was; otherwise the reparse reads on to the end.
Frontmatter, decided by unbounded lookahead, is refused by a window that opens
the note and stops short of its close.

serializeDocument uses the same pinned windows to build its next document
from the blocks it moved plus small reparses around what changed, instead of
parsing its whole output again. On an 8 MB note a one-block save goes from
about 130-200 ms to 40-80 ms. Line-ending detection uses two native scans
instead of a character loop.

Property tests hold both to a whole parse on generated edits and saves (LF,
CRLF, BOM, no final newline, no frontmatter). Phase 23, v0.1.20.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011Zsubk1jzejiCfkbLeWVQd
@lr00rl
lr00rl merged commit 2e723e8 into main Sep 30, 2026
1 check passed
lr00rl pushed a commit to roobli/Noto that referenced this pull request Sep 30, 2026
@roobli/md 0.1.20 (roobli/md#23, merge commit 2e723e8) pins every reparse
window at both edges. That fixes a wrong answer on the path Noto uses to
replace the editor's content with new text (leaving Source Mode, taking a
change made on disk, plugin transforms): an unclosed fence, math block or
comment was read as one short block with the old blocks after it, where
the file has them inside it. The same windows let the engine's serializer
build its next document without parsing its whole output: on the 8 MB
benchmark document the engine's part of a save fell from 130-203 ms to
41-77 ms.

Pinned by commit until the v0.1.20 tag is pushed. The license inventory
is regenerated; prior-split-cache gets a test that fails on 0.1.19.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011Zsubk1jzejiCfkbLeWVQd
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.

2 participants