Merge branch 'string-of-bytes' into dev-loop

A [u8] and a string are the same 16 bytes at run time, so (string b)
is a reinterpretation with no instructions. What it buys is that a
number can reach draw-text at all, which five of the ten examples
wanted and none could have.
This commit is contained in:
Joseph Ferano 2026-09-12 05:21:58 +07:00
commit cb47b98100
11 changed files with 228 additions and 86 deletions

View File

@ -5,7 +5,11 @@
;;;; screen itself and this example wants to compare it against the target it
;;;; set, so it needs the number back.
;;;;
;;;; Three TextFormats in the C, all drawn here by examples/digits.flan.
;;;; Three TextFormats in the C. Two of the three are a fixed number of
;;;; decimal places, which `f64->bytes` cannot do — it is "%g" — so they
;;;; still go through examples/digits.flan. The integer one does too, only
;;;; because its width is needed to put the next piece after it; the draw
;;;; itself is now (string (i64->bytes n)) and one draw-text.
;;;;
;;;; One faithfulness note that is a bug in the C and is kept anyway: it draws
;;;; `TextFormat("Frame time: %02.02f ms", GetFrameTime())` — GetFrameTime is

View File

@ -25,12 +25,14 @@
;;;; transcendental. The protractor needs both, so they are two more `declare`
;;;; lines here, in the same shape. They link: libm is already on the line.
;;;;
;;;; **No string formatting**, as everywhere. The C prints the angle with
;;;; `TextFormat("%f", ...)`, finds the decimal point with `TextFindIndex` and
;;;; cuts two digits past it with `TextSubtext`. `draw-f32` in
;;;; examples/digits.flan does the whole thing in one call, and rounds rather
;;;; than truncating — which is the one place this screen differs from the C's
;;;; by a digit.
;;;; **No string formatting.** A number can be made into a string now —
;;;; (string (i64->bytes n)) — but a *format* still cannot: f64->bytes is
;;;; "%g", with no way to ask for two decimal places. The C prints the angle
;;;; with `TextFormat("%f", ...)`, finds the decimal point with
;;;; `TextFindIndex` and cuts two digits past it with `TextSubtext`;
;;;; `draw-f32` in examples/digits.flan does the whole thing in one call, and
;;;; rounds rather than truncating — which is the one place this screen
;;;; differs from the C's by a digit.
;;;;
;;;; **No local fixed arrays.** The C declares `char gestureLog[20][12]` and
;;;; `Vector2 touchPosition[32]` inside main. A `let` binding takes no type

View File

@ -3,17 +3,16 @@
;;;; examples/core/core_input_mouse_wheel.c. Needed get-mouse-wheel-move, now
;;;; bound.
;;;;
;;;; This is the first example with a `TextFormat` in it, and therefore the
;;;; first that runs into the gap: the C draws
;;;; This is the first example with a `TextFormat` in it. The C draws
;;;;
;;;; DrawText(TextFormat("Box position Y: %03i", boxPositionY), ...)
;;;;
;;;; and Flan has no way to make a string out of a number. The label is drawn
;;;; with draw-text and the number after it with draw-int-padded from
;;;; examples/digits.flan, which is one glyph per digit out of a [10 string]
;;;; table. The "%03i" is the `3` argument — the leading zeroes are there, and
;;;; a number wider than three digits is drawn in full, exactly as printf's
;;;; minimum-width means.
;;;; and the number half of that is now sayable: (string (i64->bytes n)).
;;;; What is not is the "%03" — i64->bytes has no field width — so the label
;;;; is drawn with draw-text and the number with draw-int-padded from
;;;; examples/digits.flan, which draws the leading zeroes itself and then the
;;;; number in one call. A number wider than three digits is drawn in full,
;;;; exactly as printf's minimum-width means.
;;;;
;;;; The position is signed and goes negative as soon as the box scrolls past
;;;; the top, which is why draw-int-padded handles a sign at all.

