Commit01434242Recorded17 Jul 2026Repositorysigil-system

fs-read-dir: return entries in a canonical order

Message

directory-list yields the filesystem's order, i.e. arbitrary, so every client had to choose its own — or show an arbitrary one. Sort at the source instead: each consumer (Slate browsing the local machine via Lantern, a remote node via Familiar) gets the same order without duplicating the rule, and no client pays to re-sort per render, which in an interpreted runtime over a 10k listing is real cost on every keystroke.

Four tiers: all directories before all files, hidden before visible within each, so a repo reads .git/ -> src/ -> .gitignore -> README.md. Alphabetical within a tier, case-INSENSITIVELY (a file list is read by a human; ASCII order files Zebra before apple), with the case-sensitive name as a final tie-break so names differing only in case get a total, deterministic order.

This is presentation policy in a capability library, which is worth stating rather than leaving to be discovered: it is a deliberate DEFAULT, not a mandate. A client wanting date/size order re-sorts on top; it just no longer pays for an order it didn't ask for.

Carries a self-contained stable merge sort because the runtime exposes no sort — the same reason (slate selector) carries one. That duplication wants a runtime-level sort, not a third copy.

Bumps 0.1.0 -> 0.1.1. Patch, not minor: pre-1.0 convention reserves minor for breaking API changes, and this changes output ORDER, not the signature. Practically, lantern-system pins ^0.1 — a 0.2.0 would not resolve under that caret range, which is exactly the breakage signal minor is reserved for.

Changed
 package.sgl             |  2 +-
 src/sigil/system/fs.sgl | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
 test/test-system.sgl    | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 131 insertions(+), 12 deletions(-)
