diff --git a/FIX.org b/FIX.org index b379cb7..8ec7dab 100644 --- a/FIX.org +++ b/FIX.org @@ -436,7 +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. + 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). 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. diff --git a/lib/check.ml b/lib/check.ml index df6400b..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 ctx ~want:Types.Bool 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 @@ -3174,8 +3174,95 @@ 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 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 + 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. + + [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 ]) + | 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 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 +4300,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 (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..aab0b9e 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -881,6 +881,25 @@ 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, 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 @@ -888,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/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..1d0d34e --- /dev/null +++ b/test/programs/dyn-if-truthy.flan @@ -0,0 +1,103 @@ +;;;; 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] 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 +;;;; 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. + ;; 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))) + (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..45216d4 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -1248,6 +1248,30 @@ 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 [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\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" + 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..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 ──────────── *) @@ -835,6 +840,59 @@ 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)))"; + (* 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 primitive would otherwise be reported as unimplemented generics and send