fs-read-dir: return entries in a canonical order
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.
package.sgl | 2 +-
src/sigil/system/fs.sgl | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
test/test-system.sgl | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 131 insertions(+), 12 deletions(-)package.sglmodified
(package name: "sigil-system" version: "0.1.0" version: "0.1.1" sigil: "^0.17" description: "System capability library: filesystem, processes, and PTY sessions behind a grant-checked surface" url: "https://codeberg.org/sigil/sigil-system"src/sigil/system/fs.sglmodified
(import (sigil fs) (sigil path) (sigil io) (sigil string) ; string<? / string-downcase (listing order) (sigil system grant)) (export (error "fs-stat: no such path" path)) (stat->dict (path-basename path) path st))) ;; ---- canonical listing order -------------------------------------------- ;; A directory listing is sorted HERE, at the source, rather than by each ;; client. Two reasons, and the second is the load-bearing one: ;; ;; * Canonicality — every consumer (Slate browsing the local machine through ;; Lantern, a remote node through Familiar) shows the same order without ;; duplicating the rule. ;; * Cost — a client re-sorting per render pays for it on every keystroke, in ;; an interpreted runtime, over listings that reach 10k entries. ;; ;; This IS presentation policy living in a capability library, which deserves to ;; be said out loud rather than discovered: it is a deliberate DEFAULT, not a ;; mandate. A client wanting another order (by date, by size) re-sorts on top of ;; this; it just doesn't pay for one by default. `directory-list` order is the ;; filesystem's, i.e. arbitrary, so SOME order had to be chosen — this one is ;; David's, and the alternative to choosing was every client choosing separately. ;; ;; Four tiers: all directories before all files, and within each kind hidden ;; before visible — so a repo shows .git/ then src/ then .gitignore then ;; README.md. Alphabetical within a tier, CASE-INSENSITIVELY: a file list is read ;; by a human, and ASCII order would file Zebra before apple. (define (hidden-name? name) (and (> (string-length name) 0) (char=? (string-ref name 0) #\.))) (define (entry-tier e) (let ((dir? (eq? (dict-ref e type: #f) 'directory)) (hidden? (hidden-name? (dict-ref e name: "")))) (cond ((and dir? hidden?) 0) (dir? 1) (hidden? 2) (else 3)))) ;; Order two entries: tier first, then name, case-insensitively. The ;; case-sensitive name is the final tie-break so that entries differing only in ;; case get a stable, total order instead of an arbitrary one. (define (entry-before? a b) (let ((ta (entry-tier a)) (tb (entry-tier b))) (cond ((< ta tb) #t) ((> ta tb) #f) (else (let ((na (string-downcase (dict-ref a name: ""))) (nb (string-downcase (dict-ref b name: "")))) (cond ((string<? na nb) #t) ((string<? nb na) #f) (else (string<? (dict-ref a name: "") (dict-ref b name: ""))))))))) ;; A self-contained stable merge sort. The runtime exposes no `sort` (the same ;; reason (slate selector) carries its own), and a listing can be large enough ;; that the O(n log n) matters. (define (msort-split xs) (cond ((null? xs) (cons '() '())) ((null? (cdr xs)) (cons xs '())) (else (let ((r (msort-split (cddr xs)))) (cons (cons (car xs) (car r)) (cons (cadr xs) (cdr r))))))) (define (msort-merge before? a b) (cond ((null? a) b) ((null? b) a) ((before? (car b) (car a)) (cons (car b) (msort-merge before? a (cdr b)))) (else (cons (car a) (msort-merge before? (cdr a) b))))) (define (msort before? xs) (if (or (null? xs) (null? (cdr xs))) xs (let ((s (msort-split xs))) (msort-merge before? (msort before? (car s)) (msort before? (cdr s)))))) ;;; List a directory. Returns one stat dict per entry (the one ;;; round-trip a directory view needs). Entries that vanish between ;;; the listing and the stat are skipped. ;;; round-trip a directory view needs), in the canonical order above. ;;; Entries that vanish between the listing and the stat are skipped. (define (fs-read-dir g path) (: any? string? -> list?) (grant-assert! g 'fs-read path) (let ((names (directory-list path))) (unless names (error "fs-read-dir: cannot list directory" path)) (fold-right (lambda (name acc) (let* ((entry-path (path-join path name)) (st (file-stat entry-path))) (if st (cons (stat->dict name entry-path st) acc) acc))) '() names))) (msort entry-before? (fold-right (lambda (name acc) (let* ((entry-path (path-join path name)) (st (file-stat entry-path))) (if st (cons (stat->dict name entry-path st) acc) acc))) '() names)))) ;;; Read a file's contents as a string. (define (fs-read-file g path)test/test-system.sglmodified
(assert-true (dict-contains? (car entries) name:)) (assert-true (dict-contains? (car entries) size:))))) ;; ---- canonical listing order -------------------------------------------- ;; Sorted at the source so every consumer agrees and no client re-sorts per ;; render. `directory-list` order is the filesystem's (arbitrary), so these ;; assert the ORDER itself, not merely the membership. (define (entry-names entries) (map (lambda (e) (dict-ref e name: "")) entries)) (test "read-dir sorts: dirs before files, hidden before visible in each" ;; David's four tiers: hidden dirs, dirs, hidden files, files. A repo should ;; read .git/ -> src/ -> .gitignore -> README.md. (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g (string-append "fs:rw:" dir)) ;; created in deliberately scrambled order (fs-write-file g (path-join dir "README.md") "r") (fs-mkdir g (path-join dir "src")) (fs-write-file g (path-join dir ".gitignore") "i") (fs-mkdir g (path-join dir ".git")) (assert-equal (entry-names (fs-read-dir g dir)) (list ".git" "src" ".gitignore" "README.md")))) (test "read-dir sorts alphabetically WITHIN a tier, case-insensitively" ;; A file list is read by a human: ASCII order would file Zebra before apple. (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g (string-append "fs:rw:" dir)) (fs-write-file g (path-join dir "Zebra.txt") "z") (fs-write-file g (path-join dir "apple.txt") "a") (fs-write-file g (path-join dir "Mango.txt") "m") (assert-equal (entry-names (fs-read-dir g dir)) (list "apple.txt" "Mango.txt" "Zebra.txt")))) (test "read-dir order is total: names differing only in case are stable" (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g (string-append "fs:rw:" dir)) (fs-write-file g (path-join dir "b.txt") "b") (fs-write-file g (path-join dir "B.txt") "B") ;; equal case-insensitively -> the case-sensitive name breaks the tie, so the ;; order is deterministic rather than filesystem-dependent (assert-equal (entry-names (fs-read-dir g dir)) (list "B.txt" "b.txt")))) (test "read-dir puts every directory ahead of every file" (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g (string-append "fs:rw:" dir)) (fs-write-file g (path-join dir "aaa.txt") "a") ; sorts first alphabetically (fs-mkdir g (path-join dir "zzz")) ; but a dir outranks it (assert-equal (entry-names (fs-read-dir g dir)) (list "zzz" "aaa.txt")))) (test "stat reports type and size" (let ((g (make-grants)) (dir (make-temp-directory)))