Commit9b343a4cRecorded20 Jul 2026Repositorysigil-markdown

perf(inline): replace PEG inline grammar with a hand-written linear scanner

Message

The interpreted PEG grammar paid ~20 recursive peg--parse calls per plain character, which dominated render cost. Replace parse-inline with a single linear scan: each construct (code, emphasis, image, link, escape, text run) is dispatched by its unique starter character and consumed in one pass. The scanner emits the exact same capture list the grammar produced, verified byte-identical against golden snapshots of the original parser across the edge-case corpus (unclosed/empty/nested emphasis, empty backticks, links without closing paren, image vs bang, escapes at EOL).

Native (cumulative vs original baseline): mixed 2.6KB 161 -> 6.96 ms (23x) large 26KB 1797 -> 60.0 ms (30x) parse-inline plain text 70 -> 0.22 us/char (~320x)

parse-blocks (still PEG-based line classification) is now the dominant phase.

Changed
 src/sigil/markdown.sgl | 262 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------
 1 file changed, 169 insertions(+), 93 deletions(-)
Diff
src/sigil/markdown.sglmodified
@@ -521,38 +521,25 @@
521
(para-loop (cdr rest)
522
(cons (car rest) para-lines)))))))))))
523
−524
;; ========== Inline PEG Grammar ==========
−525
−526
;; cmt handlers produce SXML elements from captures
−527
−528
(define (md-code . captures)
−529
(list 'code (car captures)))
−530
−531
(define (md-strong . captures)
−532
(cons 'strong (parse-inline (car captures))))
−533
−534
(define (md-em . captures)
−535
(cons 'em (parse-inline (car captures))))
−536
−537
(define (md-link . captures)
−538
(list 'a (list '@ (list 'href (cadr captures)))
−539
(car captures)))
−540
−541
(define (md-image . captures)
−542
(list 'img (list '@ (list 'src (cadr captures))
−543
(list 'alt (car captures)))))
−544
−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).
+524
;; ========== Inline Parser ==========
+525
;;
+526
;; A hand-written single linear scan replaces the interpreted PEG
+527
;; grammar that previously drove inline parsing. The PEG version paid
+528
;; ~20 recursive `peg--parse` interpreter calls per plain character
+529
;; (dominant render cost); this scanner walks the string once, emitting
+530
;; the SAME capture list the grammar produced (verified byte-identical
+531
;; against golden snapshots of the original parser over an edge-case
+532
;; corpus). Each `scan-*` helper returns `(result . next-pos)` on a
+533
;; successful construct match, or `#f` to fall back to a single-character
+534
;; capture — exactly mirroring the grammar's ordered choice
+535
;; (/ code bold-star bold-under italic-star italic-under
+536
;; image link escape text-run any-char-capture)
+537
;; where each construct is keyed by its unique starter character.
+538
+539
;; Predicate for a plain-text character: one that cannot begin any inline
+540
;; construct. Starters are backtick (code), '*'/'_' (emphasis), '['
+541
;; (link), '\\' (escape). A bare '!' is plain text; only "![" starts an
+542
;; image (handled with a two-char check in the text-run scan).
543
(define (plain-text-char? c)
544
(not (or (eq? c #\`)
545
(eq? c #\*)
@@ -560,63 +547,108 @@
547
(eq? c #\[)
548
(eq? c #\\))))
549
−563
;; Build the inline PEG grammar (deferred to runtime so procedure
−564
;; references aren't serialized into bytecode).
−565
(define md-inline-grammar #f)
−566
−567
(define (get-inline-grammar)
−568
(if md-inline-grammar
−569
md-inline-grammar
−570
(begin
−571
(set! md-inline-grammar
−572
`((main (* (/ code bold-star bold-under italic-star italic-under
−573
image link escape text-run any-char-capture)))
−574
−575
;; Inline code: `code`
−576
(code (cmt (seq "`" (<- (+ (seq (! "`") any-char))) "`")
−577
,md-code))
−578
−579
;; Bold: **text** or __text__
−580
(bold-star (cmt (seq "**" (<- (+ (seq (! "**") any-char))) "**")
−581
,md-strong))
−582
(bold-under (cmt (seq "__" (<- (+ (seq (! "__") any-char))) "__")
−583
,md-strong))
−584
−585
;; Italic: *text* or _text_ (lookahead prevents matching ** or __)
−586
(italic-star (cmt (seq (& (seq "*" (! "*")))
−587
"*" (<- (+ (seq (! "*") any-char))) "*")
−588
,md-em))
−589
(italic-under (cmt (seq (& (seq "_" (! "_")))
−590
"_" (<- (+ (seq (! "_") any-char))) "_")
−591
,md-em))
−592
−593
;; Image: ![alt](src)
−594
(image (cmt (seq "![" (<- (* (seq (! "]") any-char))) "]("
−595
(<- (* (seq (! ")") any-char))) ")")
−596
,md-image))
−597
−598
;; Link: [text](url)
−599
(link (cmt (seq "[" (<- (* (seq (! "]") any-char))) "]("
−600
(<- (* (seq (! ")") any-char))) ")")
−601
,md-link))
−602
−603
;; Backslash escape
−604
(escape (cmt (seq "\\" (<- any-char))
−605
,md-escape))
−606
−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))))
−617
md-inline-grammar)))
−618
−619
;; ========== Inline Parser ==========
+550
;; Inline code: `code` — one or more non-backtick chars between backticks.
+551
;; Literal content (not re-parsed). Fails on empty (``) or missing close.
+552
(define (scan-code text i len)
+553
(let loop ((k (+ i 1)))
+554
(cond
+555
((>= k len) #f)
+556
((eq? (string-ref text k) #\`)
+557
(if (> k (+ i 1))
+558
(cons (list 'code (substring text (+ i 1) k)) (+ k 1))
+559
#f))
+560
(else (loop (+ k 1))))))
+561
+562
;; Double-delimiter emphasis: **strong** / __strong__. Requires the
+563
;; doubled opener (caller has already seen the first delimiter char),
+564
;; one or more inner chars, and a doubled closer. Inner is re-parsed.
+565
(define (scan-delim-double text i len ch tag)
+566
(if (and (< (+ i 1) len) (eq? (string-ref text (+ i 1)) ch))
+567
(let loop ((k (+ i 2)))
+568
(cond
+569
((>= k len) #f)
+570
((and (eq? (string-ref text k) ch)
+571
(< (+ k 1) len)
+572
(eq? (string-ref text (+ k 1)) ch))
+573
(if (> k (+ i 2))
+574
(cons (cons tag (parse-inline (substring text (+ i 2) k)))
+575
(+ k 2))
+576
#f))
+577
(else (loop (+ k 1)))))
+578
#f))
+579
+580
;; Single-delimiter emphasis: *em* / _em_. Only when the opener is NOT
+581
;; doubled (grammar lookahead (& (seq D (! D)))); one or more inner chars
+582
;; then a closing delimiter. Inner is re-parsed.
+583
(define (scan-delim-single text i len ch tag)
+584
(if (or (>= (+ i 1) len)
+585
(not (eq? (string-ref text (+ i 1)) ch)))
+586
(let loop ((k (+ i 1)))
+587
(cond
+588
((>= k len) #f)
+589
((eq? (string-ref text k) ch)
+590
(if (> k (+ i 1))
+591
(cons (cons tag (parse-inline (substring text (+ i 1) k)))
+592
(+ k 1))
+593
#f))
+594
(else (loop (+ k 1)))))
+595
#f))
+596
+597
;; Emphasis dispatch for a delimiter char: try bold (double) then italic
+598
;; (single), matching the grammar's ordered choice.
+599
(define (scan-emphasis text i len ch)
+600
(or (scan-delim-double text i len ch 'strong)
+601
(scan-delim-single text i len ch 'em)))
+602
+603
;; Bracketed construct helper: given the position of the first inner char
+604
;; (just past '[' or '!['), scan a label up to the first ']', require an
+605
;; immediately-following '(', scan a target up to the first ')', and call
+606
;; MAKE with (label-start label-end target-start target-end). Returns
+607
;; `(result . next-pos)` or #f. Labels/targets are literal (not re-parsed),
+608
;; matching the grammar's greedy, non-backtracking `*` captures.
+609
(define (scan-bracketed text lbl-start len make)
+610
(let scan-label ((k lbl-start))
+611
(cond
+612
((>= k len) #f)
+613
((eq? (string-ref text k) #\])
+614
(if (and (< (+ k 1) len) (eq? (string-ref text (+ k 1)) #\())
+615
(let scan-target ((m (+ k 2)))
+616
(cond
+617
((>= m len) #f)
+618
((eq? (string-ref text m) #\))
+619
(cons (make lbl-start k (+ k 2) m) (+ m 1)))
+620
(else (scan-target (+ m 1)))))
+621
#f))
+622
(else (scan-label (+ k 1))))))
+623
+624
;; Image: ![alt](src). alt/src may be empty. -> (img (@ (src S) (alt A))).
+625
(define (scan-image text i len)
+626
(scan-bracketed
+627
text (+ i 2) len
+628
(lambda (a-start a-end s-start s-end)
+629
(list 'img (list '@ (list 'src (substring text s-start s-end))
+630
(list 'alt (substring text a-start a-end)))))))
+631
+632
;; Link: [text](url). text/url may be empty; text is literal (not
+633
;; re-parsed, per the grammar). -> (a (@ (href U)) TEXT).
+634
(define (scan-link text i len)
+635
(scan-bracketed
+636
text (+ i 1) len
+637
(lambda (t-start t-end u-start u-end)
+638
(list 'a (list '@ (list 'href (substring text u-start u-end)))
+639
(substring text t-start t-end)))))
+640
+641
;; Run of plain text. Advances while the char is plain and not the start
+642
;; of an image marker "![". Mirrors (+ (seq (! "![") (char-set plain?))).
+643
(define (scan-text-run text i len)
+644
(let loop ((k i))
+645
(if (and (< k len)
+646
(plain-text-char? (string-ref text k))
+647
(not (and (eq? (string-ref text k) #\!)
+648
(< (+ k 1) len)
+649
(eq? (string-ref text (+ k 1)) #\[))))
+650
(loop (+ k 1))
+651
k)))
652
653
;;; Parse inline Markdown elements from text.
654
;;;
@@ -631,10 +663,54 @@
663
(: string? -> list?)
664
(if (string-blank? text)
665
'()
−634
(let ((m (peg/match (get-inline-grammar) text)))
−635
(if m
−636
(peg-match-captures m)
−637
(list text)))))
+666
(let ((len (string-length text)))
+667
(let loop ((i 0) (acc '()))
+668
(if (>= i len)
+669
(reverse acc)
+670
(let ((c (string-ref text i)))
+671
(cond
+672
;; `code`
+673
((eq? c #\`)
+674
(let ((r (scan-code text i len)))
+675
(if r
+676
(loop (cdr r) (cons (car r) acc))
+677
(loop (+ i 1) (cons "`" acc)))))
+678
;; **bold** / *em*
+679
((eq? c #\*)
+680
(let ((r (scan-emphasis text i len #\*)))
+681
(if r
+682
(loop (cdr r) (cons (car r) acc))
+683
(loop (+ i 1) (cons "*" acc)))))
+684
;; __bold__ / _em_
+685
((eq? c #\_)
+686
(let ((r (scan-emphasis text i len #\_)))
+687
(if r
+688
(loop (cdr r) (cons (car r) acc))
+689
(loop (+ i 1) (cons "_" acc)))))
+690
;; ![alt](src) — only when followed by '['
+691
((and (eq? c #\!)
+692
(< (+ i 1) len)
+693
(eq? (string-ref text (+ i 1)) #\[))
+694
(let ((r (scan-image text i len)))
+695
(if r
+696
(loop (cdr r) (cons (car r) acc))
+697
(loop (+ i 1) (cons "!" acc)))))
+698
;; [text](url)
+699
((eq? c #\[)
+700
(let ((r (scan-link text i len)))
+701
(if r
+702
(loop (cdr r) (cons (car r) acc))
+703
(loop (+ i 1) (cons "[" acc)))))
+704
;; \escape — drops backslash, keeps next char literally
+705
((eq? c #\\)
+706
(if (< (+ i 1) len)
+707
(loop (+ i 2)
+708
(cons (substring text (+ i 1) (+ i 2)) acc))
+709
(loop (+ i 1) (cons "\\" acc))))
+710
;; plain-text run (includes a bare '!')
+711
(else
+712
(let ((end (scan-text-run text i len)))
+713
(loop end (cons (substring text i end) acc)))))))))))
714
715
;; ========== Block to SXML Conversion ==========
716