View File

@ -23,7 +23,6 @@
;;;; without hardware.
(import rl "vendor:raylib")
(import d "digits.flan")
(defconst screen-width 800)
(defconst screen-height 450)
@ -57,11 +56,13 @@
;; something the port introduced.
(when (and (> (.x p) 0.0) (> (.y p) 0.0))
(rl/draw-circle-v p 34.0 rl/orange)
;; The C's TextFormat("%d", i) — one digit, drawn by the shared
;; helper rather than by a codepoint call, so every number on
;; screen in these ten files goes through the same path.
(d/draw-int i (- (i32 (.x p)) 10) (- (i32 (.y p)) 70) 40
rl/black))))
;; The C's TextFormat("%d", i). This used to need examples/digits.flan
;; and a table of one-character strings; (string b) makes the
;; number a string with no instructions, so it is one draw-text and
;; this file imports nothing but raylib.
(rl/draw-text (string (i64->bytes (i64 i)))
(- (i32 (.x p)) 10) (- (i32 (.y p)) 70) 40
rl/black))))
(rl/draw-text "touch the screen at multiple locations to get multiple balls"
10 10 20 rl/darkgray)

View File

@ -36,7 +36,6 @@
;;;; top-level `defvar`, since a `let` binding takes no type annotation.
(import rl "vendor:raylib")
(import d "digits.flan")
(defconst screen-width 800)
(defconst screen-height 450)
@ -189,7 +188,8 @@
;; Not in the C: which button the search picked, as a number, so the
;; headless case and the window agree about the same thing.
(rl/draw-text "button: " 10 34 20 rl/lightgray)
(d/draw-int pressed (+ 10 (rl/measure-text "button: " 20)) 34 20
rl/lightgray)
(rl/draw-text (string (i64->bytes (i64 pressed)))
(+ 10 (rl/measure-text "button: " 20)) 34 20
rl/lightgray)
(rl/end-drawing))))

View File

