diff --git a/examples/core-delta-time.flan b/examples/core-delta-time.flan index c0a3706..5f6ed65 100644 --- a/examples/core-delta-time.flan +++ b/examples/core-delta-time.flan @@ -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 diff --git a/examples/core-input-gestures-testbed.flan b/examples/core-input-gestures-testbed.flan index 5773586..577184b 100644 --- a/examples/core-input-gestures-testbed.flan +++ b/examples/core-input-gestures-testbed.flan @@ -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 diff --git a/examples/core-input-mouse-wheel.flan b/examples/core-input-mouse-wheel.flan index 5d2c702..1cebf26 100644 --- a/examples/core-input-mouse-wheel.flan +++ b/examples/core-input-mouse-wheel.flan @@ -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. diff --git a/examples/core-input-multitouch.flan b/examples/core-input-multitouch.flan index 4bfc7b4..44be21b 100644 --- a/examples/core-input-multitouch.flan +++ b/examples/core-input-multitouch.flan @@ -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) diff --git a/examples/core-input-virtual-controls.flan b/examples/core-input-virtual-controls.flan index 81bc7d3..df80dd3 100644 --- a/examples/core-input-virtual-controls.flan +++ b/examples/core-input-virtual-controls.flan @@ -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)))) diff --git a/examples/digits.flan b/examples/digits.flan index 15c9afc..ba5b88a 100644 --- a/examples/digits.flan +++ b/examples/digits.flan @@ -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))) diff --git a/lib/check.ml b/lib/check.ml index 6a6eb03..e2f7faa 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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) ] diff --git a/lib/emit.ml b/lib/emit.ml index 455eb27..cd1aa80 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -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 diff --git a/lib/tast.ml b/lib/tast.ml index ae713f3..0e17f01 100644 --- a/lib/tast.ml +++ b/lib/tast.ml @@ -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. *) diff --git a/test/programs/string-of-bytes.flan b/test/programs/string-of-bytes.flan new file mode 100644 index 0000000..51426b1 --- /dev/null +++ b/test/programs/string-of-bytes.flan @@ -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) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 35b6e0a..4ef74ea 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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. *)