Dyn if tests truthiness — M2 queue item 7

A dyn scrutinee is no longer required to already be a bool: it is tested
for truthiness, Clojure's rule, not C's or Python's — nil and false are
the only falsey values, and everything else, including 0, "", an empty
vec, an empty map and a keyword, is truthy. A typed scrutinee is
unchanged and keeps needing a strict bool.

The runtime side is one new entry point, flan_dyn_truthy
(runtime/flan_dyn.c/.h), reading the tag directly rather than unboxing —
it never traps, unlike flan_dyn_need_bool. Both backends reach it the
same generic way flan_dyn_need_bool already did: check.ml emits an
ordinary Rt call plus the existing i32-to-bool Cast, so emit.ml only
needed the LLVM declare added and x86.ml needed nothing at all.

check.ml's check_truthy is the one funnel every boolean position in the
language goes through: if's own condition, while's, and not's argument.
when and cond reach it for free because they desugar to Ast.If in
parse.ml, and so does and's condition; or's condition does too, but its
answer position is a separate story — its short-circuit sentinel is the
then arm of its own if, which check_if types before anything else, so a
non-bool dyn value reaching that position still meets the strict bool
boundary. and's sentinel sits in the else arm instead, so the real
value's type wins and and hands back the actual last operand,
Clojure-style; or does not get that for the reason above, and reordering
it is a decision for another day, not this one. shortcircuit in parse.ml
carries the note.