@ -1,51 +1,59 @@
;;;; Drawing a number, because the language cannot make one into a string.
;;;; Drawing a number, now that the language can make one into a string.
;;;;
;;;; Five of the ten ported examples call raylib's `TextFormat` to put a number
;;;; on the screen. Flan has no string formatting and no way to reach it:
;;;; This file used to be the workaround for a gap: five of the ten ported
;;;; examples call raylib's `TextFormat` to put a number on the screen, and
;;;; nothing in Flan could reach `draw-text` with one — `i64->bytes` answered a
;;;; `[u8]`, `draw-text` wanted a `string`, and there was no bridge and no
;;;; allocator to build one in. So a number was drawn one glyph at a time out
;;;; of a `[10 string]` table.
;;;;
;;;; - `i64->bytes` is a builtin and answers a `[u8]`;
;;;; - `draw-text` takes a `string`;
;;;; - nothing converts a `[u8]` into a `string`. A string is a compile-time
;;;; literal or a parameter, and there is no allocator to build one in.
;;;; `(string b)` closed that. It is the mirror of `(bytes s)` and costs no
;;;; instructions — a `string` and a `[u8]` are the same 16-byte %slice — so
;;;; `(string (i64->bytes n))` draws in one call and the table, the per-glyph
;;;; pen and the digit arithmetic behind them are gone.
;;;;
;;;; `TextFormat` itself is not bindable either, and not because of the FFI
;;;; rules: it is variadic, so its signature is not a signature — declaring it
;;;; with fixed arguments would be a claim about the ABI that is false on every
;;;; target at once, and it returns a `char *` into a rotating static buffer,
;;;; which `declare-c` refuses by name anyway ("a string only crosses as a
;;;; parameter — a C function that *returns* one returns something Flan has no
;;;; owner for").
;;;; What is left is the part `(string ...)` does not answer, which is
;;;; *formatting*: `i64->bytes` has no field width, so "%03i" still has to be
;;;; assembled, and `f64->bytes` is `%g` and not "%.02f", so a fixed number of
;;;; decimal places still has to be split and drawn in two pieces. Those two
;;;; are why the file survives at all, and the three signatures are unchanged
;;;; so the five callers did not have to move.
;;;;
;;;; So a number reaches the screen one digit at a time, each digit drawn as a
;;;; one-character `string` out of the table below. sand.flan already does this
;;;; for a single digit with `draw-text-codepoint`; this is the same trick
;;;; generalised, in one place, so the gap shows up in the report as one gap
;;;; rather than as five separate improvisations.
;;;; `TextFormat` itself is still not bindable, and not because of the FFI
;;;; rules: it is variadic, so its signature is not a signature, and it returns
;;;; a `char *` into a rotating static buffer, which `declare-c` refuses by
;;;; name anyway.
;;;;
;;;; ONE TRAP, and it is the reason every function below is written as a strict
;;;; sequence of format-draw-measure rather than as a let of several pieces:
;;;; `i64->bytes` and `f64->bytes` both write into a single shared static
;;;; buffer in the runtime, overwritten by the next such call. `(string ...)`
;;;; does not copy it. So a number must be *drawn before the next one is
;;;; formatted* — holding two at once is wrong pixels with no crash and no
;;;; diagnostic.
;;;;
;;;; Everything here needs a window: `measure-text` answers 0 for every string
;;;; until init-window has loaded the default font, and a zero advance would
;;;; stack every digit on top of the first.
;;;; stack every piece on top of the first.
(import rl "vendor:raylib")
;; A `[10 string]` — a fixed array whose element type is `string`. That works,
;; which is worth recording: a string is ptr+len and the array is ten of those
;; laid out flat, with the bytes themselves in the module's constant data.
(defconst digit-glyphs [10 string]
["0" "1" "2" "3" "4" "5" "6" "7" "8" "9"])
;; Padding is drawn from a literal, one zero at a time. This is the last of the
;; old glyph table and it is here only because there is no field width.
(defconst zero-glyph "0")
(defconst minus-glyph "-")
(defconst dot-glyph ".")
;; One glyph, and how far the pen moved. raylib's default font is not
;; One piece of text, and how far the pen moved. raylib's default font is not
;; monospaced — "1" is narrower than "8" — so the advance is measured rather
;; than assumed, which is also what keeps the spacing identical to what
;; draw-text would have produced for the whole string at once.
(defn draw-glyph [g string x i32 y i32 size i32 color rl/Color] i32
(rl/draw-text g x y size color)
(rl/measure-text g size))
;; than assumed.
;;
;; Nothing may be formatted between the draw and the measure: `s` may be a view
;; of the shared buffer, and both calls have to see the same bytes.
(defn draw-piece [s string x i32 y i32 size i32 color rl/Color] i32
(rl/draw-text s x y size color)
(rl/measure-text s size))
;; How many decimal digits `n` has, for n >= 0. 0 has one.
;; How many decimal digits `n` has, for n >= 0. 0 has one. Only the padded
;; forms need it now — it is how many zeroes go in front.
(defn digit-count [n i32] i32
(let [d 1
r (/ n 10)]
@ -60,42 +68,36 @@
(set p (* p 10)))
p))
;; The whole point of the file. Answers the width drawn, so a caller can put
;; something after it — which is how the `TextFormat("%s: %i", ...)` shapes in
;; the C are reassembled here: draw the literal part with draw-text, then this
;; at x plus its width.
;; The whole number, in one draw-text. Answers the width drawn, so a caller can
;; put something after it — which is how the `TextFormat("%s: %i", ...)` shapes
;; in the C are reassembled here: draw the literal part with draw-text, then
;; this at x plus its width.
;;
;; Negative numbers get the sign and then the magnitude. i32's most negative
;; value is NOT handled: negating it wraps to itself, so it would print its own
;; bit pattern with a minus in front. Nothing here ever reaches it — these are
;; screen coordinates, frame counts and axis readings — and guarding it would
;; be a branch that no call site can take.
;; The sign comes free now: i64->bytes renders "-7" itself, which also retires
;; the old note about i32's most negative value — it is widened to i64 before
;; formatting, so there is no negation to wrap.
(defn draw-int [n i32 x i32 y i32 size i32 color rl/Color] i32
(let [cx x
v n]
(when (< v 0)
(set cx (+ cx (draw-glyph minus-glyph cx y size color)))
(set v (- 0 v)))
(let [count (digit-count v)]
(dotimes [i count]
(let [d (% (/ v (pow10 (- (- count 1) i))) 10)]
(set cx (+ cx (draw-glyph (at digit-glyphs d) cx y size color))))))
(- cx x)))
(draw-piece (string (i64->bytes (i64 n))) x y size color))
;; The same with a fixed number of leading zeroes — the C's "%03i". A number
;; The same with a fixed minimum number of digits — the C's "%03i". A number
;; wider than `width` is drawn in full rather than truncated, which is what
;; printf does too.
;;
;; The zeroes are drawn first and the number after, so the one formatted value
;; is still live when it is drawn. A sign goes in front of the padding, as
;; printf's "%03i" does for -7 → "-07"; hence the magnitude is what gets
;; counted and the minus is drawn separately.
(defn draw-int-padded [n i32 width i32 x i32 y i32 size i32 color rl/Color]
i32
(let [cx x
v n]
(when (< v 0)
(set cx (+ cx (draw-glyph minus-glyph cx y size color)))
(set cx (+ cx (draw-piece "-" cx y size color)))
(set v (- 0 v)))
(let [count (max width (digit-count v))]
(dotimes [i count]
(let [d (% (/ v (pow10 (- (- count 1) i))) 10)]
(set cx (+ cx (draw-glyph (at digit-glyphs d) cx y size color))))))
(let [pad (- width (digit-count v))]
(dotimes [i pad]
(set cx (+ cx (draw-piece zero-glyph cx y size color)))))
(set cx (+ cx (draw-int v cx y size color)))
(- cx x)))
;; "%.02f" and friends. `places` digits after the point, rounded by adding a
@ -106,11 +108,16 @@
;; f32 and not f64 deliberately: every number this draws comes out of raylib,
;; and raylib's are floats. Widening them here would suggest a precision the
;; value does not have.
;;
;; `f64->bytes` is not used at all — it is "%g", which would print 0.5 for a
;; value asked for at three places and 1e+06 for a large one. The split into
;; two integers is what buys the fixed width, and it also keeps every formatted
;; value drawn before the next one is made.
(defn draw-f32 [v f32 places i32 x i32 y i32 size i32 color rl/Color] i32
(let [cx x
av v]
(when (< av 0.0)
(set cx (+ cx (draw-glyph minus-glyph cx y size color)))
(set cx (+ cx (draw-piece "-" cx y size color)))
(set av (- 0.0 av)))
(let [scale (pow10 places)
;; The rounding and the split happen in one integer so the two halves
@ -122,6 +129,6 @@
frac (% total scale)]
(set cx (+ cx (draw-int whole cx y size color)))
(when (> places 0)
(set cx (+ cx (draw-glyph dot-glyph cx y size color)))
(set cx (+ cx (draw-piece "." cx y size color)))
(set cx (+ cx (draw-int-padded frac places cx y size color)))))
(- cx x)))