Diff
package.sglmodified
@@ -7,7 +7,7 @@
7
8
(package
9
name: "sigil-system"
10
version: "0.1.0"
+10
version: "0.1.1"
11
sigil: "^0.17"
12
description: "System capability library: filesystem, processes, and PTY sessions behind a grant-checked surface"
13
url: "https://codeberg.org/sigil/sigil-system"
src/sigil/system/fs.sglmodified
@@ -27,6 +27,7 @@
27
(import (sigil fs)
28
(sigil path)
29
(sigil io)
+30
(sigil string) ; string<? / string-downcase (listing order)
31
(sigil system grant))
32
33
(export
@@ -61,24 +62,90 @@
62
(error "fs-stat: no such path" path))
63
(stat->dict (path-basename path) path st)))
64
+65
;; ---- canonical listing order --------------------------------------------
+66
;; A directory listing is sorted HERE, at the source, rather than by each
+67
;; client. Two reasons, and the second is the load-bearing one:
+68
;;
+69
;; * Canonicality — every consumer (Slate browsing the local machine through
+70
;; Lantern, a remote node through Familiar) shows the same order without
+71
;; duplicating the rule.
+72
;; * Cost — a client re-sorting per render pays for it on every keystroke, in
+73
;; an interpreted runtime, over listings that reach 10k entries.
+74
;;
+75
;; This IS presentation policy living in a capability library, which deserves to
+76
;; be said out loud rather than discovered: it is a deliberate DEFAULT, not a
+77
;; mandate. A client wanting another order (by date, by size) re-sorts on top of
+78
;; this; it just doesn't pay for one by default. `directory-list` order is the
+79
;; filesystem's, i.e. arbitrary, so SOME order had to be chosen — this one is
+80
;; David's, and the alternative to choosing was every client choosing separately.
+81
;;
+82
;; Four tiers: all directories before all files, and within each kind hidden
+83
;; before visible — so a repo shows .git/ then src/ then .gitignore then
+84
;; README.md. Alphabetical within a tier, CASE-INSENSITIVELY: a file list is read
+85
;; by a human, and ASCII order would file Zebra before apple.
+86
(define (hidden-name? name)
+87
(and (> (string-length name) 0) (char=? (string-ref name 0) #\.)))
+88
+89
(define (entry-tier e)
+90
(let ((dir? (eq? (dict-ref e type: #f) 'directory))
+91
(hidden? (hidden-name? (dict-ref e name: ""))))
+92
(cond ((and dir? hidden?) 0)
+93
(dir? 1)
+94
(hidden? 2)
+95
(else 3))))
+96
+97
;; Order two entries: tier first, then name, case-insensitively. The
+98
;; case-sensitive name is the final tie-break so that entries differing only in
+99
;; case get a stable, total order instead of an arbitrary one.
+100
(define (entry-before? a b)
+101
(let ((ta (entry-tier a)) (tb (entry-tier b)))
+102
(cond ((< ta tb) #t)
+103
((> ta tb) #f)
+104
(else
+105
(let ((na (string-downcase (dict-ref a name: "")))
+106
(nb (string-downcase (dict-ref b name: ""))))
+107
(cond ((string<? na nb) #t)
+108
((string<? nb na) #f)
+109
(else (string<? (dict-ref a name: "") (dict-ref b name: "")))))))))
+110
+111
;; A self-contained stable merge sort. The runtime exposes no `sort` (the same
+112
;; reason (slate selector) carries its own), and a listing can be large enough
+113
;; that the O(n log n) matters.
+114
(define (msort-split xs)
+115
(cond ((null? xs) (cons '() '()))
+116
((null? (cdr xs)) (cons xs '()))
+117
(else (let ((r (msort-split (cddr xs))))
+118
(cons (cons (car xs) (car r)) (cons (cadr xs) (cdr r)))))))
+119
(define (msort-merge before? a b)
+120
(cond ((null? a) b)
+121
((null? b) a)
+122
((before? (car b) (car a)) (cons (car b) (msort-merge before? a (cdr b))))
+123
(else (cons (car a) (msort-merge before? (cdr a) b)))))
+124
(define (msort before? xs)
+125
(if (or (null? xs) (null? (cdr xs)))
+126
xs
+127
(let ((s (msort-split xs)))
+128
(msort-merge before? (msort before? (car s)) (msort before? (cdr s))))))
+129
130
;;; List a directory. Returns one stat dict per entry (the one
65
;;; round-trip a directory view needs). Entries that vanish between
66
;;; the listing and the stat are skipped.
+131
;;; round-trip a directory view needs), in the canonical order above.
+132
;;; Entries that vanish between the listing and the stat are skipped.
133
(define (fs-read-dir g path)
134
(: any? string? -> list?)
135
(grant-assert! g 'fs-read path)
136
(let ((names (directory-list path)))
137
(unless names
138
(error "fs-read-dir: cannot list directory" path))
73
(fold-right
74
(lambda (name acc)
75
(let* ((entry-path (path-join path name))
76
(st (file-stat entry-path)))
77
(if st
78
(cons (stat->dict name entry-path st) acc)
79
acc)))
80
'()
81
names)))
+139
(msort entry-before?
+140
(fold-right
+141
(lambda (name acc)
+142
(let* ((entry-path (path-join path name))
+143
(st (file-stat entry-path)))
+144
(if st
+145
(cons (stat->dict name entry-path st) acc)
+146
acc)))
+147
'()
+148
names))))
149
150
;;; Read a file's contents as a string.
151
(define (fs-read-file g path)
test/test-system.sglmodified
@@ -117,6 +117,58 @@
117
(assert-true (dict-contains? (car entries) name:))
118
(assert-true (dict-contains? (car entries) size:)))))
119
+120
;; ---- canonical listing order --------------------------------------------
+121
;; Sorted at the source so every consumer agrees and no client re-sorts per
+122
;; render. `directory-list` order is the filesystem's (arbitrary), so these
+123
;; assert the ORDER itself, not merely the membership.
+124
+125
(define (entry-names entries) (map (lambda (e) (dict-ref e name: "")) entries))
+126
+127
(test "read-dir sorts: dirs before files, hidden before visible in each"
+128
;; David's four tiers: hidden dirs, dirs, hidden files, files. A repo should
+129
;; read .git/ -> src/ -> .gitignore -> README.md.
+130
(let ((g (make-grants))
+131
(dir (make-temp-directory)))
+132
(grant-add! g (string-append "fs:rw:" dir))
+133
;; created in deliberately scrambled order
+134
(fs-write-file g (path-join dir "README.md") "r")
+135
(fs-mkdir g (path-join dir "src"))
+136
(fs-write-file g (path-join dir ".gitignore") "i")
+137
(fs-mkdir g (path-join dir ".git"))
+138
(assert-equal (entry-names (fs-read-dir g dir))
+139
(list ".git" "src" ".gitignore" "README.md"))))
+140
+141
(test "read-dir sorts alphabetically WITHIN a tier, case-insensitively"
+142
;; A file list is read by a human: ASCII order would file Zebra before apple.
+143
(let ((g (make-grants))
+144
(dir (make-temp-directory)))
+145
(grant-add! g (string-append "fs:rw:" dir))
+146
(fs-write-file g (path-join dir "Zebra.txt") "z")
+147
(fs-write-file g (path-join dir "apple.txt") "a")
+148
(fs-write-file g (path-join dir "Mango.txt") "m")
+149
(assert-equal (entry-names (fs-read-dir g dir))
+150
(list "apple.txt" "Mango.txt" "Zebra.txt"))))
+151
+152
(test "read-dir order is total: names differing only in case are stable"
+153
(let ((g (make-grants))
+154
(dir (make-temp-directory)))
+155
(grant-add! g (string-append "fs:rw:" dir))
+156
(fs-write-file g (path-join dir "b.txt") "b")
+157
(fs-write-file g (path-join dir "B.txt") "B")
+158
;; equal case-insensitively -> the case-sensitive name breaks the tie, so the
+159
;; order is deterministic rather than filesystem-dependent
+160
(assert-equal (entry-names (fs-read-dir g dir))
+161
(list "B.txt" "b.txt"))))
+162
+163
(test "read-dir puts every directory ahead of every file"
+164
(let ((g (make-grants))
+165
(dir (make-temp-directory)))
+166
(grant-add! g (string-append "fs:rw:" dir))
+167
(fs-write-file g (path-join dir "aaa.txt") "a") ; sorts first alphabetically
+168
(fs-mkdir g (path-join dir "zzz")) ; but a dir outranks it
+169
(assert-equal (entry-names (fs-read-dir g dir))
+170
(list "zzz" "aaa.txt"))))
+171
172
(test "stat reports type and size"
173
(let ((g (make-grants))
174
(dir (make-temp-directory)))