Commitf34ad663Recorded20 Jul 2026Repositorysigil-markdown

perf(blocks): hand-write block classifiers; drop unused sigil-peg dep

Message

The block-line classifiers (parse-atx-header, fenced-code-start?, fence-info, parse-list-item) were the last PEG consumers and, after the inline rewrite, the dominant render cost: the grammars' (* any-char) tail cost O(line length) per call (~90us to classify one heading line). Replace them with short hand-written scans producing byte-identical results, verified against golden snapshots of the original parser over a block edge-case corpus (7+ hashes, no-space markers, tilde/4-backtick/indented fences, multi-digit and paren-style ordered markers, indented items).

With that, (sigil peg) is no longer used anywhere in the parser, so the sigil-peg dependency is removed entirely (smaller wasm bundle in Slate).

Final native speedup vs original baseline: mixed 2.6KB 161 -> 1.70 ms (95x) large 26KB 1797 -> 16.5 ms (108x) render cost is linear at ~650 us/KB from 2.6KB to 104KB (no O(n^2)).

Also makes test/test-markdown.sgl self-contained (it imported the removed (sigil sxml) library); defines the 3 SXML accessors it needs locally. All 18 tests pass.

Changed
 bench/check.sgl              |   3 ++-
 bench/corpus/block-edge.md   |  52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 bench/golden/block-edge.sxml |   1 +
 package.sgl                  |   3 +--
 sigil.lock                   |   5 -----
 src/sigil/markdown.sgl       | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------------------------------
 test/test-markdown.sgl       |  24 ++++++++++++++++++++++--
 7 files changed, 160 insertions(+), 63 deletions(-)
