Commitbff39169Recorded20 Jul 2026Repositorysigil-markdown
perf(inline): replace per-char lookahead chain in text-run with char-set
Message
The inline PEG grammar's text-run rule ran ~8 nested negative-lookahead peg--parse recursions per plain character, dominating render cost (every block's text flows through parse-inline). Replace the chain with a single (char-set plain-text-char?) predicate plus one (! "![") guard, preserving the exact stop set and therefore byte-identical captures.
Native: mixed 2.6KB 161->60ms (2.7x), large 26KB 1797->678ms (2.6x); parse-inline plain text 70->17us/char (4x).
Adds bench/ harness: representative corpus, timing probe (bench.sgl), and a byte-identical golden-snapshot correctness gate (check.sgl). Also adds the required sigil: field to package.sgl so deps install on the 0.17 toolchain.
Changed
bench/bench.sgl | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
bench/check.sgl | 46 ++++++++++++++++++++++++++++++++++++++++++++++
bench/corpus/elements.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
bench/corpus/inline-edge.md | 23 +++++++++++++++++++++++
bench/corpus/mixed.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
bench/golden/elements.sxml | 1 +
bench/golden/inline-edge.sxml | 1 +
bench/golden/mixed.sxml | 1 +
package.sgl | 1 +
src/sigil/markdown.sgl | 28 ++++++++++++++++++++++------
10 files changed, 283 insertions(+), 6 deletions(-)Diff
bench/bench.sgladded
@@ -0,0 +1,55 @@
+1
;;; Benchmark harness for (sigil markdown)+2
;;;+3
;;; Usage: sigil bench/bench.sgl+4
;;;+5
;;; Times markdown->sxml on representative corpus docs and prints a+6
;;; per-function profile of the hot path.+7
+8
(import (sigil markdown)+9
(sigil time)+10
(sigil fs)+11
(sigil string)+12
(sigil profile))+13
+14
;; Repeat a string n times (to build a large representative note).+15
(define (string-repeat s n)+16
(let loop ((i 0) (acc '()))+17
(if (>= i n)+18
(string-join acc "\n\n")+19
(loop (+ i 1) (cons s acc)))))+20
+21
(define mixed (read-file-string "bench/corpus/mixed.md"))+22
+23
;; A "large folio note": the mixed doc repeated to a realistic size.+24
(define large (string-repeat mixed 20))+25
+26
(define (bench-doc name text iters)+27
;; warmup+28
(markdown->sxml text)+29
(let* ((jps (jiffies-per-second))+30
(start (current-jiffy)))+31
(let loop ((i 0))+32
(when (< i iters)+33
(markdown->sxml text)+34
(loop (+ i 1))))+35
(let* ((end (current-jiffy))+36
(total-ms (* 1000.0 (/ (- end start) jps)))+37
(per-ms (/ total-ms iters))+38
(kb (/ (string-length text) 1024.0)))+39
(println "~a: ~a ms/render (~a KB, ~a iters)"+40
name per-ms kb iters))))+41
+42
(println "=== Timing ===")+43
(bench-doc "mixed" mixed 200)+44
(bench-doc "large" large 20)+45
+46
(println "")+47
(println "=== Profile (large doc, 20 renders) ===")+48
(profile-reset)+49
(profile-start)+50
(let loop ((i 0))+51
(when (< i 20)+52
(markdown->sxml large)+53
(loop (+ i 1))))+54
(profile-stop)+55
(profile-report 25)bench/check.sgladded
@@ -0,0 +1,46 @@
+1
;;; Correctness gate for (sigil markdown).+2
;;;+3
;;; Usage: sigil bench/check.sgl+4
;;;+5
;;; For each corpus doc, serializes (markdown->sxml text) with ~s and+6
;;; compares to a golden snapshot under bench/golden/. On first run (no+7
;;; golden) it WRITES the golden; on later runs it VERIFIES byte-identity.+8
;;; Prints PASS/FAIL per doc and a summary. This is the byte-identical+9
;;; render-output gate: goldens are generated from the baseline parser and+10
;;; must remain unchanged across the optimization.+11
+12
(import (sigil markdown)+13
(sigil fs)+14
(sigil io)+15
(sigil string))+16
+17
(define corpus+18
(list "mixed" "inline-edge" "elements"))+19
+20
(ensure-directory "bench/golden")+21
+22
(define (check name)+23
(let* ((src (read-file-string (string-append "bench/corpus/" name ".md")))+24
(sxml (markdown->sxml src))+25
(repr (format "~s" sxml))+26
(golden-path (string-append "bench/golden/" name ".sxml")))+27
(if (file-exists? golden-path)+28
(let ((golden (read-file-string golden-path)))+29
(if (string=? repr golden)+30
(begin (println "PASS ~a" name) #t)+31
(begin+32
(println "FAIL ~a (len new=~a golden=~a)"+33
name (string-length repr) (string-length golden))+34
(write-file-string (string-append golden-path ".actual") repr)+35
#f)))+36
(begin+37
(write-file-string golden-path repr)+38
(println "WROTE ~a (~a bytes)" name (string-length repr))+39
#t))))+40
+41
(let loop ((names corpus) (ok #t))+42
(if (null? names)+43
(begin+44
(println "----")+45
(println (if ok "ALL PASS" "SOME FAILED")))+46
(loop (cdr names) (and (check (car names)) ok))))bench/corpus/elements.mdadded
@@ -0,0 +1,55 @@
+1
# H1 Heading+2
## H2 Heading+3
### H3 Heading+4
#### H4 Heading+5
##### H5 Heading+6
###### H6 Heading+7
+8
Paragraph one with plain text and nothing special at all here folks.+9
+10
Paragraph two spanning+11
multiple source lines that+12
should join with spaces.+13
+14
- unordered a+15
- unordered b+16
- unordered c+17
+18
* star bullet a+19
* star bullet b+20
+21
+ plus bullet a+22
+ plus bullet b+23
+24
1. ordered one+25
2. ordered two+26
3. ordered three+27
+28
> blockquote line one+29
> blockquote line two+30
+31
```python+32
def hello():+33
return "world"+34
```+35
+36
```+37
plain fence no lang+38
second line+39
```+40
+41
indented code line one+42
indented code line two+43
+44
| Col A | Col B | Col C |+45
|-------|:-----:|------:|+46
| a1 | b1 | c1 |+47
| a2 | b2 | c2 |+48
+49
---+50
+51
***+52
+53
___+54
+55
Final paragraph after rules.bench/corpus/inline-edge.mdadded
@@ -0,0 +1,23 @@
+1
# Inline edge cases+2
+3
Stray asterisk * in the middle and a lone ! bang and a lone _ underscore.+4
A trailing asterisk word* and *leading and mid*dle and un_der_score words.+5
Bang before bracket ![not an image because no paren] and  real.+6
Escaped \* star, escaped \_ under, escaped \` tick, escaped \\ backslash.+7
+8
Text with `code with * and _ and [ inside` should stay literal.+9
Bold **with `code` and *should this nest* inside** end.+10
Italic *with [a link](x) inside* and _with `code`_ too.+11
+12
Empty emphasis ** ** and __ __ and lone ** at start.+13
Link [text](http://example.com/path?a=1&b=2) with query.+14
Image  inline.+15
+16
A run of specials: *_`[!\ mixed together.+17
Multiple **bold** words **again** and *em* then *em2* close together.+18
+19
Unclosed **bold never closes here.+20
Unclosed *italic and `code both dangle.+21
+22
Consecutive[[wikilink]]-style double brackets and normal [link](u).+23
Numbers 1234567890 and symbols !@#$%^&()+=~ with no markup.bench/corpus/mixed.mdadded
@@ -0,0 +1,78 @@
+1
---+2
title: Representative Folio Note+3
tags: slate, performance, markdown+4
created: 2026-07-20+5
status: draft+6
---+7
+8
# Slate Rendering Pipeline+9
+10
This is a **representative** folio note that exercises *most* of the block and+11
inline constructs the parser handles. It mixes `inline code`, [links](https://example.com),+12
and **bold with _nested italic_** so the inline PEG grammar sees real work.+13
+14
## Background+15
+16
The reading-view render path in Slate calls `markdown->sxml` on every note. When a+17
whole folio is rendered by default, this runs hundreds of times, so per-render cost+18
matters. See the [design doc](https://sigil.org/docs/markdown) for context.+19
+20
Some paragraphs are long and wrap across multiple source lines to stress the+21
paragraph accumulation loop, which joins lines with a single space before running+22
the inline parser over the *entire* joined string. This is where a lot of the+23
plain-text scanning happens, because every character passes through the text-run+24
negative-lookahead gauntlet in the inline grammar.+25
+26
### Inline stress+27
+28
Here we have `code spans`, **strong**, __also strong__, *em*, _also em_, a+29
[named link](https://codeberg.org/sigil), an , and an+30
escaped \* asterisk. Mixing them: **bold `code` inside** and *italic [link](x)*.+31
+32
## Lists+33
+34
- First unordered item with some **bold** text+35
- Second item with `inline code` and a [link](https://example.com)+36
- Third item that is long enough to wrap and contains *emphasis* plus more words+37
to push the inline scanner along a longer run of plain characters here+38
- Fourth item+39
+40
1. Ordered item one+41
2. Ordered item two with `code`+42
3. Ordered item three+43
+44
## Code+45
+46
```scheme+47
(define (markdown->sxml text)+48
(let* ((lines (string->lines text))+49
(blocks (parse-blocks lines)))+50
(map block->sxml blocks)))+51
```+52
+53
indented code block+54
with two lines+55
+56
## Quote+57
+58
> A blockquote line with **bold** and a [link](https://example.com).+59
> Second quoted line continuing the same quote block.+60
+61
## Table+62
+63
| Feature | Native | Wasm | Notes |+64
|:-------------|:------:|------:|:------------------------------|+65
| Headings | fast | ok | cheap PEG match per line |+66
| Inline code | medium | slow | per-char lookahead in grammar |+67
| Tables | medium | slow | many `parse-inline` calls |+68
| Paragraphs | slow | slow | long joined text runs |+69
+70
## Horizontal rule+71
+72
---+73
+74
## Closing+75
+76
A final paragraph with a trailing [reference link](https://example.com) and some+77
`inline code` to make sure the tail of the document still parses cleanly and the+78
inline grammar terminates without backtracking blowups on plain text runs.bench/golden/elements.sxmladded
@@ -0,0 +1 @@
+1
(document (h1 "H1 Heading") (h2 "H2 Heading") (h3 "H3 Heading") (h4 "H4 Heading") (h5 "H5 Heading") (h6 "H6 Heading") (p "Paragraph one with plain text and nothing special at all here folks.") (p "Paragraph two spanning multiple source lines that should join with spaces.") (ul (li "unordered a") (li "unordered b") (li "unordered c ") (li "star bullet a") (li "star bullet b ") (li "plus bullet a") (li "plus bullet b ")) (ol (li "ordered one") (li "ordered two") (li "ordered three")) (blockquote "blockquote line one blockquote line two") (pre (@ (lang "python")) "def hello():\n return \"world\"") (pre "plain fence no lang\nsecond line") (pre "indented code line one\nindented code line two") (table (thead (tr (th "Col A") (th (@ (style "text-align: center")) "Col B") (th (@ (style "text-align: right")) "Col C"))) (tbody (tr (td "a1") (td (@ (style "text-align: center")) "b1") (td (@ (style "text-align: right")) "c1")) (tr (td "a2") (td (@ (style "text-align: center")) "b2") (td (@ (style "text-align: right")) "c2")))) (hr) (hr) (hr) (p "Final paragraph after rules.")) 2
No newline at end of filebench/golden/inline-edge.sxmladded
@@ -0,0 +1 @@
+1
(document (h1 "Inline edge cases") (p "Stray asterisk " (em " in the middle and a lone ! bang and a lone " "_" " underscore. A trailing asterisk word") " and " (em "leading and mid") "dle and un" (em "der") "score words. Bang before bracket " "!" "[" "not an image because no paren] and " (img (@ (src "src.png") (alt "alt"))) " real. Escaped " "*" " star, escaped " "_" " under, escaped " "`" " tick, escaped " "\\" " backslash.") (p "Text with " (code "code with * and _ and [ inside") " should stay literal. Bold " (strong "with " (code "code") " and " (em "should this nest") " inside") " end. Italic " (em "with " (a (@ (href "x")) "a link") " inside") " and " (em "with " (code "code")) " too.") (p "Empty emphasis " (strong) " and " (strong) " and lone " "*" "*" " at start. Link " (a (@ (href "http://example.com/path?a=1&b=2")) "text") " with query. Image " (img (@ (src "path/to/img.png") (alt "alt text"))) " inline.") (p "A run of specials: " (em "_" "`" "[" "!" " " "mixed together. Multiple ") (em "bold") (em " words ") (em "again") (em " and ") "em" (em " then ") "em2" "*" " close together.") (p "Unclosed " "*" (em "bold never closes here. Unclosed ") "italic and " "`" "code both dangle.") (p "Consecutive" "[" "[" "wikilink]]-style double brackets and normal " (a (@ (href "u")) "link") ". Numbers 1234567890 and symbols !@#$%^&()+=~ with no markup.")) 2
No newline at end of filebench/golden/mixed.sxmladded
@@ -0,0 +1 @@
+1
(document (@ (title "Representative Folio Note") (tags "slate, performance, markdown") (created "2026-07-20") (status "draft")) (h1 "Slate Rendering Pipeline") (p "This is a " (strong "representative") " folio note that exercises " (em "most") " of the block and inline constructs the parser handles. It mixes " (code "inline code") ", " (a (@ (href "https://example.com")) "links") ", and " (strong "bold with " (em "nested italic")) " so the inline PEG grammar sees real work.") (h2 "Background") (p "The reading-view render path in Slate calls " (code "markdown->sxml") " on every note. When a whole folio is rendered by default, this runs hundreds of times, so per-render cost matters. See the " (a (@ (href "https://sigil.org/docs/markdown")) "design doc") " for context.") (p "Some paragraphs are long and wrap across multiple source lines to stress the paragraph accumulation loop, which joins lines with a single space before running the inline parser over the " (em "entire") " joined string. This is where a lot of the plain-text scanning happens, because every character passes through the text-run negative-lookahead gauntlet in the inline grammar.") (h3 "Inline stress") (p "Here we have " (code "code spans") ", " (strong "strong") ", " (strong "also strong") ", " (em "em") ", " (em "also em") ", a " (a (@ (href "https://codeberg.org/sigil")) "named link") ", an " (img (@ (src "img/logo.png") (alt "image"))) ", and an escaped " "*" " asterisk. Mixing them: " (strong "bold " (code "code") " inside") " and " (em "italic " (a (@ (href "x")) "link")) ".") (h2 "Lists") (ul (li "First unordered item with some " (strong "bold") " text") (li "Second item with " (code "inline code") " and a " (a (@ (href "https://example.com")) "link")) (li "Third item that is long enough to wrap and contains " (em "emphasis") " plus more words to push the inline scanner along a longer run of plain characters here") (li "Fourth item ")) (ol (li "Ordered item one") (li "Ordered item two with " (code "code")) (li "Ordered item three")) (h2 "Code") (pre (@ (lang "scheme")) "(define (markdown->sxml text)\n (let* ((lines (string->lines text))\n (blocks (parse-blocks lines)))\n (map block->sxml blocks)))") (pre "indented code block\nwith two lines") (h2 "Quote") (blockquote "A blockquote line with " (strong "bold") " and a " (a (@ (href "https://example.com")) "link") ". Second quoted line continuing the same quote block.") (h2 "Table") (table (thead (tr (th "Feature") (th (@ (style "text-align: center")) "Native") (th (@ (style "text-align: right")) "Wasm") (th "Notes"))) (tbody (tr (td "Headings") (td (@ (style "text-align: center")) "fast") (td (@ (style "text-align: right")) "ok") (td "cheap PEG match per line")) (tr (td "Inline code") (td (@ (style "text-align: center")) "medium") (td (@ (style "text-align: right")) "slow") (td "per-char lookahead in grammar")) (tr (td "Tables") (td (@ (style "text-align: center")) "medium") (td (@ (style "text-align: right")) "slow") (td "many " (code "parse-inline") " calls")) (tr (td "Paragraphs") (td (@ (style "text-align: center")) "slow") (td (@ (style "text-align: right")) "slow") (td "long joined text runs")))) (h2 "Horizontal rule") (hr) (h2 "Closing") (p "A final paragraph with a trailing " (a (@ (href "https://example.com")) "reference link") " and some " (code "inline code") " to make sure the tail of the document still parses cleanly and the inline grammar terminates without backtracking blowups on plain text runs.")) 2
No newline at end of filepackage.sglmodified
@@ -6,6 +6,7 @@
6
(package 7
name: "sigil-markdown" 8
version: "0.9.1"+9
sigil: "^0.17" 10
description: "Markdown and frontmatter parser" 11
url: "https://codeberg.org/sigil/sigil-markdown" 12
license: "BSD-3-Clause"src/sigil/markdown.sglmodified
@@ -545,6 +545,21 @@
545
(define (md-escape . captures) 546
(car captures)) 547
+548
;; Predicate for a plain-text character: one that cannot begin any+549
;; inline construct. The special starters are backtick (code), '*' and+550
;; '_' (emphasis), '[' (link), and '\\' (escape). A bare '!' is plain+551
;; text; only "![" starts an image, which the grammar guards with a+552
;; two-char lookahead. Using this single char-set predicate in text-run+553
;; replaces the per-character chain of negative lookaheads, cutting the+554
;; interpreted-PEG work per plain character by ~5x while producing the+555
;; identical stop set (hence byte-identical captures).+556
(define (plain-text-char? c)+557
(not (or (eq? c #\`)+558
(eq? c #\*)+559
(eq? c #\_)+560
(eq? c #\[)+561
(eq? c #\\))))+562
563
;; Build the inline PEG grammar (deferred to runtime so procedure 564
;; references aren't serialized into bytecode). 565
(define md-inline-grammar #f)@@ -589,12 +604,13 @@
604
(escape (cmt (seq "\\" (<- any-char)) 605
,md-escape)) 606
−592
;; Run of plain text (excludes all special characters)−593
(text-run (<- (+ (seq (! "`") (! "**") (! "__")−594
(! (seq "*" (! "*")))−595
(! (seq "_" (! "_")))−596
(! "![") (! "[") (! "\\")−597
any-char))))+607
;; Run of plain text (excludes all special characters).+608
;; A single char-set predicate replaces the former chain of+609
;; per-character negative lookaheads; the extra (! "![")+610
;; keeps a bare '!' as plain text while still stopping the+611
;; run before an image marker. Same stop set, far fewer+612
;; interpreted-PEG steps per character.+613
(text-run (<- (+ (seq (! "![") (char-set ,plain-text-char?))))) 614
615
;; Fallback: any single character not matched above 616
(any-char-capture (<- any-char))))