Files beyond slurp and barf, split by whether a handler could act

Five more: file-exists?, file-size, delete-file, rename-file and
make-directory. The interesting thing is not the list, it is the line drawn
through it.

file-exists? and file-size answer a value -- a bool and an (Option i64) -- and
are prelude functions over one declare that the compiler knows nothing about.
Absence is the reply to those two questions and not a fault, so a condition
would make the ordinary case pay for a handler search, and there is no restart
a handler could take that would turn "it is not there" into a different
answer.

delete-file, rename-file and make-directory answer () and signal FileError,
and they are check.ml builtins for the one thing a declare cannot do: they go
through file_guard, so each failure arrives under retry and use-value. Those
are restarts a handler really can take -- make the parent directory and retry,
or supply another path -- which is exactly the case a bool return throws away.
op continues the prelude's numbering as 2, 3 and 4.

One C function behind the two questions rather than two, because they are one
question: stat answers whether the path resolves and how big it is in the same
breath. It is stat and not flan_file_size's fopen-plus-ftell, which is shaped
by slurp being about to read the file and is wrong as a general size -- fopen
on a directory succeeds on Linux and ftell then answers a number that is not a
file size. The two coexist and answer different questions.

rename holds the source in the guard's path slot, so a use-value renames a
different file to the same destination. Both readings are plausible until
somebody says which, so check.ml says which.

The errno mapping is not extended. Its three buckets are what a handler can
act on; EEXIST and ENOTEMPTY land in io with everything else, and that is
honest until conditions have a hierarchy to hang a fourth reason off.

All three carry barf's decision 2 unchanged: they change the filesystem, so on
the web they signal rather than succeeding quietly into a filesystem the page
throws away.

Not here, and not half-parsed either: a directory listing, which needs an
allocating builtin and a Vec of owned strings, and streaming IO. Neither has
a name to trip over.

programs/files.flan makes and removes its own tree and takes both restarts on
operations that write. The runtime additions continue the block at the end of
flan_rt.c.
This commit is contained in:
Joseph Ferano 2026-09-17 21:51:17 +07:00
parent 2dd13b5ae0
commit c5b8af23a1
7 changed files with 382 additions and 1 deletions

View File