View File

@ -1240,6 +1240,48 @@ and named_call ctx ~want loc name args =
arity loc name 1 args;
prim Tast.Bytes (Types.Slice (Types.Int Types.U8))
[ check ctx ~want:Types.String (List.hd args) ]
(* (string b): a [u8] seen as a string. The mirror of (bytes s), spelled the
same way a type name in head position, like (bytes s) and unlike the
numeric casts, which go through [is_cast] and really do convert.
It costs nothing. emit.ml lowers Types.String and Types.Slice _ to the
same %slice, 16 bytes at align 8, so a string and a [u8] are already the
identical value at run time; both this and [Bytes] emit as the argument
itself. What changes is only what the checker will let the value be
passed to which is the whole gap: i64->bytes answers a [u8] and every
declare-c text parameter wants a string, and nothing joined them.
Two decisions are baked in here.
1. It does NOT check UTF-8, because `string` does not claim UTF-8. The
prelude settles this: valid-utf8? is an ordinary function you call when
you care, decode-rune/rune-at/rune-count all take [u8] rather than
string, and decode-rune answers {:ok false :width 1} on a malformed
byte rather than assuming its input is well-formed. The one place the
runtime treats a string differently from a byte slice is
flan_escape_bytes, for a string nested in a printed structure, and that
is a byte-wise escape table with no decoding in it. So there is no code
that would be wrong about a string of arbitrary bytes, and a check here
would be the only enforcement point in the language a claim the rest
of it does not make.
2. It does not widen the literal-write hole (NEXT.md, "Writing through a
string literal"). That hole is the other direction: (bytes "Hi") hands
you a writable-looking slice over constant data. This direction only
loses the ability to write a string is read-only everywhere so the
result of (string b) can reach strictly fewer stores than b could.
Provenance is still what the other direction needs; nothing here
depends on having it.
The one sharp edge is not new but is easier to trip over now: the slice
that i64->bytes / f64->bytes / u64->bytes answer is a view into one shared
static buffer in the runtime, overwritten by the next such call. Calling
it a string does not copy it. Use it before formatting the next number;
you cannot hold two at once. *)
| "string" ->
arity loc name 1 args;
prim Tast.StrOfBytes Types.String [ byte_slice ctx (List.hd args) ]
| "bytes->f64" ->
arity loc name 1 args;
prim Tast.BytesToF64 (Types.Float Types.F64) [ byte_slice ctx (List.hd args) ]

View File

@ -1208,6 +1208,9 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
(* string and [u8] have the same layout, so bytes is the identity — a view,
no copy (plan.org, Milestone-2 primitives). *)
| Tast.Bytes, [ x ] -> value f x
(* (string b), and the same non-instruction for the same reason: String and
Slice _ are both %slice. See check.ml's "string" case. *)
| Tast.StrOfBytes, [ x ] -> value f x
| Tast.BytesToF64, [ x ] -> shim_in f "@flan_bytes_to_f64" "double" x
| Tast.BytesToI64, [ x ] -> shim_in f "@flan_bytes_to_i64" "i64" x
| Tast.F64ToBytes, [ x ] -> shim_out f "@flan_f64_to_bytes" x

View File

@ -28,6 +28,9 @@ type prim =
*text*: bytes->f64 parses "12.5", f64->bytes renders it that is what
calc-me's tokenizer and the prelude's printers each need. *)
| Bytes | BytesToF64 | BytesToI64 | F64ToBytes | I64ToBytes
(* (string b): the other direction of [Bytes], and the same non-instruction.
See check.ml's "string" case for why it is unchecked. *)
| StrOfBytes
(* No surface name: the structural printer is the only thing that builds
these. U64ToBytes because u64 is not i64 with a flag, EscapeBytes for a
string nested inside a printed structure. *)

View File

@ -0,0 +1,63 @@
;;;; (string b) — a [u8] seen as a string.
;;;;
;;;; The conversion emits no instructions: String and Slice _ are both %slice,
;;;; 16 bytes at align 8. So what is worth testing is not arithmetic, it is the
;;;; four places a zero-instruction reinterpretation could still be wrong about
;;;; the *length* or about who owns the bytes.
;;;;
;;;; Run at -O2 and at -O0. The pair matters here for the same reason it
;;;; matters for the literal-write sharp edge: a conversion that accidentally
;;;; produced undefined behaviour would be a SIGSEGV at one level and a silent
;;;; deletion at the other, and agreeing at one level alone proves nothing.
;; puts and not a Flan printer: the point of this declaration is the *shim*,
;; which takes ptr+len and NUL-terminates a copy. A shim that instead trusted
;; the bytes to already be terminated would print the rest of the buffer for
;; every sub-view below, and nothing inside Flan would notice.
;;
;; Its return is C's "some nonnegative value", not a number worth printing, so
;; it is only shown as a sign — and it is printed through Flan's own writer,
;; which shares stdout's buffer with puts, so the interleaving is stable.
(declare-c c-puts [s string] i32 "puts")
(defn shows [s string]
(print-str "[")
(print-str s)
(print-str "] ")
(print-i64 (i64 (len (bytes s))))
(newline))
(defn main [] i32
;; A number. The gap this closes: i64->bytes answers a [u8], every text
;; parameter wants a string, and until now nothing joined them.
(shows (string (i64->bytes 42)))
(shows (string (i64->bytes -7)))
(shows (string (i64->bytes 0)))
;; An empty slice. Length 0, and no read of the pointer.
(shows (string (slice (bytes "abc") 1 1)))
;; A sub-view, whose length is not the underlying storage's. The bytes after
;; index 5 are still there and must not appear.
(let [s (bytes "hello world")]
(shows (string (slice s 0 5)))
(shows (string (slice s 6 11)))
(shows (string (slice s 11 11))))
;; Round trip: (bytes (string b)) is b, and both directions are the identity.
(let [b (i64->bytes 1234567)]
(print-i64 (i64 (len (bytes (string b)))))
(newline))
;; Across the declare-c boundary. The first is a sub-view — five bytes out of
;; eleven, the sixth of which is a space and not a NUL — so a shim that did
;; not copy would print "hello world" here.
(let [s (bytes "hello world")]
(print-str (if (>= (c-puts (string (slice s 0 5))) 0) "ok" "no"))
(newline))
(print-str (if (>= (c-puts (string (i64->bytes 12345))) 0) "ok" "no"))
(newline)
;; And an empty one: the shim's copy of a zero-length slice is "".
(print-str (if (>= (c-puts (string (slice (bytes "abc") 1 1))) 0) "ok" "no"))
(newline)
0)

View File

@ -225,6 +225,24 @@ let () =
outputs "substring, trim and parse-f64" "programs/bytes2.flan" bytes2_out;
outputs ~opt:"-O0" "substring, trim and parse-f64, -O0" "programs/bytes2.flan"
bytes2_out;
(* (string b). The conversion emits nothing — String and Slice _ are the
same %slice so the rows are about length and ownership rather than
arithmetic: a number round-tripped, an empty slice, sub-views whose
length is not the underlying storage's, and the result crossing a
declare-c boundary where the shim NUL-terminates a copy. That last one
is the load-bearing case: "hello world" cut to five bytes has a space
where C wants a NUL, so a shim that trusted the bytes would print all
eleven. Both levels, because a reinterpretation that had accidentally
been undefined would fail one way at -O0 and the other at -O2. *)
let string_of_bytes_out =
"[42] 2\n[-7] 2\n[0] 1\n[] 0\n\
[hello] 5\n[world] 5\n[] 0\n\
7\n\
hello\nok\n12345\nok\n\nok\n"
in
outputs "string of bytes" "programs/string-of-bytes.flan" string_of_bytes_out;
outputs ~opt:"-O0" "string of bytes, -O0" "programs/string-of-bytes.flan"
string_of_bytes_out;
(* handler-bind and signal, spec-conditions.md §1 and §2: signal returns
Unit and carries on, an unhandled one is a no-op, a nested frame does
not displace the one outside it, and the stack is restored after. *)