From aeeb6de59dd8a608f5f9fe259d1bb429a93a6601 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:44:39 +0700 Subject: [PATCH 1/5] The maths is the whole family now, at both widths The prelude's declare surface was five f32 functions, and the five were there because somebody needed each one. Everything else a caller wanted was written as a declare at the top of their own file -- the identical libm call with none of the caveats written down. So the rest of libm is here: tan, the three inverses, the three logarithms, exp, fmod, hypot, cbrt, fabs, and an f64 face for every one of them including the five that already existed. A declare is a line, a symbol already on the link, and nothing in either backend, which is why this was cheap enough to do completely rather than one function at a time. The f64 half is not decoration. f32 is what a position is; f64 is what a measurement is -- the clock, parse-f64, format-f64, any sum over more than a few thousand terms -- and having only the f32 face forced a cast down and back at each of those boundaries, which is where the precision went. The paragraph the sqrt note draws for itself is now drawn once for the family: IEEE-754 specifies sqrt, fabs, floor, ceil, round and fmod as exact or correctly rounded, so those agree bit for bit across glibc, musl and wasi-libc; it requires nothing of the rest, so the sand-grid rule covers all of them unchanged. floor, ceil and round are Flan at f32 and libm at f64, and that is not an inconsistency: the f32 bodies work because every f32 with a fraction fits in an i32, and at f64 that trick is gone. abs-i32 and abs-i64 are Flan, one per width because min and max are builtins and no generic covers the numeric types. pi and tau at both widths, written out rather than derived so the compiler rounds each literal once. programs/math3.flan covers it at values that are exact in binary, so nothing pins one libm's last bit. The -O0 case is the one that matters: at -O2 LLVM folds a call over two literals and leaves no symbol to resolve, which is how a missing -lm hid the first time. --- lib/prelude.ml | 147 +++++++++++++++++++++++++++++++++++++++ test/programs/math3.flan | 92 ++++++++++++++++++++++++ test/test_acceptance.ml | 17 +++++ web/index.html | 34 +++++---- 4 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 test/programs/math3.flan diff --git a/lib/prelude.ml b/lib/prelude.ml index 3ed65e6..353e69f 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -676,6 +676,153 @@ let source = {flan| (declare atan2-f32 [y f32 x f32] f32 "atan2f") (declare pow-f32 [x f32 y f32] f32 "powf") +;; ── The rest of libm, and both widths ───────────────────────────────── +;; +;; The five above were the whole of it for a long time, and the reason they +;; were is the reason the rest are here now: every one of these is a line, a +;; symbol that is already on the link, and nothing in the compiler. A program +;; that wanted a logarithm wrote the `declare` at the top of its own file — +;; which is the identical call with none of the caveats written down and +;; nobody's name on it. +;; +;; **The f64 half is not decoration.** f32 is what a position and a colour +;; are, and f64 is what a *measurement* is: the clock below is nanoseconds in +;; an i64 and seconds in an f64, parse-f64 and format-f64 are both f64, and a +;; sum over more than a few thousand f32 terms has already lost the low bits +;; the answer was about. Having only the f32 face forced a cast down and back +;; at every one of those boundaries, and a cast down is where the precision +;; went. +;; +;; The split below is the one the sqrt paragraph draws, applied to the whole +;; family, and it is the only thing here worth knowing before calling: +;; +;; **Exact on every target.** IEEE-754 specifies these as exact operations +;; or as correctly rounded, so the answer is the same bit pattern under +;; glibc, musl and wasi-libc, and a hash taken across targets may be routed +;; through them. sqrt, fabs, floor, ceil, round, fmod. +;; +;; **Not.** IEEE-754 requires nothing of these and the three libms do +;; differ in the last bit. The sand-grid rule from the sin/cos paragraph +;; above covers all of them without change: a value compared across targets +;; must not have been through one. Everything else here. + +(declare sqrt-f64 [x f64] f64 "sqrt") + +;; Magnitude, and the f32/f64 pair is libm's because fabs is a sign-bit clear +;; that the compiler folds into one instruction — cheaper than the branch a +;; Flan body would be, and right for -0.0 and for NaN, which a (< x 0.0) test +;; is not: -0.0 is not less than zero, so the branch returns it unchanged and +;; hands back a negative zero from a function named abs. +(declare abs-f32 [x f32] f32 "fabsf") +(declare abs-f64 [x f64] f64 "fabs") + +;; The f64 faces of the three rounding functions floor-f32, ceil-f32 and +;; round-f32 are libm's rather than Flan's, and that is not an inconsistency. +;; Those three are Flan because of a cast: (i32 x) is the whole of floor-f32's +;; body, and it works precisely because every f32 with a fractional part fits +;; in an i32. At f64 it does not — the exact range runs to 2^53 and i64's cast +;; would have to carry its own guard — so the trick that made them free is not +;; available and the libm call is both shorter and exact. +(declare floor-f64 [x f64] f64 "floor") +(declare ceil-f64 [x f64] f64 "ceil") +(declare round-f64 [x f64] f64 "round") + +;; Remainder, and it is C's fmod and not a modulo: the sign follows the +;; *dividend*, so (fmod-f32 -1.0 3.0) is -1.0 and not 2.0. An angle wrapped +;; into [0, tau) therefore needs the add-and-fmod-again that every wrap +;; function has, and this is the line where that is written down rather than +;; discovered. It is exact — the result is the true remainder, representable +;; by construction — so it belongs to the first group above. +(declare fmod-f32 [x f32 y f32] f32 "fmodf") +(declare fmod-f64 [x f64 y f64] f64 "fmod") + +;; The trigonometric family, in full and at both widths. tan is separate from +;; (/ (sin-f32 x) (cos-f32 x)) for the reason atan2 is separate from a +;; division: near pi/2 the quotient is a ratio of two small errors and tanf +;; is not. +(declare tan-f32 [x f32] f32 "tanf") +(declare sin-f64 [x f64] f64 "sin") +(declare cos-f64 [x f64] f64 "cos") +(declare tan-f64 [x f64] f64 "tan") + +;; The inverses. asin and acos answer NaN outside [-1, 1] rather than +;; clamping, which is what catches a dot product that drifted to 1.0000001 +;; through rounding — clamp it at the call site, on purpose, and the drift is +;; visible instead of silently becoming an angle of zero. +(declare asin-f32 [x f32] f32 "asinf") +(declare acos-f32 [x f32] f32 "acosf") +(declare atan-f32 [x f32] f32 "atanf") +(declare asin-f64 [x f64] f64 "asin") +(declare acos-f64 [x f64] f64 "acos") +(declare atan-f64 [x f64] f64 "atan") +(declare atan2-f64 [y f64 x f64] f64 "atan2") + +;; Logarithms and the exponential. log is the natural one, as in C and unlike +;; the spreadsheet convention — log2 and log10 are the other two and are named +;; for their bases, so nothing here is ambiguous. log2 is not (/ (log x) +;; (log 2.0)): it is exact at every power of two, which is the whole reason a +;; bit-width or an octave is computed with it. +;; +;; All four answer -inf at zero and NaN below it rather than signalling. A +;; condition per logarithm would cost a handler search on a path whose callers +;; are loops over samples, and NaN is the value that propagates to wherever +;; the caller does check. +(declare log-f32 [x f32] f32 "logf") +(declare log2-f32 [x f32] f32 "log2f") +(declare log10-f32 [x f32] f32 "log10f") +(declare exp-f32 [x f32] f32 "expf") +(declare log-f64 [x f64] f64 "log") +(declare log2-f64 [x f64] f64 "log2") +(declare log10-f64 [x f64] f64 "log10") +(declare exp-f64 [x f64] f64 "exp") +(declare pow-f64 [x f64 y f64] f64 "pow") + +;; hypot over (sqrt-f32 (+ (* x x) (* y y))) because the obvious form +;; overflows on inputs the answer does not: the square of an f32 above ~1.8e19 +;; is infinity, so a distance between two far-apart points comes back inf when +;; the distance itself is perfectly representable. hypotf scales first. It +;; costs more than the naive form and is worth it exactly when the naive form +;; is wrong. +(declare hypot-f32 [x f32 y f32] f32 "hypotf") +(declare hypot-f64 [x f64 y f64] f64 "hypot") + +;; Cube root, and it is here because (pow-f32 x 0.33333334) is not it: pow +;; goes through a logarithm, which is undefined for a negative base, so the +;; obvious spelling answers NaN for every negative number where cbrt answers +;; the negative root. +(declare cbrt-f32 [x f32] f32 "cbrtf") +(declare cbrt-f64 [x f64] f64 "cbrt") + +;; Integer magnitude, one per width because there are no generics over the +;; numeric types and min and max are builtins rather than functions, so a +;; single abs is not expressible today. +;; +;; The most negative value of each width has no positive counterpart, and this +;; does not special-case it: the subtraction is the same subtraction written +;; anywhere else and meets whatever the build's overflow rule is. Saturating +;; to the maximum would be a wrong answer returned quietly, which is the one +;; thing this file does not do. +(defn abs-i32 [x i32] i32 + (if (< x 0) (- 0 x) x)) + +(defn abs-i64 [x i64] i64 + (if (< x 0) (- 0 x) x)) + +;; pi and tau at both widths, because a defconst has a type and a cast between +;; them is where digits go missing. tau is 2pi and is written out rather than +;; multiplied, so the f32 one is the nearest f32 to tau and not twice the +;; nearest f32 to pi — which is the same number here and is not guaranteed to +;; be for the derived form in general. +;; +;; Both are given to more digits than either width holds. That is deliberate: +;; the literal is rounded once, by the compiler, to the nearest value of the +;; declared type, which is the best available answer and is the same answer on +;; both targets. +(defconst pi-f32 f32 3.14159265358979323846) +(defconst pi-f64 f64 3.14159265358979323846) +(defconst tau-f32 f32 6.28318530717958647692) +(defconst tau-f64 f64 6.28318530717958647692) + ;; ── Byte classes ────────────────────────────────────────────────────── ;; ;; ASCII only, and deliberately: a byte is a byte here, there is no code point diff --git a/test/programs/math3.flan b/test/programs/math3.flan new file mode 100644 index 0000000..7f8a381 --- /dev/null +++ b/test/programs/math3.flan @@ -0,0 +1,92 @@ +;;;; The rest of libm, at both widths — what math.flan and math2.flan left out. +;;;; +;;;; The rule those two set holds here unchanged and is the only reason this +;;;; file looks the way it does: none of these is correctly rounded under +;;;; IEEE-754 except sqrt, fabs, the rounding three and fmod, so every value +;;;; below is one whose answer is exact in binary — zero, one, a power of two, +;;;; a perfect square, a perfect cube. A case that pinned glibc's last bit +;;;; would pass here and fail on wasi-libc. +;;;; +;;;; The acceptance table builds this at -O0 as well, and that run is the one +;;;; that matters: at -O2 LLVM folds a libm call over two literals and leaves +;;;; no symbol to resolve, which is how a missing -lm hid the first time. + +(defn show [x f32] () + (print x) + (print " ")) + +(defn show64 [x f64] () + (print x) + (print " ")) + +(defn main [] i32 + ;; The f32 half. tan, the three inverses, the three logarithms and exp. + (show (tan-f32 0.0)) ; 0 + (show (asin-f32 0.0)) ; 0 + (show (acos-f32 1.0)) ; 0 + (show (atan-f32 0.0)) ; 0 + (show (log-f32 1.0)) ; 0 + (show (log2-f32 8.0)) ; 3 + (show (log10-f32 1000.0)) ; 3 + (show (exp-f32 0.0)) ; 1 + (println "") + + (show (fmod-f32 7.0 4.0)) ; 3 + ;; The sign follows the dividend and not the divisor, which is the line a + ;; caller reaching for a modulo gets wrong. Written down as a case. + (show (fmod-f32 -1.0 3.0)) ; -1 + (show (hypot-f32 3.0 4.0)) ; 5 + (show (cbrt-f32 27.0)) ; 3 + ;; The negative root, where (pow-f32 x 0.33333334) would be NaN: pow goes + ;; through a logarithm and cbrt does not. + (show (cbrt-f32 -8.0)) ; -2 + (show (abs-f32 -2.5)) ; 2.5 + (println "") + + ;; The f64 half, at the same exact values. This block is the whole point of + ;; the f64 face existing: before it, every one of these was a cast down to + ;; f32 and back, and the cast down is where the precision went. + (show64 (sqrt-f64 16.0)) ; 4 + (show64 (sin-f64 0.0)) ; 0 + (show64 (cos-f64 0.0)) ; 1 + (show64 (tan-f64 0.0)) ; 0 + (show64 (asin-f64 0.0)) ; 0 + (show64 (acos-f64 1.0)) ; 0 + (show64 (atan-f64 0.0)) ; 0 + (show64 (atan2-f64 0.0 1.0)) ; 0 + (println "") + + (show64 (log-f64 1.0)) ; 0 + (show64 (log2-f64 1024.0)) ; 10 + (show64 (log10-f64 100.0)) ; 2 + (show64 (exp-f64 0.0)) ; 1 + (show64 (pow-f64 2.0 10.0)) ; 1024 + (show64 (fmod-f64 7.0 4.0)) ; 3 + (show64 (hypot-f64 3.0 4.0)) ; 5 + (show64 (cbrt-f64 8.0)) ; 2 + (show64 (abs-f64 -1.5)) ; 1.5 + (println "") + + ;; The f64 rounding family, which is libm's where the f32 one is Flan's — + ;; and it agrees with the Flan one where they overlap: half away from zero, + ;; so -2.5 goes to -3 and not to -2. + (show64 (floor-f64 -2.5)) ; -3 + (show64 (ceil-f64 -2.5)) ; -2 + (show64 (round-f64 -2.5)) ; -3 + (show64 (round-f64 2.5)) ; 3 + (println "") + + ;; Integer magnitude, one per width. + (print (abs-i32 -7)) (print " ") ; 7 + (print (abs-i64 (i64 -7))) (print " ") ; 7 + (print (abs-i32 7)) (print " ") ; 7 + ;; tau is 2pi at both widths. Pinning the relation rather than the digits is + ;; what catches a constant written to too few of them. + (print (= tau-f32 (* 2.0 pi-f32))) (print " ") + (print (= tau-f64 (* 2.0 pi-f64))) + (println "") + + ;; pi is the one value here that can be pinned without pinning a libm: it is + ;; a literal the compiler rounds, so it is the same on every target. + (println (and (> pi-f64 3.14159265) (< pi-f64 3.14159266))) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index a99ab31..4457ba1 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -268,6 +268,23 @@ let () = outputs "atan2, pow and clamp" "programs/math2.flan" math2_out; outputs ~opt:"-O0" "atan2, pow and clamp, -O0" "programs/math2.flan" math2_out; + (* The rest of libm, at both widths. Same rule as math2 above and for the + same reason — every value is exact in binary — and the -O0 pass is + doing the same job: at -O2 LLVM folds a libm call over two literals and + leaves no symbol to resolve, so that run is the one proving all thirty + new declares actually link. *) + let math3_out = + "0 0 0 0 0 3 3 1 \n\ + 3 -1 5 3 -2 2.5 \n\ + 4 0 1 0 0 0 0 0 \n\ + 0 10 2 1 1024 3 5 2 1.5 \n\ + -3 -2 -3 3 \n\ + 7 7 7 true true\n\ + true\n" + in + outputs "the rest of libm, both widths" "programs/math3.flan" math3_out; + outputs ~opt:"-O0" "the rest of libm, both widths, -O0" + "programs/math3.flan" math3_out; (* index-of-bytes, trim, the byte classes and parse-f64. The search cases are the ones that separate a correct loop from a lucky one: a match only at the end, "aab" in "aaab" (where the first byte matches twice diff --git a/web/index.html b/web/index.html index a4ab6ec..5536c84 100644 --- a/web/index.html +++ b/web/index.html @@ -1047,7 +1047,7 @@ takes the value as it is and prints the number it holds.

The prelude

-

The prelude is written in Flan, all but five lines of it, and prepended to every +

The prelude is written in Flan, all but its declare lines, and prepended to every program, so nothing in it needs importing. It holds no printing of its own: print and println are the compiler's, and write-stdout — the one output primitive — is what they are written @@ -1063,7 +1063,7 @@ over.

textsplit-on-byte, split-next!, split, lower-ascii, upper-ascii, to-lower, to-upper building bytesappend!, append-i64!, append-f64!, concat, join, repeat-bytes, replace-bytes, slices-new, format-f64 UTF-8decode-rune, rune-at, rune-count, rune-size, rune-start?, valid-utf8?, encode-rune! -numberssign-f32, lerp, clamp, floor-f32, ceil-f32, round-f32, and the five declares: sqrt-f32, sin-f32, cos-f32, atan2-f32, pow-f32 +numberssign-f32, lerp, clamp, floor-f32, ceil-f32, round-f32, abs-i32, abs-i64, the constants pi-f32, pi-f64, tau-f32, tau-f64, and libm through a declare at both widths: sqrt, abs, floor, ceil, round, fmod, sin, cos, tan, asin, acos, atan, atan2, log, log2, log10, exp, pow, hypot, cbrt — each spelled -f32 or -f64 randomrand-seed, rand-u32, rand-f32, rand-i32-range, rand-f32-range forms, for macrosform-nil, form-cons, form-append, form-rest, form-items, form-pair, form-sym?, form-is-sym?, gensym, and unless and into, which are macros written here rather than special forms the restpause, which signals the Pause condition the break loop stops on, and embed-find @@ -1084,18 +1084,24 @@ native and on wasm32. The parsers are ours too: "abc" and 12 for "12x", which are three wrong answers a caller cannot tell from a real 12.

-

Five functions in the file are not Flan, and they are libm's: -(declare sqrt-f32 [x f32] f32 "sqrtf") and the same line for -sinf, cosf, atan2f and powf. Every -other number here is reachable from the four operations and a cast; a square root is -not, and the usual trick of seeding Newton's method from the exponent bits needs a -bit-cast between f32 and u32 that the language does not have. -IEEE-754 makes sqrt correctly rounded, so libm gives the same bit pattern -on both targets anyway. The other four are not: IEEE-754 requires -nothing of sinf, cosf, atan2f or -powf, and glibc, musl and wasi-libc do differ in the last bit — so the -byte-identical-hash property the RNG exists for does not survive a hash routed through -any of them. Every link carries -lm.

+

The maths in the file is not Flan, and it is libm's: +(declare sqrt-f32 [x f32] f32 "sqrtf") and the same line for thirty-odd +more. Every other number here is reachable from the four operations and a cast; a square +root is not, and the usual trick of seeding Newton's method from the exponent bits needs +a bit-cast between f32 and u32 that the language does not have. +A declare is also the cheapest thing in the language to add — a line, a +symbol already on the link, and nothing in either backend — which is why the surface is +now the whole family at both widths rather than the five it started as.

+ +

One split is worth knowing before calling any of them. IEEE-754 +specifies sqrt, fabs, floor, ceil, +round and fmod as exact or correctly rounded, so those give the +same bit pattern under glibc, musl and wasi-libc. It requires nothing of the +restsin, cos, tan, the inverses, the +logarithms, exp, pow, hypot, cbrt — +and the three libms do differ in the last bit, so the byte-identical-hash property the +RNG exists for does not survive a value routed through any of them. Every link carries +-lm.

The primitives underneath are few — a primitive is the only thing implemented twice per backend: argv, From 2dd13b5ae0ebbb0237d49ed71f9a65c8325527a2 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:45:10 +0700 Subject: [PATCH 2/5] The language can tell the time, and read its environment Nothing in it could. A game got a clock from raylib and a program without a window had none at all, so "how long did that take" was unanswerable in the half of daily use that is a tool rather than a game. Two clocks, because the mistake a single one invites is using it for the other job. monotonic-ns measures: it never goes backwards, nothing adjusts it, and its zero is arbitrary, so it is meaningless alone and correct as a difference. unix-ns dates: nanoseconds since 1970, which is what goes in a save file, and which jumps in either direction when somebody sets the system clock. The names are picked so that reaching for the wrong one reads wrong. This is Odin's shape, from core/time/time.odin and core/time/time_linux.odin: Tick against Time, both an i64 of nanoseconds, over MONOTONIC and REALTIME, with the seconds-valued face derived rather than a second syscall. Three C functions here and six Flan names over them, which is the rule flan_rt.c's own header states -- a primitive is the only thing implemented twice. The monotonic origin is the first read of the clock in the process, not boot, and that is the one decision worth arguing. CLOCK_MONOTONIC counts from boot, so on a machine up a hundred days the raw value is past 2^53 nanoseconds and monotonic-seconds would lose sub-microsecond resolution depending on the machine's uptime rather than on anything the program did. Latched to first read it stays integer-exact for a hundred days of process life, and it also matches what a game already has: raylib's GetTime is seconds since InitWindow, so the two numbers now mix without a conversion at every site. sleep-ns loops on EINTR, because otherwise a signal cuts the wait short and a frame loop wobbles for reasons nothing in the program explains. It is documented as at-least and not as a frame limiter; the shape that actually paces a loop is a deadline recomputed from monotonic-ns each turn, and the comment says so where somebody will read it. getenv answers an (Option [u8]) viewing the process environment, which needs no allocator and no free and is safe precisely because nothing in this language can call setenv or spawn a process. The absent case rides in the length rather than in the pointer: there is no null test to write, since a (Ptr T) here always addresses something, so flan_getenv answers -1 and a pointer at a valid empty string and the Flan side tests arithmetic. The runtime additions are a single block at the end of flan_rt.c, with inside it for the reason sits beside the file section. programs/time.flan asserts invariants and never a reading -- t2 >= t1, a sleep that did not return early, a date after 2020 and before 2100 -- because the same file is in the corpus @x86 builds twice and diffs, so a timestamp would fail a correct compiler on its second run. --- lib/prelude.ml | 105 ++++++++++++++++++++++++++++++++++ runtime/flan_rt.c | 122 ++++++++++++++++++++++++++++++++++++++++ test/programs/time.flan | 71 +++++++++++++++++++++++ test/test_acceptance.ml | 9 +++ web/index.html | 14 +++++ 5 files changed, 321 insertions(+) create mode 100644 test/programs/time.flan diff --git a/lib/prelude.ml b/lib/prelude.ml index 353e69f..ff16762 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -823,6 +823,111 @@ let source = {flan| (defconst tau-f32 f32 6.28318530717958647692) (defconst tau-f64 f64 6.28318530717958647692) +;; ── The clock ───────────────────────────────────────────────────────── +;; +;; Until this section nothing in the language could tell the time. A game got +;; one from raylib and a program that was not a game had none at all, which +;; made "how long did that take" unanswerable in a tool — the half of daily +;; use that has no window. +;; +;; **Two clocks, and they are not interchangeable.** This is the whole of what +;; a caller has to know, and the names are chosen so that picking the wrong +;; one reads wrong: +;; +;; `monotonic-…` measures. It never goes backwards, it is not moved by NTP +;; or by a user setting the clock, and its zero is arbitrary — the first +;; time the program reads it. It is meaningless on its own and correct as a +;; difference. +;; +;; `unix-…` dates. Seconds (or nanoseconds) since 1970-01-01 UTC, which is +;; what goes in a file, a log line or a save. It *can* jump, forwards or +;; backwards, so a duration computed from two readings of it can be +;; negative, and timing anything with it is the bug this pair exists to make +;; hard to write. +;; +;; Odin draws exactly this line and this is its shape: core/time/time.odin has +;; `Time` for the date and `Tick` for the measurement, both an i64 of +;; nanoseconds, and core/time/time_linux.odin implements them as REALTIME and +;; MONOTONIC. The nanosecond integer is the primitive there and the f64 of +;; seconds is derived, which is why it is derived here too — three C functions, +;; six names. +;; +;; **Which face to use.** The i64 of nanoseconds is exact and is what a +;; difference should be taken in. The f64 of seconds is what a frame loop +;; wants, and it is the shape raylib's `get-time` already answers with +;; (vendor/raylib/raylib.flan, `(declare-c get-time [] f64 "GetTime")`), so the +;; two mix without a conversion at every site. The monotonic origin is latched +;; at the first read rather than being boot — see runtime/flan_rt.c — so that +;; the f64 stays integer-exact in nanoseconds for a hundred days of process +;; life, which a boot-relative clock on a long-lived machine does not. + +(declare monotonic-ns [] i64 "flan_monotonic_ns") +(declare unix-ns [] i64 "flan_unix_ns") + +;; Nanoseconds, so the caller writes the unit rather than counting zeroes, and +;; so that a duration in the language is one type rather than a per-unit +;; family. Odin spells the same idea as `Duration` constants in core/time. +(defconst ns-per-microsecond i64 1000) +(defconst ns-per-millisecond i64 1000000) +(defconst ns-per-second i64 1000000000) + +(defn monotonic-seconds [] f64 + (/ (f64 (monotonic-ns)) 1000000000.0)) + +(defn unix-seconds [] f64 + (/ (f64 (unix-ns)) 1000000000.0)) + +;; **Not a frame limiter.** A sleep asks the operating system to stop this +;; thread for *at least* the time given and says nothing about the upper +;; bound: a default Linux kernel wakes a sleeper on the timer tick after the +;; deadline, so a request for one millisecond commonly returns after rather +;; more, and the error is on the late side every time. A frame loop that +;; sleeps a fixed slice per frame therefore runs slow and drifts; the shape +;; that works is to sleep until a deadline computed from `monotonic-ns` and to +;; recompute it from the same clock each turn, so that a long frame is +;; absorbed instead of accumulated. +;; +;; A zero or negative request returns immediately rather than being refused, +;; which is what a deadline that has already passed produces and is not an +;; error — see flan_sleep_ns for why, and for the EINTR loop that keeps a +;; signal from cutting the wait short. +(declare sleep-ns [ns i64] () "flan_sleep_ns") + +(defn sleep-seconds [s f64] () + (sleep-ns (i64 (* s 1000000000.0)))) + +;; ── The environment ─────────────────────────────────────────────────── +;; +;; One lookup, and `argv` and `exit` are the rest of the OS surface. Setting a +;; variable is not here and is not an omission: `setenv` mutates a table the +;; slice below views, and nothing in the language can spawn the process that +;; would be the only reason to set one. +;; +;; **The result borrows.** It is a view of the process environment, not a copy: +;; it needs no allocator and no free, and it stays valid because there is no +;; writer — that is the same promise `slice-from-ptr` asks a caller to make, +;; kept here once so that no caller has to. A program that wants to hold the +;; value past a point where that reasoning stops being obvious should copy it +;; into a Vec, which `concat` of one part already does. +;; +;; `None` and an empty `Some` are different answers and both occur: an unset +;; variable is None, and `FOO=` set to nothing is `(Some [])`. A caller that +;; wants to treat them alike says so. +;; +;; The absent case rides in the length and not in the pointer, because there is +;; no null test to write here — a (Ptr T) in this language always addresses +;; something. flan_getenv answers a length of -1 and a pointer at a valid empty +;; string, so the test below is arithmetic and the pointer is never dereferenced +;; on the absent path. +(declare getenv-raw [name string out-len (Ptr i64)] (Ptr u8) "flan_getenv") + +(defn getenv [name string] (Option [u8]) + (let [n (i64 0) + p (getenv-raw name (addr n))] + (if (< n 0) + None + (Some (slice-from-ptr p (i32 n)))))) + ;; ── Byte classes ────────────────────────────────────────────────────── ;; ;; ASCII only, and deliberately: a byte is a byte here, there is no code point diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 2ca6ff4..33c0424 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -2749,3 +2749,125 @@ int8_t flan_slurp_into(flan_vec *v, const uint8_t *path, int64_t n) { v->len = got; return 1; } + +/* ── Time, and the environment ───────────────────────────────────────── + * + * Three clock primitives and one environment lookup, added as a block at the + * end so that the two halves of this file — the milestone-2 ABI above and the + * host services below — stay separable. is included here rather than + * at the top for the reason is: an include beside the only section + * that needs it says which section that is. + * + * The rule the whole file is written to applies hardest here: a primitive is + * the only thing implemented twice, so the seconds-valued faces of all three + * (monotonic-seconds, unix-seconds, sleep-seconds) are Flan in the prelude, + * over these. Odin draws the same line — core/time/time_linux.odin is exactly + * _now, _tick_now, _sleep and _yield over clock_gettime and nanosleep, and + * duration_seconds is derived arithmetic in core/time/time.odin. */ + +#include + +/* The two clocks are kept apart on purpose, because the mistake they invite is + * using one for the other's job. + * + * MONOTONIC never goes backwards and is not adjusted by NTP or by the user + * setting the clock, which is what makes it the one to *measure* with: a frame + * time taken across a daylight-saving change is still a frame time. It has no + * meaning as a date — its zero is arbitrary — so it can only ever be + * subtracted from another reading of itself. + * + * REALTIME is the date, and it is the one that jumps: it can move backwards, + * and a duration computed from two readings of it can be negative. It is here + * to answer "when", not "how long". */ + +/* The origin is the first read of this clock in the process, not boot, and + * that is a decision rather than an accident. + * + * The reason is the f64 face above it. CLOCK_MONOTONIC counts from boot, so on + * a machine up a hundred days the raw value is past 2^53 nanoseconds — beyond + * where an f64 holds consecutive integers — and (monotonic-seconds) would + * quietly lose sub-microsecond resolution depending on how long the *machine* + * had been running, which is the worst kind of bug to be handed. Latched to + * first read, the f64 stays integer-exact for a hundred days of *process* + * life, and nothing this language builds runs that long without a restart. + * + * It also matches what a game already expects: raylib's GetTime is seconds + * since InitWindow, not seconds since boot, and the two now mix without a + * caller having to notice one of them is a much larger number. + * + * A plain static and no atomics, because the language has no threads. If it + * ever gets them, the worst a race here can do is latch two origins a few + * nanoseconds apart, which costs a reading that is early by that much and + * cannot make the clock run backwards. */ +static int64_t flan_mono_origin; +static int flan_mono_armed; + +static int64_t flan_clock_ns(clockid_t which) { + struct timespec ts; + /* A failure here is not reachable with a constant clock id the platform + * has, and there is no channel to report it on that a caller could act on: + * the answer to "what time is it" cannot be a condition without every + * reading of it costing a handler search. A zeroed timespec is what a + * failure reads as, and for MONOTONIC that is the origin. */ + if (clock_gettime(which, &ts) != 0) { ts.tv_sec = 0; ts.tv_nsec = 0; } + return (int64_t)ts.tv_sec * 1000000000 + (int64_t)ts.tv_nsec; +} + +int64_t flan_monotonic_ns(void) { + int64_t now = flan_clock_ns(CLOCK_MONOTONIC); + if (!flan_mono_armed) { flan_mono_armed = 1; flan_mono_origin = now; } + return now - flan_mono_origin; +} + +int64_t flan_unix_ns(void) { return flan_clock_ns(CLOCK_REALTIME); } + +/* A negative or zero request returns at once rather than being refused: the + * caller that computed "sleep until the frame's deadline" and arrived late + * wants to carry on, not to be told it is late, and that is by far the most + * common way this is called. + * + * The EINTR loop is the reason this is C and not two Flan lines over a raw + * nanosleep: a signal — a profiler's timer, the dev loop's own — otherwise + * cuts the wait short and the caller's frame pacing wobbles for reasons + * nothing in the program explains. The remaining time comes back in the same + * timespec, so resuming is a second call with no arithmetic. Odin's _sleep in + * core/time/time_linux.odin loops on EINTR for the same reason. + * + * On emscripten this is still nanosleep, which there spins rather than yields: + * the sleep is the length asked for, and it burns a core and blocks the frame + * doing it. Correct, and not what a browser build should be reaching for — a + * web frame loop waits by returning to the browser, not by sleeping. */ +void flan_sleep_ns(int64_t ns) { + struct timespec ts; + if (ns <= 0) return; + ts.tv_sec = (time_t)(ns / 1000000000); + ts.tv_nsec = (long)(ns % 1000000000); + while (nanosleep(&ts, &ts) != 0 && errno == EINTR) { } +} + +/* getenv, with the absent case carried in the length rather than in the + * pointer, so that the Flan side never has to compare a pointer against null — + * a test the language does not offer, since a (Ptr T) only ever arrives from a + * declare and nothing in the type says it may be nothing. Absent is *len = -1 + * and a pointer to a valid empty string; present is *len >= 0 and the + * environment's own bytes, which (slice-from-ptr) then views. + * + * The bytes are the process environment's and are not copied. They outlive the + * call — nothing in this language can call setenv or spawn a process, so there + * is no writer — and they are not the caller's to free. The prelude's `getenv` + * says so where a caller will read it. + * + * A name with an embedded NUL reads as absent rather than as the shorter name + * before it, which is flan_path_cstr's rule and for its reason: the name + * looked up must be the name written. */ +const uint8_t *flan_getenv(const uint8_t *name, int64_t n, int64_t *len) { + static const char empty[1] = { 0 }; + char buf[FLAN_PATH_MAX]; + const char *v; + *len = -1; + if (!flan_path_cstr(name, n, buf)) return (const uint8_t *)empty; + v = getenv(buf); + if (!v) return (const uint8_t *)empty; + *len = (int64_t)strlen(v); + return (const uint8_t *)v; +} diff --git a/test/programs/time.flan b/test/programs/time.flan new file mode 100644 index 0000000..6836c24 --- /dev/null +++ b/test/programs/time.flan @@ -0,0 +1,71 @@ +;;;; The clock and the environment. +;;;; +;;;; Every line of output here is an invariant and not a reading, and that is +;;;; forced rather than chosen: this file is in the corpus @x86 builds twice +;;;; and diffs, and the acceptance table matches its stdout exactly, so a +;;;; timestamp or an elapsed count would fail a correct compiler on the second +;;;; run. What is left is what a clock actually has to promise — that it does +;;;; not go backwards, that a sleep does not return early, that the two faces +;;;; of one clock describe one instant — and those are the properties worth +;;;; pinning anyway. A test that asserted "this took under 3ms" would be a +;;;; test of the machine's load. + +(defn main [] i32 + ;; Monotonic, twice. The whole contract in one line: it never goes + ;; backwards. Equal is allowed and is not a bug — two reads inside one tick + ;; of a coarse timer are the same nanosecond. + (let [t1 (monotonic-ns) + t2 (monotonic-ns)] + (println (>= t2 t1))) + + ;; And the origin is the first read rather than boot, so the first readings + ;; a program takes are small. Bounded rather than pinned: the number is + ;; whatever this process spent between the calls above and this one, which + ;; is not a second on any machine that can run the suite at all. + (println (< (monotonic-ns) ns-per-second)) + + ;; The f64 face is the i64 one divided, and what is checked is that the two + ;; describe the same instant: a later reading in seconds is at or past an + ;; earlier reading in nanoseconds converted the same way. A clock whose two + ;; faces came from different sources fails this. + (let [a (/ (f64 (monotonic-ns)) 1000000000.0) + b (monotonic-seconds)] + (println (>= b a))) + + ;; The wall clock is a date, so the invariant is a date one: it is after + ;; 2020 and before 2100. That pins the epoch and the unit at once — a clock + ;; counting microseconds, or counting from boot, fails both halves. + (let [now (unix-seconds)] + (println (and (> now 1577836800.0) (< now 4102444800.0)))) + + ;; Sleep is specified as *at least*, so at-least is what is asserted; the + ;; upper bound belongs to the scheduler and not to this language. Two + ;; milliseconds because the shortest sleep a default kernel actually + ;; performs is a timer tick, and a shorter request would make this a test of + ;; how that kernel was configured. + (let [before (monotonic-ns)] + (sleep-ns (* 2 ns-per-millisecond)) + (println (>= (- (monotonic-ns) before) (* 2 ns-per-millisecond)))) + + ;; Zero and negative return at once rather than being refused, which is what + ;; a deadline already passed produces. That they return at all is the + ;; assertion; nothing here is timed. + (sleep-ns 0) + (sleep-ns -1) + (sleep-seconds 0.0) + (println "slept") + + ;; ── The environment ────────────────────────────────────────────── + + ;; A variable nothing sets. None is the answer, and it is a different answer + ;; from a variable set to nothing. + (match (getenv "FLAN_NO_SUCH_VARIABLE_AT_ALL") + (Some v) (println "unexpectedly set") + None (println "unset")) + + ;; PATH is set for every process that gets as far as running this, and the + ;; only portable thing about its contents is that there are some. + (match (getenv "PATH") + (Some v) (println (> (len v) 0)) + None (println "no PATH")) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 4457ba1..5054422 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -285,6 +285,15 @@ let () = outputs "the rest of libm, both widths" "programs/math3.flan" math3_out; outputs ~opt:"-O0" "the rest of libm, both widths, -O0" "programs/math3.flan" math3_out; + (* The clock and the environment. Every line of that program's output is + an invariant — a monotonicity, a date range, a sleep that did not + return early — and not a reading, because the same file is in the + corpus @x86 builds twice and diffs, so a timestamp would fail a correct + compiler on its second run. *) + let time_out = "true\ntrue\ntrue\ntrue\ntrue\nslept\nunset\ntrue\n" in + outputs "the clock and getenv" "programs/time.flan" time_out; + outputs ~opt:"-O0" "the clock and getenv, -O0" "programs/time.flan" + time_out; (* index-of-bytes, trim, the byte classes and parse-f64. The search cases are the ones that separate a correct loop from a lucky one: a match only at the end, "aab" in "aaab" (where the first byte matches twice diff --git a/web/index.html b/web/index.html index 5536c84..59d57cf 100644 --- a/web/index.html +++ b/web/index.html @@ -1064,6 +1064,8 @@ over.

building bytesappend!, append-i64!, append-f64!, concat, join, repeat-bytes, replace-bytes, slices-new, format-f64 UTF-8decode-rune, rune-at, rune-count, rune-size, rune-start?, valid-utf8?, encode-rune! numberssign-f32, lerp, clamp, floor-f32, ceil-f32, round-f32, abs-i32, abs-i64, the constants pi-f32, pi-f64, tau-f32, tau-f64, and libm through a declare at both widths: sqrt, abs, floor, ceil, round, fmod, sin, cos, tan, asin, acos, atan, atan2, log, log2, log10, exp, pow, hypot, cbrt — each spelled -f32 or -f64 +timemonotonic-ns, monotonic-seconds, unix-ns, unix-seconds, sleep-ns, sleep-seconds, and ns-per-second and its two smaller siblings +the operating systemgetenv, which answers an (Option [u8]) viewing the process environment randomrand-seed, rand-u32, rand-f32, rand-i32-range, rand-f32-range forms, for macrosform-nil, form-cons, form-append, form-rest, form-items, form-pair, form-sym?, form-is-sym?, gensym, and unless and into, which are macros written here rather than special forms the restpause, which signals the Pause condition the break loop stops on, and embed-find @@ -1103,6 +1105,18 @@ and the three libms do differ in the last bit, so the byte-identical-hash proper RNG exists for does not survive a value routed through any of them. Every link carries -lm.

+

The clock is two clocks and they are not interchangeable. +monotonic-ns measures: it never goes backwards, nothing adjusts it, and its +zero is the first time the program reads it, so it is meaningless alone and correct as a +difference. unix-ns dates: nanoseconds since 1970, which is what goes in a +save file or a log line, and which can jump in either direction when the system clock is +set. Odin draws the same line — Tick against Time in +core/time — and the nanosecond integer is the primitive on both sides, with +the -seconds faces derived from it. The f64 of seconds is the +shape raylib's get-time already answers with, so the two mix; it stays +integer-exact in nanoseconds for a hundred days of process life, which is why the +monotonic origin is the first read and not boot.

+

The primitives underneath are few — a primitive is the only thing implemented twice per backend: argv, write-stdout, exit, len, at, From c5b8af23a1a88c79b0e99c5716f73023798473df Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 21:51:17 +0700 Subject: [PATCH 3/5] 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. --- lib/check.ml | 64 +++++++++++++++++++++ lib/emit.ml | 8 +++ lib/prelude.ml | 41 ++++++++++++- runtime/flan_rt.c | 120 +++++++++++++++++++++++++++++++++++++++ test/programs/files.flan | 107 ++++++++++++++++++++++++++++++++++ test/test_acceptance.ml | 31 ++++++++++ web/index.html | 12 ++++ 7 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 test/programs/files.flan diff --git a/lib/check.ml b/lib/check.ml index 13c9203..46c3f10 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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 diff --git a/lib/emit.ml b/lib/emit.ml index c9e771a..7f7eb1e 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -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) |} diff --git a/lib/prelude.ml b/lib/prelude.ml index ff16762..30d6435 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -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 diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 33c0424..db27a46 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -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 +#include + +/* 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 +} diff --git a/test/programs/files.flan b/test/programs/files.flan new file mode 100644 index 0000000..adf8178 --- /dev/null +++ b/test/programs/files.flan @@ -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) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 5054422..3b51865 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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 diff --git a/web/index.html b/web/index.html index 59d57cf..d8d80e5 100644 --- a/web/index.html +++ b/web/index.html @@ -1065,6 +1065,7 @@ over.

UTF-8decode-rune, rune-at, rune-count, rune-size, rune-start?, valid-utf8?, encode-rune! numberssign-f32, lerp, clamp, floor-f32, ceil-f32, round-f32, abs-i32, abs-i64, the constants pi-f32, pi-f64, tau-f32, tau-f64, and libm through a declare at both widths: sqrt, abs, floor, ceil, round, fmod, sin, cos, tan, asin, acos, atan, atan2, log, log2, log10, exp, pow, hypot, cbrt — each spelled -f32 or -f64 timemonotonic-ns, monotonic-seconds, unix-ns, unix-seconds, sleep-ns, sleep-seconds, and ns-per-second and its two smaller siblings +filesfile-exists? and file-size, which answer a value; slurp, barf, delete-file, rename-file and make-directory, which signal FileError under retry and use-value the operating systemgetenv, which answers an (Option [u8]) viewing the process environment randomrand-seed, rand-u32, rand-f32, rand-i32-range, rand-f32-range forms, for macrosform-nil, form-cons, form-append, form-rest, form-items, form-pair, form-sym?, form-is-sym?, gensym, and unless and into, which are macros written here rather than special forms @@ -1117,6 +1118,17 @@ shape raylib's get-time 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.

+

The file surface is split by whether a handler could do anything. +file-exists? and file-size answer a bool and an +(Option i64): absence is the reply, not a fault, and a condition would make +the ordinary case pay for a handler search. slurp, barf, +delete-file, rename-file and make-directory signal +FileError instead, under the two restarts Common Lisp establishes for a +file error — retry, because the handler may have just made the directory, +and use-value 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.

+

The primitives underneath are few — a primitive is the only thing implemented twice per backend: argv, write-stdout, exit, len, at, From 901376ba49f671e211981627910be51008475616 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 22:02:38 +0700 Subject: [PATCH 4/5] The three new programs join the two memory sweeps Both corpora are explicit lists and not globs, so a program added to test/programs is covered by dune test and by @x86 and by nothing else until somebody types its name here. files.flan, math3.flan and time.flan are typed. time.flan is the one with something to say. getenv hands back a slice viewing the process environment and never a copy, which is the exact shape a use-after-free or an off-by-one length would be, and neither ASan nor memcheck had ever seen it. files.flan brings three more path buffers through flan_path_cstr. math3.flan is the cheap one and is here for completeness. files.flan makes and removes its own tree, so the sweeps' two runs of it see the same directory both times. @sanitize is clean with all three in. @valgrind is not run here -- it is tens of minutes and opt-in -- so those three entries are checked by the next person who runs the alias. --- test/test_sanitize.ml | 9 +++++++++ test/test_valgrind.ml | 3 +++ 2 files changed, 12 insertions(+) diff --git a/test/test_sanitize.ml b/test/test_sanitize.ml index 12090cd..971d768 100644 --- a/test/test_sanitize.ml +++ b/test/test_sanitize.ml @@ -128,9 +128,14 @@ let corpus = "programs/edn.flan", []; "programs/enum-compare.flan", []; "programs/error.flan", []; + (* Makes and removes its own tree, so the two runs of the sweep see the + same directory; the new C here is three more path buffers, which is + exactly what this tool is for. *) + "programs/files.flan", []; "programs/handles.flan", []; "programs/machine.flan", []; "programs/math.flan", []; + "programs/math3.flan", []; "programs/pkg-diamond.flan", []; "programs/pkg-return.flan", []; "programs/pkg-shared.flan", []; @@ -143,6 +148,10 @@ let corpus = "programs/slices.flan", []; "programs/string-of-bytes.flan", []; "programs/text.flan", []; + (* The clock and getenv. getenv hands back a slice viewing the process + environment and never a copy, so a report here would be the one that + matters. *) + "programs/time.flan", []; "programs/unit-main.flan", []; "programs/utf8.flan", []; "programs/values.flan", []; diff --git a/test/test_valgrind.ml b/test/test_valgrind.ml index e58a06e..3577948 100644 --- a/test/test_valgrind.ml +++ b/test/test_valgrind.ml @@ -215,6 +215,7 @@ let corpus = "programs/error.flan", []; "programs/exhausted.flan", []; "programs/exhausted-unhandled.flan", []; + "programs/files.flan", []; "programs/free-all-refused.flan", []; "programs/handles.flan", []; "programs/machine.flan", []; @@ -222,6 +223,7 @@ let corpus = "programs/map-stale-region.flan", []; "programs/maps.flan", []; "programs/math.flan", []; + "programs/math3.flan", []; "programs/pool-stale-region.flan", []; "programs/pkg-macro.flan", []; "programs/pkg-diamond.flan", []; @@ -241,6 +243,7 @@ let corpus = "programs/stale-region.flan", []; "programs/string-of-bytes.flan", []; "programs/text.flan", []; + "programs/time.flan", []; "programs/datas.flan", []; "programs/unit-main.flan", []; "programs/utf8.flan", []; From 98dce374d12c387c20c9da6db9d2a2fa820cb95f Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 17 Sep 2026 22:06:59 +0700 Subject: [PATCH 5/5] The FFI example declared a name the prelude now has web/examples/ffi.flan opened with (declare cos-f64 [x f64] f64 "cos"), which was a fine one-liner until the prelude grew cos-f64 an hour ago. @page caught it: "cos-f64 is defined twice", with the prelude line named as the other site. It is cosh now. Same shape, same answer, and the collision is worth keeping in the page rather than editing around silently -- a reader reaching for a libm function needs to know the common ones are already there and that a second declaration of a name is refused, not shadowed. The example says so in one sentence. This is the cost of filling out the prelude, and it is the whole of it: a program that declared one of the new names for itself stops compiling, with both sites named. Nothing in the corpus or in examples/ hit it; this page did. --- web/examples/ffi.flan | 9 +++++++-- web/index.html | 11 ++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/web/examples/ffi.flan b/web/examples/ffi.flan index d34ca00..6429f95 100644 --- a/web/examples/ffi.flan +++ b/web/examples/ffi.flan @@ -1,6 +1,11 @@ ;; A plain `declare` names a C symbol in a signature Flan can already spell: ;; no aggregate crosses, so no wrapper is generated. -(declare cos-f64 [x f64] f64 "cos") +;; +;; cosh and not cos, because the prelude already declares cos-f64 and a second +;; declaration of a name is refused with both sites named. That refusal is the +;; useful half of this example: the prelude is where the common libm calls +;; live, and a `declare` is how you reach one it does not name. +(declare cosh-f64 [x f64] f64 "cosh") (defn main [] () - (print (cos-f64 0.0)) (println "")) + (print (cosh-f64 0.0)) (println "")) diff --git a/web/index.html b/web/index.html index d8d80e5..8860532 100644 --- a/web/index.html +++ b/web/index.html @@ -1418,14 +1418,19 @@ structural rule could tell them apart.

declare names a C symbol in a signature Flan can already spell. Nothing is generated; a Flan string crosses as ptr+len, exactly as it is stored.

-
(declare cos-f64 [x f64] f64 "cos")
+
(declare cosh-f64 [x f64] f64 "cosh")
 
 (defn main [] ()
-  (print (cos-f64 0.0)) (println ""))
+ (print (cosh-f64 0.0)) (println ""))
1
-

That is 1.0, printed by the same rule as before.

+

That is 1.0, printed by the same rule as before. It is cosh and not +cos because the prelude already declares +cos-f64, and a second declaration of a name is refused with both sites +named — which is the other half of what this example shows. The prelude is where the +common libm calls live; a declare is how you reach one it does not +name.

declare-c names the C library's own function in the C library's own signature, and the compiler writes the wrapper. This is what raylib's package is made