@ -4777,6 +4777,70 @@ and named_call ctx ~want loc name args =
[ file_guard ctx loc ~path_slot:ps ~op:1 steps ])))
| _ -> assert false)
(* ── the three that change the filesystem ──────────────────────────
[delete-file], [rename-file] and [make-directory] are [barf]'s shape with
a different runtime call, and they are here rather than as prelude
[declare]s for the one thing a declare cannot do: signal [FileError] with
the two restarts the compiler emits. A declare could only answer a bool,
and "the delete failed, here is a boolean" is the shape decision 5 exists
to keep out of this language a handler that made the parent directory
and wants [retry], or that has another path and wants [use-value], has
nothing to hold onto.
Each answers [()] and not a bool for the same reason [barf] does: the
failure is the condition, so a return value would only ever be true. The
questions that are *not* failures does this exist, how big is it
answer a value instead, and those two are prelude functions over one
[declare] because nothing about them needs a restart.
[op] continues the FileError numbering the prelude names: 0 read, 1 write,
and 2, 3, 4 here. A handler matching on it is matching on the prelude's
[file-op-delete] and friends, not on a literal. *)
| "delete-file" | "make-directory" ->
arity loc name 1 args;
let sym, op =
if String.equal name "delete-file" then "flan_file_delete", 2
else "flan_file_mkdir", 4
in
let path = check ctx ~want:Types.String (List.hd args) in
let ps = fresh_slot ctx Types.String in
let steps try_ =
[ try_ (rt loc (Types.Int Types.I8) sym
[ mk loc Types.String (Tast.Local ps) ]) ]
in
expect loc ~want
(mk loc Types.Unit
(Tast.Let ([ (ps, path) ],
[ file_guard ctx loc ~path_slot:ps ~op steps ])))
(* Two paths and one restart slot, so the guard holds the *source*: a
[use-value] renames a different file to the same destination. That is the
direction a handler can act on the destination it asked for is the one
thing it already knows and it is written down here because the other
reading is equally plausible until somebody says which it is.
The destination is bound before the loop, exactly as [barf] binds its
data, so a retry re-attempts the rename and not the expression that
computed where to. *)
| "rename-file" ->
arity loc name 2 args;
(match args with
| [ from_; to_ ] ->
let from_ = check ctx ~want:Types.String from_ in
let to_ = check ctx ~want:Types.String to_ in
let ps = fresh_slot ctx Types.String in
let ds = fresh_slot ctx Types.String in
let steps try_ =
[ try_ (rt loc (Types.Int Types.I8) "flan_file_rename"
[ mk loc Types.String (Tast.Local ps);
mk loc Types.String (Tast.Local ds) ]) ]
in
expect loc ~want
(mk loc Types.Unit
(Tast.Let ([ (ps, from_); (ds, to_) ],
[ file_guard ctx loc ~path_slot:ps ~op:3 steps ])))
| _ -> assert false)
(* ── containers ────────────────────────────────────────────────── *)
(* [at] and [len] were already the names for a fixed array and a slice, so a
Vec extends them rather than adding a parallel pair which is the

View File

@ -2809,6 +2809,14 @@ declare i64 @flan_hash_combine(i64, i64)
; here. `embed` needs none of these: it is a compile-time constant.
declare i8 @flan_file_size(ptr, i64, ptr)
declare i8 @flan_file_write(ptr, i64, ptr, i64)
; The three that change the filesystem. flan_file_stat is not here for the
; reason flan_file_read is not: nothing emitted calls it. It is reached from
; the prelude through a `declare`, because file-exists? and file-size answer a
; value rather than signalling and so need none of the guard machinery these
; three do.
declare i8 @flan_file_delete(ptr, i64)
declare i8 @flan_file_rename(ptr, i64, ptr, i64)
declare i8 @flan_file_mkdir(ptr, i64)
declare i64 @flan_file_fail_reason()
declare i8 @flan_slurp_into(ptr, ptr, i64)
|}

View File