check_truthy checks the scrutinee with no expectation first, so a dyn
value takes the truthy path and everything else takes the strict one. A
refusal on that second path is re-checked with the old want:Bool rather
than reported from the bare check, because a bare integer or float
literal, or a bare None, answers "what type is this" differently than
"is this a bool" — check.ml's own arms only give the nicer sentence
("expected bool, found the integer literal 5", "expected bool, found
None") when asked the second way, and that sentence is preserved exactly,
letter for letter, against what a typed if already said.

test/programs/dyn-if-truthy.flan surveys every falsey and truthy case —
nil, false, true, 0, a nonzero number, an empty and nonempty string, an
empty and nonempty vec, an empty and nonempty map, a keyword — through
if, when, cond, and, or, not and while, with real output pinned in
test_acceptance.ml across LLVM, -O0 and --x86. test_flan.ml covers the
checker side directly: a typed if still takes a bare bool and still
refuses a non-bool scrutinee and a bare None with their original
messages, a dyn if/not/while/when/cond/and/or all accept a non-bool dyn
condition. test/dyn_ops.c gets a matching set of direct calls to
flan_dyn_truthy, keeping the header's own contract with the C side.
This commit is contained in:
Joseph Ferano 2026-09-20 07:20:32 +07:00
parent 221df5af1c
commit 264765a6a5
9 changed files with 240 additions and 3 deletions

View File

@ -2032,7 +2032,7 @@ let rec check ctx ?want (e : Ast.expr) : Tast.expr =
top of every trip but it stays outside [in_loop], because a [break]
in a condition still means the enclosing loop and a [defer] there is
still the outer block's. *)
let c = check ctx ~want:Types.Bool c in
let c = check_truthy ctx loc c in
let body = in_loop ctx ?label (fun () ->
scoped ctx (fun () -> map_lr (fun b -> check ctx b) body))
in
@ -3174,8 +3174,42 @@ and check_recur ctx ~tail loc args =
mk loc Types.Never
(Tast.Let (temps, sets @ [ mk loc Types.Never (Tast.Continue depth) ]))
(* Every boolean-position test in the language funnels through here: [if]'s
own condition, [while]'s, and [not]'s argument. ([when] and [cond] are
sugar built out of [Ast.If] in parse.ml, so they get this for free
without a separate case. [and] and [or] are sugar too, and their *tests*
get this for free the same way see [shortcircuit] in parse.ml for why
[or]'s answer position does not.)
A dyn scrutinee is tested for truthiness, Clojure's rule: nil and false
are the only falsey values, and everything else 0, "", an empty vec, an
empty map, a keyword is truthy. A typed scrutinee stays strictly bool,
exactly as before.
The scrutinee is checked with no expectation first so its own type
decides which rule applies. That is fine for the passing cases dyn, or
already bool but a *refused* one has to be re-checked with the old
[want:Bool] rather than reported from here, and that covers two shapes of
"asked the wrong question first": a bare integer or float literal answers
differently to "what type is this" than to "is this a bool" (check.ml's
int_literal/float arms only give the nicer answer, "expected bool, found
the integer literal 5", when asked the second way), and [None] does not
even have an answer to the first question "nothing here says what None
is an Option of" — where the second gets straight to "expected bool,
found None". Asking the first way first is what makes the dyn case work,
so the refusal path success or exception both asks the second way
again, after the fact, purely to get the sentence a typed if has always
given. *)
and check_truthy ctx loc c =
match check ctx c with
| c0 when c0.Tast.ty = Types.Dyn ->
widen loc Types.Bool (rt loc (Types.Int Types.I32) "flan_dyn_truthy" [ c0 ])
| c0 when Types.fits ~expected:Types.Bool ~actual:c0.Tast.ty -> c0
| _ -> check ctx ~want:Types.Bool c
| exception Loc.Error _ -> check ctx ~want:Types.Bool c
and check_if ctx ?(tail = false) ?want loc c t e =
let c = check ctx ~want:Types.Bool c in
let c = check_truthy ctx loc c in
(* Both arms are the tail, and a one-armed [if] counts: [(when c (recur ...))]
is how nearly every loop is written, and the branch is still the last
thing the body does. *)
@ -4213,7 +4247,9 @@ and named_call ctx ~want loc name args =
end
| "not" ->
arity loc name 1 args;
prim Tast.Not Types.Bool [ check ctx ~want:Types.Bool (List.hd args) ]
(* Same truthiness as [if]: a dyn argument is negated on nil/false vs.
everything else, not narrowed to a strict bool first. *)
prim Tast.Not Types.Bool [ check_truthy ctx loc (List.hd args) ]
(* Bitwise operators are integers-only, and the shift count has the same type
as the value shifted there is no implicit widening anywhere else either. *)
| "bit-and" | "bit-or" | "bit-xor" ->

View File

@ -3387,6 +3387,7 @@ declare i32 @flan_dyn_need_bool(i64)
; builtin.
declare i32 @flan_dyn_is_nil(i64)
declare i64 @flan_dyn_need_not_nil(i64)
declare i32 @flan_dyn_truthy(i64)
declare void @flan_dyn_root_push(ptr)
declare void @flan_dyn_root_push_desc(ptr, ptr)
declare void @flan_dyn_root_pop(i64)

View File

@ -881,6 +881,18 @@ and cond f (args : Form.t list) : Ast.expr =
in
if args = [] then Loc.fail f.loc "cond needs at least one clause" else go args
(* Every test here is an [if]'s condition, so a dyn operand is truthy-tested
(check.ml's check_truthy) exactly the way a bare [if]'s is that much is
symmetric between [and] and [or]. What is not symmetric is the *answer*:
[and]'s "false" sentinel sits in the else arm, so check_if picks the real
branch's type first and boxes "false" to match it, which is what lets
[and] hand back the actual last dyn value, Clojure-style, rather than a
bare bool. [or]'s "true" sentinel sits in the then arm instead the one
check_if types *first* so it is what decides the whole expression's
type when nothing upstream already demanded one, and a later non-bool dyn
answer then hits the strict bool boundary instead of surviving as itself.
Reordering [or] to match [and] is a real fix and a separate decision; not
this item's to make. *)
and shortcircuit f (args : Form.t list) ~is_and : Ast.expr =
let mk e = { Ast.e; loc = f.loc } in
let rec go = function

View File

@ -1021,6 +1021,16 @@ flan_dyn flan_dyn_need_not_nil(flan_dyn v) {
return v;
}
/* Clojure's truthiness, not C's or Python's: nil and false are the only
* falsey values, and everything else 0, 0.0, "", an empty vec, an empty
* map, any keyword is truthy. Never traps; every tag answers. */
uint8_t flan_dyn_truthy(flan_dyn v) {
int32_t t = flan_dyn_tag(v);
if (t == FLAN_DYN_TAG_NIL) return 0;
if (t == FLAN_DYN_TAG_BOOL) return (uint8_t)(dyn_payload(v) ? 1 : 0);
return 1;
}
/* ── Arithmetic ────────────────────────────────────────────────────────
*
* Two ints answer an int; anything else numeric answers a float. The promotion

View File

@ -145,6 +145,12 @@ uint8_t flan_dyn_need_bool(flan_dyn v);
int32_t flan_dyn_is_nil(flan_dyn v);
flan_dyn flan_dyn_need_not_nil(flan_dyn v);
/* Truthiness for a dyn used where a typed value would need a strict bool —
* an [if]'s condition when the scrutinee's own type is dyn. Clojure's rule:
* nil and false are falsey, every other value is truthy, including 0, 0.0,
* "", an empty vec, an empty map, and any keyword. Never traps. */
uint8_t flan_dyn_truthy(flan_dyn v);
/* ── The collector ─────────────────────────────────────────────────────
*
* Mark-sweep, precise, and never moving. [flan_gc_init] is idempotent, and the

View File

@ -340,6 +340,20 @@ static void ops(void) {
check(flan_dyn_need_bool(flan_dyn_from_bool(1)) == 1, "need-bool true");
check(flan_dyn_need_bool(flan_dyn_from_bool(0)) == 0, "need-bool false");
/* Truthiness: only nil and false are falsey. Everything else — including
the values C or Python would call falsey is truthy. */
check(flan_dyn_truthy(flan_dyn_nil()) == 0, "truthy nil");
check(flan_dyn_truthy(flan_dyn_from_bool(0)) == 0, "truthy false");
check(flan_dyn_truthy(flan_dyn_from_bool(1)) == 1, "truthy true");
check(flan_dyn_truthy(flan_dyn_from_i64(0)) == 1, "truthy 0");
check(flan_dyn_truthy(flan_dyn_from_i64(5)) == 1, "truthy nonzero int");
check(flan_dyn_truthy(flan_dyn_from_f64(0.0)) == 1, "truthy 0.0");
check(flan_dyn_truthy(text("")) == 1, "truthy empty string");
check(flan_dyn_truthy(text("x")) == 1, "truthy nonempty string");
check(flan_dyn_truthy(flan_dyn_kw((const uint8_t *)"k", 1)) == 1, "truthy keyword");
check(flan_dyn_truthy(flan_dyn_vec_new()) == 1, "truthy empty vec");
check(flan_dyn_truthy(flan_dyn_map_new()) == 1, "truthy empty map");
s = text("kept");
w = v;
flan_dyn_root_pop(6);

View File

@ -0,0 +1,97 @@
;;;; M2 queue item 7: dyn if tests a scrutinee's truthiness rather than
;;;; requiring a strict bool, when the scrutinee's own type is dyn. Clojure's
;;;; rule, not C's or Python's: nil and false are the only falsey values, and
;;;; everything else -- 0, "", an empty vec, an empty map, a keyword -- is
;;;; truthy.
;;;;
;;;; The rule reaches every form built out of [if] under the hood -- [when],
;;;; [cond], [and] and [or] all desugar to it in parse.ml -- so their *tests*
;;;; need no separate case in check.ml and get exercised below through their
;;;; own syntax rather than by inspecting the desugaring. [and]'s *answer*
;;;; carries a non-bool dyn value through too, Clojure-style, because its
;;;; short-circuit sentinel is the else arm and the real value's type wins.
;;;; [or]'s sentinel is the then arm instead, so it is the one that decides
;;;; the whole expression's type when nothing else does, and [or]'s answer
;;;; stays a strict bool -- a pre-existing asymmetry (parse.ml, shortcircuit)
;;;; this item leaves alone; only [or]'s tests are exercised here, not a
;;;; non-bool value in its answer position. [not] and [while] are not [if]
;;;; in disguise, so check_truthy is called at their own sites by hand, and
;;;; get their own coverage too.
;;;;
;;;; A typed if keeps needing a strict bool -- that refusal, and its message,
;;;; is a checker test in test_flan.ml, not a row here, since a program that
;;;; gave a typed if a non-bool scrutinee would not compile.
;; An unannotated parameter is always dyn, so calling this on a literal is
;; what boxes it -- the same way an argument to a dyn-typed parameter always
;; does. Used below wherever a literal has to reach a boolean position as a
;; genuine dyn value rather than as the typed value it would otherwise default
;; to (a bare 0 is an i32 until something wants it as dyn).
(defn box [x] dyn x)
(defn truthy? [x] dyn (if x "truthy" "falsey"))
(defn main [] i32
;; nil and false: the only two falsey dyn values. Everything else Clojure
;; calls truthy that C or Python would not: 0, "", an empty vec, an empty
;; map, a keyword.
(println (truthy? nil))
(println (truthy? false))
(println (truthy? true))
(println (truthy? 0))
(println (truthy? 7))
(println (truthy? ""))
(println (truthy? "x"))
(println (truthy? (vec-new dyn)))
(let [xs (vec-new dyn)]
(push xs 1)
(println (truthy? xs)))
(let [m {}]
(println (truthy? m)))
(println (truthy? {:a 1}))
(println (truthy? :kw))
;; when: sugar for a one-armed if, so nil/false skip the body and every
;; other dyn value -- 0 and "" included -- runs it.
(when (box nil) (println "when nil ran"))
(when (box false) (println "when false ran"))
(when (box 0) (println "when 0 ran"))
(when (box "") (println "when empty-string ran"))
;; cond: each test is an if in a chain, so the same rule applies clause by
;; clause -- a boxed 0 falls through to its body just like a boxed "x".
(println (cond (box nil) "a" (box 0) "b" :else "c"))
(println (cond (box false) "a" (box "x") "b" :else "c"))
;; and/or: also if in disguise, so each test along the chain is
;; truthy-tested the same way if's own is -- 0 and "" do not stop and,
;; only nil and false do; 0 does stop or, the way any truthy value does.
;; and's answer is the last truthy operand itself (:kw here), Clojure's
;; and. or's answer stays a plain bool -- "true" below is or's own
;; sentinel, not the operand that made it truthy; that asymmetry is
;; parse.ml's, not this item's to close.
(println (and (box 1) (box "") (box :kw)))
(println (and (box 1) (box false) (box "unreached")))
(println (or (box 0) (box false)))
(println (or (box nil) (box false)))
;; not: truthiness, negated -- true only for nil and false.
(println (not (box nil)))
(println (not (box false)))
(println (not (box 0)))
(println (not (box "")))
(println (not (box true)))
;; while: the loop condition is a truthiness test the same way if's is. A
;; dyn vec ending in nil stops the loop; the 0 and "" along the way, if any
;; were there, would not.
(let [n (vec-new dyn)]
(push n 3)
(push n 2)
(push n 1)
(push n nil)
(let [i 0]
(while (at n i)
(println (at n i))
(set i (+ i 1)))))
0)

View File

@ -1248,6 +1248,26 @@ let () =
string_eq_out;
outputs ~x86:true "string equality, --x86" "programs/string-eq.flan"
string_eq_out;
(* dyn if: truthiness -- M2 queue item 7. A dyn scrutinee is tested for
nil/false vs. everything else, Clojure's rule, on both backends; a
typed scrutinee stays strictly bool, which is a checker test
(test_flan.ml) and not a row here since a typed if given a non-bool
scrutinee would not compile. [when], [cond] and [and]'s and [or]'s
*tests* ride along for free because they desugar to [if] in
parse.ml; [not] and [while] get the same treatment by hand in
check.ml's check_truthy. [and]'s answer carries a non-bool dyn value
through, Clojure-style; [or]'s answer stays a plain bool, a
pre-existing asymmetry in its desugaring this item leaves alone. *)
let dyn_if_truthy_out =
"falsey\nfalsey\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\ntruthy\n\
truthy\ntruthy\ntruthy\nwhen 0 ran\nwhen empty-string ran\nb\nb\n:kw\n\
false\ntrue\nfalse\ntrue\ntrue\nfalse\nfalse\nfalse\n3\n2\n1\n"
in
outputs "dyn if truthiness" "programs/dyn-if-truthy.flan" dyn_if_truthy_out;
outputs ~opt:"-O0" "dyn if truthiness, -O0" "programs/dyn-if-truthy.flan"
dyn_if_truthy_out;
outputs ~x86:true "dyn if truthiness, --x86" "programs/dyn-if-truthy.flan"
dyn_if_truthy_out;
(* format-f64, the first number formatter a caller can steer. The three
lines that would ship wrong are pinned deliberately: 0.999995 at five
places, where the rounded fraction equals the scale and is the next

View File

@ -835,6 +835,47 @@ let () =
rejects_check "if branches disagree"
"(defn f [] i32 (if true 1 true))" ~needle:"expected i32";
(* M2 queue item 7: a dyn if's scrutinee is truthiness-tested (Clojure's
rule -- nil and false are the only falsey values); a typed if keeps
needing a strict bool, exactly as before. The runtime survey is
programs/dyn-if-truthy.flan; these three pin the checker's own half. *)
accepts "typed if still takes a bare bool" "(defn f [] i32 (if true 1 2))";
rejects_check "typed if still refuses a non-bool scrutinee"
"(defn f [] i32 (if 1 1 2))"
~needle:"expected bool, found the integer literal 1";
(* Not just refused -- refused with the exact sentence a typed if has
always given here. check_truthy's dyn-or-bool check runs first, but a
value that is neither is re-checked with the old want:Bool so the
message a literal gets is the one it names itself with, not the type
it silently defaulted to along the way. *)
rejects_check "typed if still refuses None with its own message"
"(defn f [] i32 (if None 1 2))" ~needle:"expected bool, found None";
(* None is the other shape of "asked the wrong question first": checked
with no expectation at all it has no answer -- "nothing here says what
None is an Option of" -- rather than a wrong one, so check_truthy's
first attempt *raises* here instead of returning some non-bool,
non-dyn type. The re-check has to run on that path too. *)
accepts "dyn if accepts a non-bool dyn scrutinee"
"(defn f [x] dyn (if x 1 2))";
(* not, while: not [if] under the hood, so check_truthy is reached at
their own call sites (check.ml) rather than for free through
desugaring -- both take a non-bool dyn condition too. *)
accepts "dyn not accepts a non-bool dyn argument"
"(defn f [x] dyn (not x))";
accepts "dyn while accepts a non-bool dyn condition"
"(defn f [x] () (let [y x] (while y (set y false))))";
(* when, cond, and, or: sugar built out of [Ast.If] in parse.ml, so a
non-bool dyn condition reaches them with no separate check.ml case --
confirmed here rather than assumed. *)
accepts "dyn when accepts a non-bool dyn condition"
"(defn f [x] () (when x 0))";
accepts "dyn cond accepts a non-bool dyn condition"
"(defn f [x] dyn (cond x 1 :else 2))";
accepts "dyn and accepts a non-bool dyn operand"
"(defn f [x] i32 (let [y x] (if (and y true) 0 1)))";
accepts "dyn or accepts a non-bool dyn operand"
"(defn f [x] i32 (let [y x] (if (or y true) 0 1)))";
(* ── Unknown types ─────────────────────────────────────────────── *)
(* A lowercase name is a type variable (plan.org, Types), so a mistyped
primitive would otherwise be reported as unimplemented generics and send