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