@ -1691,6 +1691,9 @@ let source = {flan|
(defconst file-op-read i32 0)
(defconst file-op-write i32 1)
(defconst file-op-delete i32 2)
(defconst file-op-rename i32 3)
(defconst file-op-mkdir i32 4)
(defconst file-missing i32 1)
(defconst file-denied i32 2)
@ -1699,9 +1702,45 @@ let source = {flan|
;; desktop-only, and it signals rather than refusing at build time (Flan has no
;; conditional compilation, so isolating code to desktop is not expressible) or
;; silently doing nothing (which is how a save file disappears with nothing
;; said).
;; said). `delete-file`, `rename-file` and `make-directory` carry the same
;; decision: all three change the filesystem, so all three signal this on the
;; web rather than quietly succeeding into a filesystem the page throws away.
(defconst file-unsupported i32 4)
;; The two file questions that are not failures, and they are prelude
;; functions rather than builtins because of that: nothing here needs a
;; restart, so nothing here needs the compiler.
;;
;; That is the line the whole file surface is drawn on. `slurp`, `barf`,
;; `delete-file`, `rename-file` and `make-directory` can fail in ways a
;; handler can *answer* make the parent and retry, supply another path so
;; each signals FileError with those two restarts. "Is it there" and "how big
;; is it" have no such answer: absence is the reply, not a fault, and a
;; condition would make the ordinary case cost a handler search.
(declare file-stat-raw [path string out-size (Ptr i64)] i8 "flan_file_stat")
;; True for anything the path resolves to a file, a directory, a device
;; because that is what the question asks and a caller wanting "and it is a
;; regular file" is asking a second question this does not pretend to answer.
;;
;; **It is a reading and not a guarantee.** Between this answering true and the
;; next line opening the file, anything may have removed it; the race is
;; unavoidable and is the reason `slurp` signals rather than requiring this
;; first. Reach for it when the answer is the point choosing a config path,
;; deciding whether to write a default and not as a guard in front of an
;; operation that already reports its own failure properly.
(defn file-exists? [path string] bool
(let [n (i64 0)]
(= (file-stat-raw path (addr n)) 1)))
;; None for a path that does not resolve, which folds every reason into one
;; answer that is the trade a caller makes by asking a question with no
;; restart on it. A caller that needs to tell "missing" from "denied" wants
;; `slurp`, whose FileError carries the reason.
(defn file-size [path string] (Option i64)
(let [n (i64 0)]
(if (= (file-stat-raw path (addr n)) 1) (Some n) None)))
;; Form: what a macro takes and what it answers
;;
;; The reader's output, mirrored on the Flan side, because a macro is a

View File

@ -2871,3 +2871,123 @@ const uint8_t *flan_getenv(const uint8_t *name, int64_t n, int64_t *len) {
*len = (int64_t)strlen(v);
return (const uint8_t *)v;
}
/* ── The rest of the file surface ──────────────────────────────────────
*
* Four more POSIX-shaped calls under the same rules as flan_file_size,
* flan_file_read and flan_file_write above: a path as ptr+len, 1 or 0, and the
* reason in flan_file_fail where the compiler's file_guard reads it. Nothing
* here holds a descriptor between calls, so a second target implements four
* functions and inherits the Flan that sits on them.
*
* The errno mapping is flan_errno_reason's and is not extended. Its three
* buckets missing, denied, io are what a *handler* can act on: retry after
* making the directory, use-value with another path, or give up. EEXIST and
* ENOTEMPTY land in io along with everything else, and that is the honest
* place for them until conditions have a hierarchy to hang a fourth reason
* off (see the FileError note in the prelude). */
#include <sys/stat.h>
#include <unistd.h>
/* One call behind both file-exists? and file-size, because they are one
* question: stat answers whether the path resolves and how big it is in the
* same breath, and two entry points would be two chances for them to disagree.
*
* stat and not the fopen-plus-ftell that flan_file_size uses. That one is
* shaped by slurp's needs it is about to read the file, so opening it is the
* test that matters and it is wrong as a general size: fopen on a directory
* succeeds on Linux and ftell then answers a number that is not a file size.
* The two coexist deliberately and answer different questions. */
int8_t flan_file_stat(const uint8_t *path, int64_t n, int64_t *size) {
char buf[FLAN_PATH_MAX];
struct stat st;
*size = 0;
if (!flan_path_cstr(path, n, buf)) {
flan_file_fail = FLAN_FILE_MISSING;
return 0;
}
errno = 0;
if (stat(buf, &st) != 0) { flan_file_fail = flan_errno_reason(); return 0; }
*size = (int64_t)st.st_size;
flan_file_fail = FLAN_FILE_OK;
return 1;
}
/* The three that change the filesystem, and they carry flan_file_write's
* decision 2 unchanged: on the web they signal, every time, with the path in
* the condition. Not a build-time refusal, because Flan has no conditional
* compilation and "isolate this to desktop" is therefore not expressible in
* source; and not a silent no-op, because that is how a save directory fails
* to appear with nothing said. */
int8_t flan_file_delete(const uint8_t *path, int64_t n) {
#if defined(__EMSCRIPTEN__)
(void)path; (void)n;
flan_file_fail = FLAN_FILE_UNSUPPORTED;
return 0;
#else
char buf[FLAN_PATH_MAX];
if (!flan_path_cstr(path, n, buf)) {
flan_file_fail = FLAN_FILE_MISSING;
return 0;
}
errno = 0;
/* remove(), so that an empty directory is deletable by the same call a file
* is it is unlink or rmdir depending on what the path names, which is the
* distinction a caller of a language with one `delete-file` does not want to
* have to make. A non-empty directory fails, and that is deliberate:
* recursive deletion is a loop the caller writes and sees. */
if (remove(buf) != 0) { flan_file_fail = flan_errno_reason(); return 0; }
flan_file_fail = FLAN_FILE_OK;
return 1;
#endif
}
/* Two paths, so two conversions, and the failure of either is reported as a
* missing path the same answer flan_path_cstr's refusal gets everywhere
* else. rename() is atomic within one filesystem and fails with EXDEV across
* two rather than copying, which lands in the io bucket; a caller that wants
* a move across devices writes slurp and barf, and sees that it did. */
int8_t flan_file_rename(const uint8_t *from, int64_t fn, const uint8_t *to,
int64_t tn) {
#if defined(__EMSCRIPTEN__)
(void)from; (void)fn; (void)to; (void)tn;
flan_file_fail = FLAN_FILE_UNSUPPORTED;
return 0;
#else
char a[FLAN_PATH_MAX], b[FLAN_PATH_MAX];
if (!flan_path_cstr(from, fn, a) || !flan_path_cstr(to, tn, b)) {
flan_file_fail = FLAN_FILE_MISSING;
return 0;
}
errno = 0;
if (rename(a, b) != 0) { flan_file_fail = flan_errno_reason(); return 0; }
flan_file_fail = FLAN_FILE_OK;
return 1;
#endif
}
/* 0777 and not 0755, because the process umask is what decides: a program that
* hardcodes 0755 has overridden a user's umask for no reason it could know.
* One level only an intervening directory that does not exist is ENOENT,
* which reaches the caller as `missing` and is answerable by a handler that
* makes the parent and takes `retry`, which is the restart that path exists
* for. */
int8_t flan_file_mkdir(const uint8_t *path, int64_t n) {
#if defined(__EMSCRIPTEN__)
(void)path; (void)n;
flan_file_fail = FLAN_FILE_UNSUPPORTED;
return 0;
#else
char buf[FLAN_PATH_MAX];
if (!flan_path_cstr(path, n, buf)) {
flan_file_fail = FLAN_FILE_MISSING;
return 0;
}
errno = 0;
if (mkdir(buf, 0777) != 0) { flan_file_fail = flan_errno_reason(); return 0; }
flan_file_fail = FLAN_FILE_OK;
return 1;
#endif
}

107
test/programs/files.flan Normal file
View File

@ -0,0 +1,107 @@
;;;; The file surface beyond slurp and barf: file-exists?, file-size,
;;;; delete-file, rename-file and make-directory.
;;;;
;;;; The split down the middle of that list is the whole design and this file
;;;; is arranged to show it. The two that ask a *question* — is it there, how
;;;; big is it — answer a value, because absence is a reply and not a fault;
;;;; they are prelude functions over one declare and the compiler knows
;;;; nothing about them. The three that *change* the filesystem answer () and
;;;; signal FileError with the two restarts slurp and barf already establish,
;;;; because each of their failures is one a handler can act on: make the
;;;; parent directory and retry, or supply another path.
;;;;
;;;; Everything is made and removed inside this program, so it leaves the
;;;; directory as it found it — checked at the end rather than assumed.
;; Handlers cannot see the locals of the function that established them, so the
;; observations are globals, as in slurp.flan.
(defvar seen i64)
(defvar last-reason i32)
(defvar last-op i32)
(defn main [] i32
;; ── The questions ─────────────────────────────────────────────────
(println (file-exists? "programs/assets/a.txt")) ; true
(println (file-exists? "programs/assets/nope")) ; false
;; A directory resolves, which is what the name asks and not "is a regular
;; file" — a caller wanting the narrower question is asking a second one.
(println (file-exists? "programs/assets")) ; true
(match (file-size "programs/assets/a.txt")
(Some n) (println n) ; 13
None (println "missing"))
;; None folds every reason into one answer, which is the trade a question
;; with no restart on it makes.
(match (file-size "programs/assets/nope")
(Some n) (println n)
None (println "none"))
;; ── make-directory, rename-file, delete-file ──────────────────────
(make-directory "files-tmp")
(println (file-exists? "files-tmp")) ; true
(barf "files-tmp/one.txt" (bytes "0123456789"))
(match (file-size "files-tmp/one.txt")
(Some n) (println n) ; 10
None (println "missing"))
(rename-file "files-tmp/one.txt" "files-tmp/two.txt")
(println (file-exists? "files-tmp/one.txt")) ; false
(println (file-exists? "files-tmp/two.txt")) ; true
(delete-file "files-tmp/two.txt")
(println (file-exists? "files-tmp/two.txt")) ; false
;; ── retry, after the handler made the parent ──────────────────────
;; The restart this family exists for. Writing into a directory that is not
;; there is ENOENT, which arrives as `missing`; the handler makes the
;; directory and takes `retry`, and the second attempt succeeds. Nothing in
;; the failing code knows any of that happened.
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-reason (.reason c))
(set last-op (.op c))
(make-directory "files-tmp/sub")
(invoke-restart 'retry))]
(barf "files-tmp/sub/deep.txt" (bytes "deep")))
(println seen) ; 1
(println (= last-reason file-missing)) ; true
(println (= last-op file-op-write)) ; true
(println (file-exists? "files-tmp/sub/deep.txt")) ; true
;; ── use-value, on a delete ────────────────────────────────────────
;; The same restart slurp's read offers, on an operation that writes: the
;; handler names a path that is there and the delete resumes against it.
(set seen 0)
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-op (.op c))
(invoke-restart 'use-value "files-tmp/sub/deep.txt"))]
(delete-file "files-tmp/sub/not-there.txt"))
(println seen) ; 1
(println (= last-op file-op-delete)) ; true
(println (file-exists? "files-tmp/sub/deep.txt")) ; false
;; ── A non-empty directory does not delete ─────────────────────────
;; remove() is unlink or rmdir depending on what the path names, so an empty
;; directory goes by the same call a file does — and a full one does not,
;; which is deliberate: a recursive delete is a loop the caller writes and
;; sees. Here the handler declines to answer, which is what an unhandled
;; condition would do, so it counts and lets the program carry on by
;; supplying the child path instead.
(set seen 0)
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-op (.op c))
(invoke-restart 'use-value "files-tmp/sub"))]
(delete-file "files-tmp"))
(println seen) ; 1
(println (= last-op file-op-delete)) ; true
;; And now it is empty, so it goes.
(delete-file "files-tmp")
(println (file-exists? "files-tmp")) ; false
0)