Diff
bench/check.sglmodified
@@ -15,7 +15,8 @@
15
(sigil string))
16
17
(define corpus
18
(list "mixed" "inline-edge" "elements" "emphasis-edge" "code-link-edge"))
+18
(list "mixed" "inline-edge" "elements" "emphasis-edge" "code-link-edge"
+19
"block-edge"))
20
21
(ensure-directory "bench/golden")
22
bench/corpus/block-edge.mdadded
@@ -0,0 +1,52 @@
+1
####### Seven hashes is not a header
+2
#NoSpaceAfterHash is not a header
+3
###### Six hashes is a header
+4
#
+5
+6
Paragraph then
+7
####### seven hashes mid-paragraph stays paragraph
+8
+9
-no space after dash
+10
- space after dash is a list
+11
-
+12
*no space star
+13
+ plus with space
+14
1.no space after dot
+15
1. ordered with space
+16
10. multi digit ordered
+17
1) paren ordered is not a list
+18
+19
- indented dash item
+20
- deeper indented item
+21
+22
Text before list
+23
- item right after paragraph
+24
+25
~~~
+26
tilde fence no lang
+27
~~~
+28
+29
~~~ruby
+30
tilde fence with lang
+31
~~~
+32
+33
````
+34
four backtick fence
+35
````
+36
+37
```js
+38
fence with lang js
+39
```
+40
+41
```
+42
indented three-space fence
+43
```
+44
+45
> quote level one
+46
>quote no space after gt
+47
> quote with **bold** and `code`
+48
+49
- - -
+50
* * *
+51
+52
Regular ending paragraph with trailing text.
bench/golden/block-edge.sxmladded
@@ -0,0 +1 @@
+1
(document (p "####### Seven hashes is not a header #NoSpaceAfterHash is not a header") (h6 "Six hashes is a header") (p "#") (p "Paragraph then ####### seven hashes mid-paragraph stays paragraph") (p "-no space after dash") (ul (li "space after dash is a list - " "*" "no space star") (li "plus with space 1.no space after dot")) (ol (li "ordered with space") (li "multi digit ordered 1) paren ordered is not a list ")) (ul (li "indented dash item") (li "deeper indented item")) (p "Text before list") (ul (li "item right after paragraph")) (pre "tilde fence no lang") (pre (@ (lang "ruby")) "tilde fence with lang") (pre "four backtick fence") (pre (@ (lang "js")) "fence with lang js") (pre "indented three-space fence") (blockquote "quote level one quote no space after gt quote with " (strong "bold") " and " (code "code")) (hr) (hr) (p "Regular ending paragraph with trailing text."))
2
No newline at end of file
package.sglmodified
@@ -25,5 +25,4 @@
25
optimize: 2))
26
27
dependencies: (list
28
(from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.9.0")
29
(from-git url: "codeberg:sigil/sigil-peg" version: "^0.9.0")))
+28
(from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.9.0")))
sigil.lockmodified
@@ -6,9 +6,4 @@
6
sha: "54f66e8ec446b65b75ab6f12454d2c0aa269f2c0"
7
package-selector: "sigil-stdlib"
8
version: "0.9.1")
9
(package name: "sigil-peg"
10
url: "codeberg:sigil/sigil-peg"
11
ref: "^0.9.0"
12
sha: "2a0adbd32b13fb835cf021a8e279341161a77436"
13
version: "0.9.1")
9
)
src/sigil/markdown.sglmodified
@@ -43,7 +43,6 @@
43
44
(define-library (sigil markdown)
45
(import (sigil string)
46
(sigil peg)
46
(sigil io))
47
(export
48
;; Main API
@@ -204,66 +203,96 @@
203
(cons parsed metadata)
204
metadata))))))))
205
207
;; ========== PEG Patterns for Block Parsing ==========
208
209
;; ATX header: 1-6 '#' followed by space and text
210
(define header-pattern
211
'(seq (<- (between 1 6 "#")) " " (<- (* any-char))))
212
213
;; Fenced code block start: 3+ backticks or tildes with optional language
214
(define fence-start-pattern
215
'(seq (* " ") (/ (between 3 100 "`") (between 3 100 "~")) (* any-char)))
216
217
;; Extract language info from fence line
218
(define fence-info-pattern
219
'(seq (* " ") (/ (+ "`") (+ "~")) (<- (* any-char))))
220
221
;; Unordered list item: optional spaces, marker (- + *), space, text
222
(define unordered-item-pattern
223
'(seq (<- (* " ")) (<- (/ "-" "+" "*")) " " (<- (* any-char))))
224
225
;; Ordered list item: optional spaces, digits, period, space, text
226
(define ordered-item-pattern
227
'(seq (<- (* " ")) (<- (+ (char-range #\0 #\9))) ". " (<- (* any-char))))
228
206
;; ========== Block Line Classification ==========
230
231
;; Parse ATX header (# Header) using PEG
+207
;;
+208
;; These classifiers were previously driven by the interpreted PEG
+209
;; engine, whose `(* any-char)` tail cost O(line length) per call
+210
;; (~90us to classify one heading line) — the dominant block-parsing
+211
;; cost once the inline scanner landed. They are now short hand-written
+212
;; scans producing byte-identical results (verified against golden
+213
;; snapshots of the original PEG-based parser over the block edge-case
+214
;; corpus: 7+ hashes, no-space markers, tilde/4-backtick/indented fences,
+215
;; multi-digit and paren-style ordered markers, indented items).
+216
+217
;; Parse an ATX header line: 1-6 leading '#' then a single space then
+218
;; text. Returns (header LEVEL TRIMMED-TEXT) or #f. Seven-plus hashes or
+219
;; a missing space after the hashes is not a header (matches the greedy,
+220
;; non-backtracking `(between 1 6 "#")` followed by a literal space).
221
(define (parse-atx-header line)
233
(let ((m (peg/match header-pattern line)))
234
(if m
235
(let ((caps (peg-match-captures m)))
236
(list 'header (string-length (car caps)) (string-trim (cadr caps))))
237
#f)))
238
239
;; Check if line starts a fenced code block using PEG
+222
(let ((len (string-length line)))
+223
(let loop ((n 0))
+224
(cond
+225
((and (< n 6) (< n len) (eq? (string-ref line n) #\#))
+226
(loop (+ n 1)))
+227
((and (>= n 1)
+228
(< n len)
+229
(eq? (string-ref line n) #\space))
+230
(list 'header n (string-trim (substring line (+ n 1) len))))
+231
(else #f)))))
+232
+233
;; Skip leading spaces; return the index of the first non-space char.
+234
(define (skip-spaces line i len)
+235
(if (and (< i len) (eq? (string-ref line i) #\space))
+236
(skip-spaces line (+ i 1) len)
+237
i))
+238
+239
;; Count a run of CH starting at K; return the index past the run.
+240
(define (skip-run line k len ch)
+241
(if (and (< k len) (eq? (string-ref line k) ch))
+242
(skip-run line (+ k 1) len ch)
+243
k))
+244
+245
;; Does LINE start a fenced code block? Optional leading spaces, then 3+
+246
;; backticks or 3+ tildes.
247
(define (fenced-code-start? line)
241
(if (peg/match fence-start-pattern line) #t #f))
+248
(let* ((len (string-length line))
+249
(i (skip-spaces line 0 len)))
+250
(or (>= (- (skip-run line i len #\`) i) 3)
+251
(>= (- (skip-run line i len #\~) i) 3))))
252
243
;; Get fence info (language) from opening fence using PEG
+253
;; Extract the info string (language) from an opening fence: skip leading
+254
;; spaces and the run of fence chars, then trim the remainder.
255
(define (fence-info line)
245
(let ((m (peg/match fence-info-pattern line)))
246
(if m
247
(string-trim (car (peg-match-captures m)))
+256
(let* ((len (string-length line))
+257
(i (skip-spaces line 0 len))
+258
(ch (cond
+259
((and (< i len) (eq? (string-ref line i) #\`)) #\`)
+260
((and (< i len) (eq? (string-ref line i) #\~)) #\~)
+261
(else #f))))
+262
(if ch
+263
(string-trim (substring line (skip-run line i len ch) len))
264
"")))
265
250
;; Check if line is a list item using PEG, return (type marker indent rest) or #f
+266
;; Classify a list-item line. Returns (unordered MARKER-CHAR INDENT REST)
+267
;; or (ordered DIGIT-STRING INDENT REST) or #f. INDENT is the leading
+268
;; space count; a single space (unordered) or ". " (ordered) must follow
+269
;; the marker, else it is not a list item.
270
(define (parse-list-item line)
252
(let ((m (peg/match unordered-item-pattern line)))
253
(if m
254
(let ((caps (peg-match-captures m)))
255
(list 'unordered
256
(string-ref (cadr caps) 0)
257
(string-length (car caps))
258
(caddr caps)))
259
(let ((m2 (peg/match ordered-item-pattern line)))
260
(if m2
261
(let ((caps (peg-match-captures m2)))
262
(list 'ordered
263
(cadr caps)
264
(string-length (car caps))
265
(caddr caps)))
266
#f)))))
+271
(let* ((len (string-length line))
+272
(i (skip-spaces line 0 len)))
+273
(cond
+274
;; Unordered: marker (- + *) then a single space
+275
((and (< i len)
+276
(let ((c (string-ref line i)))
+277
(or (eq? c #\-) (eq? c #\+) (eq? c #\*)))
+278
(< (+ i 1) len)
+279
(eq? (string-ref line (+ i 1)) #\space))
+280
(list 'unordered (string-ref line i) i
+281
(substring line (+ i 2) len)))
+282
;; Ordered: one or more digits then ". "
+283
(else
+284
(let ((k (let scan ((k i))
+285
(if (and (< k len)
+286
(char-numeric? (string-ref line k)))
+287
(scan (+ k 1))
+288
k))))
+289
(if (and (> k i)
+290
(< (+ k 1) len)
+291
(eq? (string-ref line k) #\.)
+292
(eq? (string-ref line (+ k 1)) #\space))
+293
(list 'ordered (substring line i k) i
+294
(substring line (+ k 2) len))
+295
#f))))))
296
297
;; ========== Table Parsing ==========
298
test/test-markdown.sglmodified
@@ -1,6 +1,26 @@
1
(import (sigil test)
2
(sigil markdown)
3
(sigil sxml))
+2
(sigil markdown))
+3
+4
;; Minimal SXML accessors for these tests. The standalone (sigil sxml)
+5
;; library is no longer part of this package's dependency set (SXML helpers
+6
;; now live in sigil-wasm-dom), so we define the few accessors the tests
+7
;; need locally rather than pulling in a UI dependency for testing.
+8
(define (sxml-tag node) (car node))
+9
+10
(define (sxml-has-attrs? node)
+11
(and (pair? (cdr node))
+12
(pair? (cadr node))
+13
(eq? (car (cadr node)) '@)))
+14
+15
(define (sxml-attributes node)
+16
(if (sxml-has-attrs? node)
+17
(cdr (cadr node))
+18
'()))
+19
+20
(define (sxml-content node)
+21
(if (sxml-has-attrs? node)
+22
(cddr node)
+23
(cdr node)))
24
25
;; ============================================================
26
;; Headings