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