View File

@ -850,6 +850,37 @@ let () =
end;
(try Sys.remove exe with Sys_error _ -> ());
(* The rest of the file surface. What is being checked as much as the
calls is the line drawn through them: file-exists? and file-size answer
a value because absence is a reply and not a fault, and the three that
change the filesystem signal FileError with the same two restarts slurp
and barf establish. Both restarts are taken here on operations that
write - retry after the handler made the parent directory, and
use-value on a delete - which is what the pair is for and what a bool
return could not have offered.
The program makes and removes its own tree, so the cleanup below is for
a run that failed part way through and not for a passing one. *)
let clean_dir () =
List.iter (fun f -> try Sys.remove f with Sys_error _ -> ())
[ "files-tmp/sub/deep.txt"; "files-tmp/one.txt"; "files-tmp/two.txt" ];
List.iter (fun d -> try Unix.rmdir d with Unix.Unix_error _ -> ())
[ "files-tmp/sub"; "files-tmp" ]
in
let files_out =
"true\nfalse\ntrue\n13\nnone\ntrue\n10\nfalse\ntrue\nfalse\n\
1\ntrue\ntrue\ntrue\n1\ntrue\nfalse\n1\ntrue\nfalse\n"
in
clean_dir ();
outputs "the rest of the file surface" "programs/files.flan" files_out;
clean_dir ();
outputs ~opt:"-O0" "the rest of the file surface, -O0" "programs/files.flan"
files_out;
clean_dir ();
outputs ~dev:true "the rest of the file surface, dev" "programs/files.flan"
files_out;
clean_dir ();
(* The epoch trap: a container whose allocator has been released. This is
spec-memory.md's shipping answer to "Open: catching a use-after-release
statically" — detection, loud and immediate, rather than a static rule

View File

@ -1065,6 +1065,7 @@ over.</p>
<tr><td>UTF-8</td><td><code>decode-rune</code>, <code>rune-at</code>, <code>rune-count</code>, <code>rune-size</code>, <code>rune-start?</code>, <code>valid-utf8?</code>, <code>encode-rune!</code></td></tr>
<tr><td>numbers</td><td><code>sign-f32</code>, <code>lerp</code>, <code>clamp</code>, <code>floor-f32</code>, <code>ceil-f32</code>, <code>round-f32</code>, <code>abs-i32</code>, <code>abs-i64</code>, the constants <code>pi-f32</code>, <code>pi-f64</code>, <code>tau-f32</code>, <code>tau-f64</code>, and libm through a <code>declare</code> at both widths: <code>sqrt</code>, <code>abs</code>, <code>floor</code>, <code>ceil</code>, <code>round</code>, <code>fmod</code>, <code>sin</code>, <code>cos</code>, <code>tan</code>, <code>asin</code>, <code>acos</code>, <code>atan</code>, <code>atan2</code>, <code>log</code>, <code>log2</code>, <code>log10</code>, <code>exp</code>, <code>pow</code>, <code>hypot</code>, <code>cbrt</code> — each spelled <code>-f32</code> or <code>-f64</code></td></tr>
<tr><td>time</td><td><code>monotonic-ns</code>, <code>monotonic-seconds</code>, <code>unix-ns</code>, <code>unix-seconds</code>, <code>sleep-ns</code>, <code>sleep-seconds</code>, and <code>ns-per-second</code> and its two smaller siblings</td></tr>
<tr><td>files</td><td><code>file-exists?</code> and <code>file-size</code>, which answer a value; <code>slurp</code>, <code>barf</code>, <code>delete-file</code>, <code>rename-file</code> and <code>make-directory</code>, which signal <code>FileError</code> under <code>retry</code> and <code>use-value</code></td></tr>
<tr><td>the operating system</td><td><code>getenv</code>, which answers an <code>(Option [u8])</code> viewing the process environment</td></tr>
<tr><td>random</td><td><code>rand-seed</code>, <code>rand-u32</code>, <code>rand-f32</code>, <code>rand-i32-range</code>, <code>rand-f32-range</code></td></tr>
<tr><td>forms, for macros</td><td><code>form-nil</code>, <code>form-cons</code>, <code>form-append</code>, <code>form-rest</code>, <code>form-items</code>, <code>form-pair</code>, <code>form-sym?</code>, <code>form-is-sym?</code>, <code>gensym</code>, and <code>unless</code> and <code>into</code>, which are macros written here rather than special forms</td></tr>
@ -1117,6 +1118,17 @@ shape raylib's <code>get-time</code> already answers with, so the two mix; it st
integer-exact in nanoseconds for a hundred days of process life, which is why the
monotonic origin is the first read and not boot.</p>
<p><strong>The file surface is split by whether a handler could do anything.</strong>
<code>file-exists?</code> and <code>file-size</code> answer a <code>bool</code> and an
<code>(Option i64)</code>: absence is the reply, not a fault, and a condition would make
the ordinary case pay for a handler search. <code>slurp</code>, <code>barf</code>,
<code>delete-file</code>, <code>rename-file</code> and <code>make-directory</code> signal
<code>FileError</code> instead, under the two restarts Common Lisp establishes for a
file error — <code>retry</code>, because the handler may have just made the directory,
and <code>use-value</code> with another path. Nothing here returns an error code, which
is the same rule allocation follows. Streaming, stdin and directory listings are not
here; a whole file at a time is the surface.</p>
<p>The primitives underneath are few — a primitive is the only thing implemented
twice per backend: <code>argv</code>,
<code>write-stdout</code>, <code>exit</code>, <code>len</code>, <code>at</code>,