From 264765a6a5c13a45ff47aa8818b4257ab41957ba Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 07:20:32 +0700 Subject: [PATCH 1/4] =?UTF-8?q?Dyn=20if=20tests=20truthiness=20=E2=80=94?= =?UTF-8?q?=20M2=20queue=20item=207?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/check.ml | 42 +++++++++++++- lib/emit.ml | 1 + lib/parse.ml | 12 ++++ runtime/flan_dyn.c | 10 ++++ runtime/flan_dyn.h | 6 ++ test/dyn_ops.c | 14 +++++ test/programs/dyn-if-truthy.flan | 97 ++++++++++++++++++++++++++++++++ test/test_acceptance.ml | 20 +++++++ test/test_flan.ml | 41 ++++++++++++++ 9 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 test/programs/dyn-if-truthy.flan diff --git a/lib/check.ml b/lib/check.ml index df6400b..d62177a 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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" -> diff --git a/lib/emit.ml b/lib/emit.ml index 049fbc4..08afc7c 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -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) diff --git a/lib/parse.ml b/lib/parse.ml index 670b660..d2bf07b 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -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 diff --git a/runtime/flan_dyn.c b/runtime/flan_dyn.c index 2594ed2..0aadc59 100644 --- a/runtime/flan_dyn.c +++ b/runtime/flan_dyn.c @@ -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 diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h index 60c7208..390599a 100644 --- a/runtime/flan_dyn.h +++ b/runtime/flan_dyn.h @@ -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 diff --git a/test/dyn_ops.c b/test/dyn_ops.c index a9a1749..2c0360a 100644 --- a/test/dyn_ops.c +++ b/test/dyn_ops.c @@ -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); diff --git a/test/programs/dyn-if-truthy.flan b/test/programs/dyn-if-truthy.flan new file mode 100644 index 0000000..b7b9bc0 --- /dev/null +++ b/test/programs/dyn-if-truthy.flan @@ -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) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 778396c..6af66f3 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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 diff --git a/test/test_flan.ml b/test/test_flan.ml index d9174ec..0ac4409 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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 From d7070501e4e568b1e9969f3005e6362d01bc163a Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 07:20:53 +0700 Subject: [PATCH 2/4] The queue marks item 7 landed, and names the or asymmetry it turned up dyn if's truthiness rule reached when, cond, and, or, not and while through one checker funnel, and turned up something nobody had decided: and's short-circuit answer already carries a non-bool dyn value through, Clojure-style, but or's does not, because its sentinel occupies the arm check_if types first. Written down here rather than fixed, since the queue's items get decided one at a time and this one is not yet. --- FIX.org | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/FIX.org b/FIX.org index b379cb7..9cf7bcc 100644 --- a/FIX.org +++ b/FIX.org @@ -436,7 +436,18 @@ rename. typed-flan branch freezes the static language pre-dyn. 6. defclass = named dyn map + shape tag; CLOS class dispatch AND Clojure-style arbitrary dispatch functions. After 1. 7. dyn if: truthiness (nil/false are false, all else true). Typed stays - strict bool. + strict bool. — LANDED, a94efcc + + Reaches when, cond, if's own condition, and's condition, or's + condition, not and while for free or by hand, all through one funnel + in check.ml (check_truthy). One thing fell out of it that nobody had + decided: and's answer position already carries a non-bool dyn value + through, 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 what decides the whole expression's type, and or's + answer stays a strict bool. Reordering or to match and is a real fix + and nobody's called it yet — left alone, noted in parse.ml at + shortcircuit. 8. Return slot stays mandatory (dyn or ()) — the parse ambiguity it closes is real; revisit only if it grates. SETTLED 2026-09-19, reconfirmed with the author: both spellings stay legal, () is not collapsing into dyn. From ad0f1fbd6a8aa1d0a97e50ece53dabb1ef19ea20 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 09:56:34 +0700 Subject: [PATCH 3/4] or hands back its deciding operand instead of a bare bool, review pass on item 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dyn if truthiness review turned up that or's answer position, unlike and's, still traps on a non-bool dyn value: or's short-circuit sentinel sat in the then arm of its own if, the one check_if types first, so that sentinel decided the whole expression's type and a later non-bool dyn answer hit the strict bool boundary and unboxed itself into a trap rather than surviving as itself. (or nil "x") — the canonical Clojure (or x default) idiom — crashed instead of answering "x", identical on all three backends. or now binds its test to a temp and answers the temp itself, exactly the way Clojure's own or macro expands: (or a b) becomes (let [t a] (if t t b)), not (if a true b). The temp evaluates a once and lets the answer be a without writing it a second time as the then arm; it is the temp's own type check_if sees first, so or hands back the actual truthy operand the same way and always has. Verified real output, unchanged, on LLVM, -O0 and --x86, and the survey program now exercises the case its own header used to exclude for being unsafe: a non-bool value stopping or and being handed back as-is. check_truthy also gets three corrections a closer look found. Its own [loc] used to come from the enclosing if/while/not rather than from the condition itself, so the rt call and cast it builds carried the wrong column in an --x86 disassembly or the dev inspector whenever the condition was not the form's first token; it now takes loc from the scrutinee's own AST node, confirmed against a real --x86 dump. A comment now names the precondition its exception-swallowing retry rests on: none of check.ml's save-restore sites (barrier, in_frames, in_defer, loops, scope) are exception-safe, which is harmless only because the retry always either succeeds cleanly or re-raises and aborts the compile before ctx is read again — and would stop being harmless the day some want-sensitive elaboration on this path could succeed differently on retry. And a bare keyword condition, which used to be checked with want:Bool from the start and refused by the keyword arm's enum-or-refuse case, now resolves as the dyn keyword instead and is unconditionally truthy — a deliberate loss of that diagnostic, the author's call, pinned in test_flan.ml so it does not regress by accident. The two typed-refusal messages captured before this pass (a float literal condition, an i32 while condition) are unchanged, checked again against the same baseline. test_flan.ml's parser test for or's shape is updated to match the new let-bound desugaring. --- lib/check.ml | 69 ++++++++++++++++++++++++++++---- lib/parse.ml | 35 ++++++++++------ test/programs/dyn-if-truthy.flan | 28 ++++++++----- test/test_acceptance.ml | 18 +++++---- test/test_flan.ml | 19 ++++++++- 5 files changed, 130 insertions(+), 39 deletions(-) diff --git a/lib/check.ml b/lib/check.ml index d62177a..788f917 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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_truthy ctx loc c in + let c = check_truthy ctx c in let body = in_loop ctx ?label (fun () -> scoped ctx (fun () -> map_lr (fun b -> check ctx b) body)) in @@ -3177,9 +3177,10 @@ and check_recur ctx ~tail loc args = (* 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.) + without a separate case. [and] and [or] are sugar too, and both their + tests and their answers get this for free the same way — see + [shortcircuit] in parse.ml for how [or] carries its deciding operand + through, the same way [and] always has.) 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 @@ -3199,8 +3200,60 @@ and check_recur ctx ~tail loc args = 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 = + given. + + [loc] comes from [c] itself, not from the caller's [if]/[while]/[not] — + the built-in rt call and cast have to sit at the condition's own position + or an --x86 disassembly and the dev inspector point at the wrong column + when the two differ (an [if] whose test is not its first token). + + The [exception Loc.Error _] arm swallows a rejection from arbitrary depth + inside [c] and re-runs the whole of [check ctx c] a second time, which is + sound only because every one of [ctx]'s save-restore sites — [barrier], + and the [in_frames]/[in_defer]/[loops]/[scope] plumbing [check] itself + uses — is not exception-safe: a failure mid-walk can leave one of those + pushed without its pop. Today that is harmless, because the second call + always either succeeds outright or raises again and this function's own + caller then aborts the compile — nothing downstream ever reads [ctx] + again on that path. It would stop being harmless the day some later + want-sensitive elaboration on this path can *succeed* by yielding a + concrete [Bool] on retry rather than failing a second time: then the + first, swallowed pass's half-restored state and any name or slot it + registered before raising would both still be live. + + That same retry-on-failure is also, deliberately, not made cheaper by + only retrying at the leaf that actually needs a nicer message (an int or + float literal, or [None]) and re-raising everywhere else: the shorter + path was tried and shelved, because "everywhere else" is not safe to + generalise past [not]'s one level of nesting — a condition that is + itself a compound form carrying its own literals arbitrarily deep (an + [if] or [let] standing where a condition is expected) would need [want] + threaded through exactly as far as this function's own second call + already threads it, and stopping short changes which of *those* + literals gets the nicer message, not just the speed. The cost that + buys is real: nested [not] on a program that does not type-check re-runs + this whole function once per level of nesting inside the level above it, + which is exponential in how deep the nesting goes — moot for a program + that compiles, since neither retry ever fires, and moot for ordinary + nesting depths, but visible within a second or so around twenty levels + of a [not] wrapped in a [not] wrapped in .... The dev daemon is the one + caller that could feel this, recompiling a half-typed form on every + edit; nobody has hit it in practice and it is not fixed here. + + Keywords are a separate, deliberate loss rather than a bug: a bare + [:kw] used to be checked here with [want:Types.Bool] from the start, so + it hit the keyword arm's [Some other] case and refused by name — "is an + enum member where an enum is expected and a dyn keyword elsewhere, but + bool is expected here". Checking it here with no expectation first, as + every other scrutinee now is, resolves it as the dyn keyword instead + (there being no enum in play), and dyn keywords are unconditionally + truthy — so [(if :kw a b)] now takes [a], where it used to refuse + outright. The author's call: lispy truthiness wins wherever it can, so + this refusal is given up on purpose and not special-cased back in; + test_flan.ml pins the new answer down so it is not lost again by + accident. *) +and check_truthy ctx c = + let loc = c.Ast.loc in 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 ]) @@ -3209,7 +3262,7 @@ and check_truthy ctx loc c = | exception Loc.Error _ -> check ctx ~want:Types.Bool c and check_if ctx ?(tail = false) ?want loc c t e = - let c = check_truthy ctx loc c in + let c = check_truthy ctx 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. *) @@ -4249,7 +4302,7 @@ and named_call ctx ~want loc name args = arity loc name 1 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) ] + prim Tast.Not Types.Bool [ check_truthy ctx (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" -> diff --git a/lib/parse.ml b/lib/parse.ml index d2bf07b..aab0b9e 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -882,17 +882,24 @@ and cond f (args : Form.t list) : Ast.expr = 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. *) + (check.ml's check_truthy) exactly the way a bare [if]'s is, for both + [and] and [or]. The *answer* used to be asymmetric between them: [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 lets [and] hand + back the actual last dyn value, Clojure-style. [or] used to put its + "true" sentinel in the then arm instead — the one check_if types first — + so that sentinel decided the whole expression's type, and a later + non-bool dyn answer hit the strict bool boundary instead of surviving as + itself: (or nil "x") traps rather than answering "x", exactly the + canonical (or x default) idiom Clojure is reached for. + + [or] now binds its test to a temp and asks the temp itself, the way + Clojure's own or expands: [(or a b)] is [(let [t a] (if t t b))], not + [(if a true b)]. The temp is what lets the answer be [a] itself without + writing [a] a second time as the then arm — [(if a a b)] would evaluate + it twice, once for the test and again for the answer — and it is the + temp's own type check_if sees first, so [or] hands back the actual + truthy value the same way [and] hands back its own. *) and shortcircuit f (args : Form.t list) ~is_and : Ast.expr = let mk e = { Ast.e; loc = f.loc } in let rec go = function @@ -900,7 +907,11 @@ and shortcircuit f (args : Form.t list) ~is_and : Ast.expr = | [ last ] -> expr last | x :: rest -> if is_and then mk (Ast.If (expr x, go rest, Some (mk (Ast.Var "false")))) - else mk (Ast.If (expr x, mk (Ast.Var "true"), Some (go rest))) + else + let t = fresh_temp () in + let tvar = mk (Ast.Var t) in + let bind = { Ast.bname = t; bty = None; bval = expr x; bloc = f.loc } in + mk (Ast.Let ([ bind ], [ mk (Ast.If (tvar, tvar, Some (go rest))) ])) in go args diff --git a/test/programs/dyn-if-truthy.flan b/test/programs/dyn-if-truthy.flan index b7b9bc0..1d0d34e 100644 --- a/test/programs/dyn-if-truthy.flan +++ b/test/programs/dyn-if-truthy.flan @@ -10,13 +10,15 @@ ;;;; 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. +;;;; [or] used to put its sentinel in the then arm instead, so a non-bool +;;;; dyn answer hit the strict bool boundary and traps -- (or nil "x"), the +;;;; canonical Clojure (or x default) idiom, used to crash. [or] now binds +;;;; its test to a temp and answers the temp itself, Clojure's own +;;;; expansion, so its answer carries a non-bool dyn value through exactly +;;;; the way [and]'s does; both are exercised below, including the case +;;;; that used to be excluded here for being unsafe. [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 @@ -66,14 +68,18 @@ ;; 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. + ;; Both hand back the actual operand that decided them, Clojure-style -- + ;; and's answer is the last truthy operand itself (:kw here); or's is the + ;; first truthy one (0, then "x") rather than a bare true. (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))) + ;; The case excluded before the fix: a non-bool value stopping or and + ;; being handed back as-is -- the canonical (or x default) idiom, which + ;; used to trap trying to unbox "x" as a strict bool. + (println (or (box nil) (box "x"))) + (println (or (box 5) (box "unreached"))) ;; not: truthiness, negated -- true only for nil and false. (println (not (box nil))) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 6af66f3..45216d4 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -1252,16 +1252,20 @@ let () = 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. *) + scrutinee would not compile. [when], [cond], [and] and [or] ride + along for free because their tests desugar to [if] in parse.ml; + [not] and [while] get the same treatment by hand in check.ml's + check_truthy. [and] and [or] both hand back the actual operand that + decided them, Clojure-style -- [or] used to answer a bare bool + instead, its short-circuit sentinel sitting in the arm check_if + types first, and a non-bool dyn value reaching that position (the + canonical (or x default) idiom) used to trap rather than survive as + itself; [or] now binds its test to a temp and answers the temp, + fixing that. *) 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" + false\n0\nfalse\nx\n5\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" diff --git a/test/test_flan.ml b/test/test_flan.ml index 0ac4409..51bedcb 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -354,8 +354,13 @@ let () = (match (parse1 "(and a b)").e with | If (_, _, Some { e = Var "false"; _ }) -> () | _ -> check "and short-circuits" false); + (* or binds its test to a temp and answers the temp itself -- Clojure's + own expansion, and what lets or hand back the actual truthy operand + rather than a bare true (M2 queue item 7's review pass). *) (match (parse1 "(or a b)").e with - | If (_, { e = Var "true"; _ }, Some _) -> () + | Let ([ _ ], + [ { e = If ({ e = Var t1; _ }, { e = Var t2; _ }, Some _); _ } ]) + when t1 = t2 -> () | _ -> check "or short-circuits" false); (* ── Forms that bind or alter control are never calls ──────────── *) @@ -875,6 +880,18 @@ let () = "(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)))"; + (* A bare keyword condition used to be checked with want:Bool from the + start, landing on the keyword arm's enum-or-refuse case and refusing + by name -- ":kw is an enum member where an enum is expected ... but + bool is expected here", since there was no enum in play. Checked with + no expectation first, as every scrutinee now is, it resolves as the + dyn keyword instead, and a dyn keyword is unconditionally truthy: a + typed if with a bare keyword condition now compiles, and always takes + the then branch. The author's call, recorded at check_truthy: lispy + truthiness wins here, the lost diagnostic is not brought back, and + this pins the new answer down so it does not regress by accident. *) + accepts "a typed if with a bare keyword condition now compiles" + "(defn f [] i32 (if :kw 1 2))"; (* ── Unknown types ─────────────────────────────────────────────── *) (* A lowercase name is a type variable (plan.org, Types), so a mistyped From b5f17826fe0c13db0da6a0c76833ecfd5ed67c07 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 09:56:53 +0700 Subject: [PATCH 4/4] The queue records item 7's review pass: the or fix, and two things left alone FIX.org's item 7 gets the full account of the review that followed landing: or's fix (ad0f1fb), named there rather than left as "review pass"; the keyword-condition diagnostic given up on purpose, the author's call; and the exponential retry on a chain of nested not that does not type-check, looked at and left alone since a cheaper retry would cost message fidelity on a compound condition wrapping a literal, not just speed. --- FIX.org | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/FIX.org b/FIX.org index 9cf7bcc..8ec7dab 100644 --- a/FIX.org +++ b/FIX.org @@ -436,18 +436,46 @@ rename. typed-flan branch freezes the static language pre-dyn. 6. defclass = named dyn map + shape tag; CLOS class dispatch AND Clojure-style arbitrary dispatch functions. After 1. 7. dyn if: truthiness (nil/false are false, all else true). Typed stays - strict bool. — LANDED, a94efcc + strict bool. — LANDED, 264765a Reaches when, cond, if's own condition, and's condition, or's condition, not and while for free or by hand, all through one funnel - in check.ml (check_truthy). One thing fell out of it that nobody had - decided: and's answer position already carries a non-bool dyn value - through, 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 what decides the whole expression's type, and or's - answer stays a strict bool. Reordering or to match and is a real fix - and nobody's called it yet — left alone, noted in parse.ml at - shortcircuit. + in check.ml (check_truthy). Two things fell out of it that nobody had + decided going in, one fixed on review and one left as the author's + call: + + - or's answer used to stay a strict bool where and's already carried a + non-bool dyn value through, Clojure-style — and's short-circuit + sentinel sits in the else arm, so the real value's type wins there, + but or's sat in the then arm, the one check_if types first, so it + decided the whole expression's type and a later non-bool dyn answer + hit the strict bool boundary and trapped. (or nil "x"), the + canonical (or x default) idiom, crashed rather than answering "x". + FIXED, ad0f1fb: or now binds its test to a temp and answers the + temp, Clojure's own expansion, evaluating the test once and handing + back whichever operand actually decided it. + - A bare keyword condition used to be checked with want:Bool from the + start and refused by the keyword arm's enum-or-refuse case: ":kw is + an enum member where an enum is expected and a dyn keyword + elsewhere, but bool is expected here", there being no enum in play. + Checked with no expectation first, as every scrutinee now is, it + resolves as the dyn keyword instead, and a dyn keyword is + unconditionally truthy — a typed if with a bare keyword condition + now compiles and always takes the then branch. The author's call: + lispy truthiness wins here, the lost diagnostic is not brought back. + Pinned in test_flan.ml so it does not regress by accident. + + Also noted at check_truthy (check.ml) and not acted on: check_truthy's + own retry-on-failure, needed to keep a refused literal's or None's + message unchanged, re-runs the whole failing subtree rather than only + the leaf that needs it, which is exponential in how deep a chain of + nested not gets on a program that does not type-check. Moot for + anything that compiles; visible only around twenty levels deep, and + only the dev daemon's half-typed-form recompiles could ever feel it. + A cheaper retry was tried and shelved — it would need to thread want + exactly as far as the full retry already does, or it changes which + literal further inside a compound condition gets the nicer message, + not just the speed. 8. Return slot stays mandatory (dyn or ()) — the parse ambiguity it closes is real; revisit only if it grates. SETTLED 2026-09-19, reconfirmed with the author: both spellings stay legal, () is not collapsing into dyn.