Commit0e639830Recorded17 Jul 2026Repositorysigil-system

Use list-sort for the canonical listing order

Message

The first cut of this hand-rolled a merge sort on the belief that the runtime exposes no sort. It does: (sigil list)'s list-sort, predicate first per SRFI-132. sort is unbound, which is what sends people looking — and two modules independently wrote their own as a result.

The hand-rolled one was also worse, and worse exactly here. Its merge built results with (cons x (merge ...)) — not tail position — so it cost O(n) stack and overflowed at 10,000 elements (measured). A directory listing is precisely the unbounded input: /gnu/store is ~190k entries. list-sort's merge accumulates and append-reverses, so it is tail-recursive and stable.

Changed
 src/sigil/system/fs.sgl | 58 ++++++++++++++++++++++++++++++++++++++--------------------
 1 file changed, 38 insertions(+), 20 deletions(-)
Diff
src/sigil/system/fs.sglmodified
@@ -27,12 +27,14 @@
27
(import (sigil fs)
28
(sigil path)
29
(sigil io)
+30
(sigil list) ; list-sort (stable, stack-safe — listing order)
31
(sigil string) ; string<? / string-downcase (listing order)
32
(sigil process) ; getenv (fs-home)
33
(sigil system grant))
34
35
(export
36
fs-home
+37
fs-sort-entries
38
fs-read-dir
39
fs-stat
40
fs-read-file
@@ -109,8 +111,16 @@
111
(define (hidden-name? name)
112
(and (> (string-length name) 0) (char=? (string-ref name 0) #\.)))
113
+114
;; `type:` is a SYMBOL in a sigil-system stat dict, but a wrapper that has
+115
;; already marshalled for the wire carries the string. Accept both, so a caller
+116
;; can sort on either side of its own encoding step rather than being forced to
+117
;; sort before it (or to re-implement the rule).
+118
(define (entry-directory? e)
+119
(let ((t (dict-ref e type: #f)))
+120
(or (eq? t 'directory) (equal? t "directory"))))
+121
122
(define (entry-tier e)
113
(let ((dir? (eq? (dict-ref e type: #f) 'directory))
+123
(let ((dir? (entry-directory? e))
124
(hidden? (hidden-name? (dict-ref e name: ""))))
125
(cond ((and dir? hidden?) 0)
126
(dir? 1)
@@ -131,35 +141,43 @@
141
((string<? nb na) #f)
142
(else (string<? (dict-ref a name: "") (dict-ref b name: "")))))))))
143
134
;; A self-contained stable merge sort. The runtime exposes no `sort` (the same
135
;; reason (slate selector) carries its own), and a listing can be large enough
136
;; that the O(n log n) matters.
137
(define (msort-split xs)
138
(cond ((null? xs) (cons '() '()))
139
((null? (cdr xs)) (cons xs '()))
140
(else (let ((r (msort-split (cddr xs))))
141
(cons (cons (car xs) (car r)) (cons (cadr xs) (cdr r)))))))
142
(define (msort-merge before? a b)
143
(cond ((null? a) b)
144
((null? b) a)
145
((before? (car b) (car a)) (cons (car b) (msort-merge before? a (cdr b))))
146
(else (cons (car a) (msort-merge before? (cdr a) b)))))
147
(define (msort before? xs)
148
(if (or (null? xs) (null? (cdr xs)))
149
xs
150
(let ((s (msort-split xs)))
151
(msort-merge before? (msort before? (car s)) (msort before? (cdr s))))))
+144
;; Sorting is (sigil list)'s `list-sort`: a stable merge sort whose own merge is
+145
;; tail-recursive (it accumulates and append-reverses), so it costs O(log n)
+146
;; stack rather than O(n). That matters here more than anywhere — a directory
+147
;; listing is exactly the unbounded-length input, and /gnu/store is ~190k
+148
;; entries. A hand-rolled merge that builds with (cons x (merge …)) is not in
+149
;; tail position and overflows at 10k (measured), so `sort` being unbound is NOT
+150
;; a reason to write one: the name is just `list-sort`, predicate first
+151
;; (SRFI-132).
+152
+153
;;; Put a list of entry dicts (anything carrying `name:` and `type:`)
+154
;;; into the canonical order described above.
+155
;;;
+156
;;; Exported because `fs-read-dir` is NOT the only listing path. A
+157
;;; wrapper serving a browsable UI cannot always use it: fs-read-dir
+158
;;; stats EVERY name, which at /gnu/store scale (~190k entries) is
+159
;;; prohibitive, so such a wrapper lists names, caps, and stats only
+160
;;; the survivors. That wrapper still wants THIS order, and should get
+161
;;; it by calling this — not by re-deriving the rule and drifting from
+162
;;; it. (lantern-system's fs.read-dir is exactly that caller.)
+163
(define (fs-sort-entries entries)
+164
(: list? -> list?)
+165
(list-sort entry-before? entries))
166
167
;;; List a directory. Returns one stat dict per entry (the one
168
;;; round-trip a directory view needs), in the canonical order above.
169
;;; Entries that vanish between the listing and the stat are skipped.
+170
;;;
+171
;;; Stats every entry: fine for ordinary directories, prohibitive for
+172
;;; a pathological one. A caller that must cap should list + cap
+173
;;; itself and call `fs-sort-entries` on what it kept.
174
(define (fs-read-dir g path)
175
(: any? string? -> list?)
176
(grant-assert! g 'fs-read path)
177
(let ((names (directory-list path)))
178
(unless names
179
(error "fs-read-dir: cannot list directory" path))
162
(msort entry-before?
+180
(fs-sort-entries
181
(fold-right
182
(lambda (name acc)
183
(let* ((entry-path (path-join path name))