diff --git a/FIX.org b/FIX.org index dbc5214..3c704b7 100644 --- a/FIX.org +++ b/FIX.org @@ -4462,3 +4462,36 @@ sand-dependent tests (test_flan's parse pin, test_session's create, test_acceptance's "a package's main is not visible") are red here and turn green when those seven lines say ~defonce~ (or ~def~, where the author wants the initialiser to follow the source — ~colors~ was the motivating one). +* dotimes counts, 2026-09-21 +"Is there a way to do dotimes or a loop in reverse?" — the answer was a +hand-written let plus set, which is the wrong answer for the commonest loop +there is after counting up. + +Ruled: dotimes grows the start/stop/step arities, the way CL's loop and +Clojure's range have them. + + (dotimes [i n]) ; 0 .. n-1, unchanged + (dotimes [i start stop]) ; start .. stop-1 + (dotimes [i start stop step]) ; start, start+step, ... while short of stop + +stop is exclusive in every arity, so (dotimes [i 0 n]) is (dotimes [i n]) — +one rule, not two — and a negative step counts down, testing with > instead +of <. (dotimes [i 9 -1 -1]) is 9 down to 0. + +The two edges, decided: a literal step of 0 is refused at compile time, being +an infinite loop spelled as an accident; a step that is only a value cannot +be refused there, and the sign test that picks the loop's direction leaves 0 +with neither direction, so it runs no times at all. Terminating and +deterministic, and it costs nothing — a literal step still emits the one +comparison it always did. + +Still a special form desugaring in check.ml to a Let and a While, so neither +backend learned anything. test/programs/dotimes-range.flan is the corpus +program; docs/BUILT.md carries the convention. + +Four sites, all of them this feature and none of them a pre-existing bug: +parse.ml takes a vector of two to four, check.ml desugars it, and load.ml's +two walks — the Ast rename and the Form-level one at load.ml:503 — learn to +walk more than one bound. The Form walk matched Vec [n; count] exactly, so +it had to grow; before this, a three-bound dotimes was a parse error long +before that walk could see it, so nothing was ever miscompiled by it. diff --git a/docs/BUILT.md b/docs/BUILT.md index bc4af64..c66564e 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -12,7 +12,8 @@ first. Come here when you need to know why something is the shape it is. **`dotimes`** desugars in `check.ml` to a `Let` plus a `While` — no new IR node. The bound is evaluated once into a hidden slot before the loop, so a body that changes it cannot change the trip count, and the loop variable is not -assignable, which makes the generated step its only writer. +assignable, which makes the generated step its only writer. **Amended** by the start/stop/step arities; see +"`dotimes` counts from where you say" at the foot of this file. **`defer`** is recognised in `check_fn` and nowhere else, because that is the only place that knows a form is at the top level of a function body. Each one is checked in place, then registered on the context; it emits nothing where it @@ -6239,3 +6240,54 @@ One shared plan feeds both backends, so x86 and LLVM cannot disagree; `programs/ every `def` spelling's startup on both, at `-O0` and `-O2`, and `programs/dev-rerun.flan` pins the live loop — a `(def c 3)` printing 4 on every run beside a defonce that climbs, and the edited `(def c 9)` printing 10 after the next re-run. +## `dotimes` counts from where you say + +The author asked whether there was a way to run a `dotimes` in reverse, and the answer was a hand-written `let` plus +`set` — which is the wrong answer for the commonest loop there is after counting up. So `dotimes` grew the arities +Common Lisp's `loop` and Clojure's `range` have: + +```flan +(dotimes [i n]) ; 0 .. n-1, exactly as before +(dotimes [i start stop]) ; start .. stop-1 +(dotimes [i start stop step]) ; start, start+step, ... while it is still short of stop +``` + +**`stop` is exclusive in every arity, and a negative step counts down and tests with `>` instead of `<`** — so +`(dotimes [i 0 n] ...)` is the same loop as `(dotimes [i n] ...)`, one rule rather than two, and +`(dotimes [i 9 -1 -1] ...)` counts 9 down to 0. + +Still a special form, still `check_dotimes`, still a `Let` and a `While` with the step in the latch. Nothing new +reaches either backend: an `x86` build of `test/programs/dotimes-range.flan` prints what the LLVM build prints, at +`-O2` and at `-O0`. + +### Each bound once, and in the order written + +`start` is the counter's initial value in the `Let`, `stop` gets the hidden slot it always had, and a `step` gets one +too when it is not a literal. All three are checked — and therefore evaluated — left to right and outside the +counter's scope, so a body that assigns to whatever they were computed from cannot move them. `dotimes-range.flan` +proves it with a function that prints a marker per call: the three markers appear once each, in order, ahead of the +counter's own output. A literal `step` needs no slot to be evaluated once, which is why the one-bound form emits +exactly the frame and the comparison it emitted before. + +### The sign of the step, and the step of zero + +The comparison direction is the step's sign, and when the step is a literal the direction is known while checking: one +`Lt` or one `Gt`, no test, no cost. **A literal step of `0` is refused where it is written** — it is an infinite loop +spelled as an accident, and the message says to write a step that moves or leave it out for 1. + +A step that is only known at run time cannot be refused, so the condition asks the sign first: `step > 0` and the +counter short of `stop`, else `step < 0` and the counter past it, else stop. Written as nested `If`s, which is what +`and` and `or` already become. That shape decides the run-time zero for free — neither arm holds, so **a run-time step +of `0` runs the loop no times at all**. It is the one answer that is both deterministic and terminating; a trap would +need a check the literal case does not want, and looping for ever is the failure the refusal above exists to prevent. + +### Width, and overflow + +Every bound is an index, so every bound is `i32` — `check ~want:index_ty`, the same as the single bound has always +had. There is no width to join here and the repo's join rules do not come into it: a bound of another width is the +ordinary `expected i32, found i64`. + +Arithmetic wraps in Flan (`emit.ml`, no `nsw`/`nuw`), and the counter is arithmetic like any other. A step that +carries `i` past `i32`'s range therefore wraps to the far end instead of trapping — defined, but the loop then runs +far longer than it was meant to, and can fail to reach `stop` at all. Keep `stop` within one `step` of the width's +limit; nothing checks it for you. diff --git a/lib/ast.ml b/lib/ast.ml index 9fcea8d..c9cab78 100644 --- a/lib/ast.ml +++ b/lib/ast.ml @@ -116,7 +116,12 @@ and expr_kind = | ArrayGen of len list * expr (* These bind names or alter control flow, so none of them can be a call. *) | Fn of string list * expr list (* (fn [x y] ...) — non-escaping *) - | Dotimes of string option * string * expr * expr list (* (dotimes :o [i n] ...) *) + (* (dotimes :o [i n] ...), (dotimes [i start stop] ...) and + (dotimes [i start stop step] ...). The bounds are a record rather than + three positional fields because the one-bound form is the common one and + "which of these is the stop" should not be a counting exercise at every + site that walks them. *) + | Dotimes of string option * string * bounds * expr list | Defer of expr list (* runs on scope exit *) | Unwrap of unwrap * expr (* (some x) / (try x) *) (* (handler-bind [(Type [c] body ...) ...] body ...) — spec-conditions.md. @@ -136,6 +141,11 @@ and expr_kind = | RestartCase of expr * rclause list | InvokeRestart of string * expr list +(* A [dotimes]'s counting. [dstop] is always written; the other two have + defaults — 0 and 1 — and are [None] when the source left them out, which is + what lets the one-bound form desugar to exactly what it always did. *) +and bounds = { dstart : expr option; dstop : expr; dstep : expr option } + (* Two ways to signal, because they are two different things — §1 and §2. [signal] returns Unit whatever it finds; [error] has type Never and, with nothing transferring, the program stops. *) @@ -408,7 +418,12 @@ let map_children f (e : expr) : expr = | ArrayFill (ds, v) -> ArrayFill (ds, ex v) | ArrayGen (ds, f) -> ArrayGen (ds, ex f) | Fn (ps, es) -> Fn (ps, List.map ex es) - | Dotimes (l, n, c, es) -> Dotimes (l, n, ex c, List.map ex es) + | Dotimes (l, n, b, es) -> + Dotimes (l, n, + { dstart = Option.map ex b.dstart; + dstop = ex b.dstop; + dstep = Option.map ex b.dstep }, + List.map ex es) | Defer es -> Defer (List.map ex es) | Unwrap (u, x) -> Unwrap (u, ex x) | HandlerBind (cs, es) -> HandlerBind (List.map hcl cs, List.map ex es) diff --git a/lib/check.ml b/lib/check.ml index adf6a99..1dd2449 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -3144,8 +3144,8 @@ let rec check ctx ?want (e : Ast.expr) : Tast.expr = Option; this one returns %s" (Types.to_string other)) | Ast.Unwrap (Ast.Utry, _) -> unimplemented loc "try (Result)" 6 | Ast.Fn (params, body) -> check_fn ctx ~want loc params body - | Ast.Dotimes (label, name, count, body) -> - check_dotimes ctx ~want loc label name count body + | Ast.Dotimes (label, name, bounds, body) -> + check_dotimes ctx ~want loc label name bounds body (* (signal c) : Unit, always — spec-conditions.md §1. A handler that returns normally leaves the signalling function to carry on, and with nothing matching this is a no-op, so nothing about it alters control flow. That is @@ -4106,31 +4106,108 @@ and loop_target ctx loc verb label = in go 0 ctx.loops -and check_dotimes ctx ~want loc label name count body = - let count = check ctx ~want:index_ty count in +(* [(dotimes [i n] ...)], [(dotimes [i start stop] ...)] and + [(dotimes [i start stop step] ...)]. + + **The convention.** [stop] is exclusive, so [(dotimes [i 0 n] ...)] is the + same loop as [(dotimes [i n] ...)] — one rule rather than two, and the + shorter form stays the longer one with its defaults left off. A negative + step counts down and tests with [>] instead of [<], which is what makes + [(dotimes [i 9 -1 -1] ...)] run 9 down to 0. + + **Each bound once, before the loop.** [start] is the counter's initial + value, [stop] and a non-literal [step] each get a hidden slot, and all three + are checked — and therefore evaluated — left to right, outside the counter's + scope. A body that assigns to what they were computed from cannot change the + trip count. + + **The sign of the step.** When it is a literal the direction is known here + and the condition is the one comparison it always was, so nothing changed + for every loop anyone has written. A literal 0 is refused: it is an infinite + loop spelled as an accident. A step that is only known at run time gets a + condition that asks the sign first, and the cost lands on exactly the loops + that need it. A run-time 0 falls out of that test as a loop that runs no + times at all — neither arm of the sign test holds — which is deterministic + and terminating, the two things an accidental hang is not. *) +and check_dotimes ctx ~want loc label name (b : Ast.bounds) body = + (* Left to right, and all three before the counter is bound: they are + evaluated before it exists, so [(dotimes [i i (* outer i *)] ...)] reads + the outer name and the order a counter function sees is the written one. *) + let start = Option.map (fun e -> check ctx ~want:index_ty e) b.Ast.dstart in + let stop = check ctx ~want:index_ty b.Ast.dstop in + let step = Option.map (fun e -> check ctx ~want:index_ty e) b.Ast.dstep in + let int k = mk loc index_ty (Tast.Int (k, Types.I32)) in + (* A literal step, if that is what was written. The default is 1, which is a + literal too, so the one-bound form takes this path and emits exactly what + it has always emitted. *) + let literal = + match step with + | None -> Some 1L + | Some { Tast.e = Tast.Int (k, _); _ } -> Some k + | Some _ -> None + in + (match literal, step with + | Some 0L, Some s -> + fail s.Tast.loc + "a step of 0 never moves the counter, so this loop would never end. \ + Give it a step that moves, as in (dotimes [i 0 10 2] (print i)). \ + Left out, the step is 1" + | _ -> ()); scoped ctx (fun () -> let i = bind ctx name index_ty ~assignable:false in let limit = fresh_slot ctx index_ty in + (* A slot only when the step is not a literal: a literal needs no slot to + be evaluated once, and the one-bound form's frame keeps the shape it + had. *) + let stepslot = + match literal with None -> Some (fresh_slot ctx index_ty) | Some _ -> None + in let body = in_loop ctx ?label (fun () -> map_lr (fun b -> check ctx b) body) in let iv = mk loc index_ty (Tast.Local i) in - let one = mk loc index_ty (Tast.Int (1L, Types.I32)) in - let cond = - mk loc Types.Bool - (Tast.Prim (Tast.Lt, [ iv; mk loc index_ty (Tast.Local limit) ])) + let limitv = mk loc index_ty (Tast.Local limit) in + let stepv = + match literal, stepslot with + | Some k, _ -> int k + | None, Some s -> mk loc index_ty (Tast.Local s) + | None, None -> assert false in - let step = + let cmp op = mk loc Types.Bool (Tast.Prim (op, [ iv; limitv ])) in + let cond = + match literal with + | Some k when k > 0L -> cmp Tast.Lt + | Some _ -> cmp Tast.Gt + | None -> + (* Both directions, asked in the order that leaves 0 with neither: the + counter has not passed the stop *and* the step is going that way. + Written as nested [If]s because that is what [and] and [or] already + become, so nothing new reaches a backend. *) + let sign op = + mk loc Types.Bool (Tast.Prim (op, [ stepv; int 0L ])) + in + mk loc Types.Bool + (Tast.If (sign Tast.Gt, cmp Tast.Lt, + mk loc Types.Bool + (Tast.If (sign Tast.Lt, cmp Tast.Gt, + mk loc Types.Bool (Tast.Bool false))))) + in + let advance = mk loc Types.Unit (Tast.Set (Tast.Plocal i, - mk loc index_ty (Tast.Prim (Tast.Add, [ iv; one ])))) + mk loc index_ty (Tast.Prim (Tast.Add, [ iv; stepv ])))) in - let zero = mk loc index_ty (Tast.Int (0L, Types.I32)) in (* The step is the *latch* and not the last form of the body. Folded onto the body it would be skipped by a [continue], which branches past the rest of the body — so [i] would never advance and the loop would hang. That is the whole reason [Tast.While] carries a third list. *) - let loop = mk loc Types.Unit (Tast.While (cond, body, [ step ])) in - expect ctx loc ~want - (mk loc Types.Unit (Tast.Let ([ (i, zero); (limit, count) ], [ loop ])))) + let loop = mk loc Types.Unit (Tast.While (cond, body, [ advance ])) in + let binds = + (i, match start with Some s -> s | None -> int 0L) + :: (limit, stop) + :: (match stepslot, step with + | Some s, Some v -> [ (s, v) ] + | _ -> []) + in + expect ctx loc ~want (mk loc Types.Unit (Tast.Let (binds, [ loop ])))) (* ── (loop [...] ...) and (recur ...) ─────────────────────────────────── diff --git a/lib/load.ml b/lib/load.ml index 67e5dca..c488e2d 100644 --- a/lib/load.ml +++ b/lib/load.ml @@ -314,8 +314,14 @@ let rec rename_expr owned alias bound (e : Ast.expr) : Ast.expr = | Ast.ArrayGen (ds, v) -> Ast.ArrayGen (List.map (rename_len owned alias) ds, go v) | Ast.Fn (ps, body) -> Ast.Fn (ps, List.map (rename_expr owned alias (ps @ bound)) body) - | Ast.Dotimes (l, i, n, body) -> - Ast.Dotimes (l, i, go n, + | Ast.Dotimes (l, i, b, body) -> + (* The bounds are outside the counter's scope — they are evaluated before + it exists — so they rename against [bound], and only the body gets + [i] added to it. *) + Ast.Dotimes (l, i, + { Ast.dstart = Option.map go b.Ast.dstart; + dstop = go b.Ast.dstop; + dstep = Option.map go b.Ast.dstep }, List.map (rename_expr owned alias (i :: bound)) body) | Ast.Defer body -> Ast.Defer (gos body) | Ast.Unwrap (u, v) -> Ast.Unwrap (u, go v) @@ -616,10 +622,13 @@ let rec rename_form owned alias bound (f : Form.t) : Form.t = keep (Form.List (hd :: pv :: List.map (go bound) body)) | Form.List (({ Form.v = Form.Sym "dotimes"; _ } as hd) :: rest) -> (match peel_label rest with - | lbl, ({ Form.v = Form.Vec [ n; count ]; loc = bloc } :: body) -> + (* One, two or three bounds. They are evaluated before the counter + exists, so they walk under [bound]; only the body sees [n]. *) + | lbl, ({ Form.v = Form.Vec (n :: ((_ :: _) as bs)); loc = bloc } :: body) + when List.length bs <= 3 -> keep (Form.List (hd :: lbl - @ Form.make (Form.Vec [ n; go bound count ]) bloc + @ Form.make (Form.Vec (n :: List.map (go bound) bs)) bloc :: List.map (go (form_syms n bound)) body)) | _ -> keep (Form.List (hd :: List.map (go bound) rest))) | Form.List xs -> keep (Form.List (List.map (go bound) xs)) @@ -789,7 +798,9 @@ let rec expr_uses acc (e : Ast.expr) = ds; go v | Ast.Fn (_, body) -> gos body - | Ast.Dotimes (_, _, n, body) -> go n; gos body + | Ast.Dotimes (_, _, b, body) -> + Option.iter go b.Ast.dstart; go b.Ast.dstop; Option.iter go b.Ast.dstep; + gos body | Ast.Defer body -> gos body | Ast.Unwrap (_, v) -> go v | Ast.Signal (_, c) -> go c diff --git a/lib/parse.ml b/lib/parse.ml index 3501b7b..8dc6ad4 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -496,12 +496,30 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = mk (Ast.Fn (List.map sym ps, body_of body)) | _ -> fail f "fn is (fn [param ...] body ...)") + (* One, two or three bounds. The stop is always the last one written, so the + shorter forms are the longer one with its defaults left off: [start] is 0 + and [step] is 1. *) | Sym "dotimes" -> (match label args with - | lbl, ({ v = Vec [ n; count ]; _ } :: body) -> + | lbl, ({ v = Vec (n :: ((_ :: _) as bs)); _ } :: body) + when List.length bs <= 3 -> no_pattern n; - mk (Ast.Dotimes (lbl, sym n, expr count, body_of body)) - | _ -> fail f "dotimes is (dotimes [name count] body ...)") + let b = + match List.map expr bs with + | [ stop ] -> { Ast.dstart = None; dstop = stop; dstep = None } + | [ start; stop ] -> + { Ast.dstart = Some start; dstop = stop; dstep = None } + | [ start; stop; step ] -> + { Ast.dstart = Some start; dstop = stop; dstep = Some step } + | _ -> assert false + in + mk (Ast.Dotimes (lbl, sym n, b, body_of body)) + | _ -> + fail f + "dotimes is (dotimes [name stop] body ...), \ + (dotimes [name start stop] body ...) or \ + (dotimes [name start stop step] body ...) — stop is exclusive, and a \ + negative step counts down") (* [(loop [x 0 acc 1] body ...)]. No label: [break] and [continue] may not leave a loop — a loop answers with the value of its body, and a jump out diff --git a/test/programs/dotimes-range.flan b/test/programs/dotimes-range.flan new file mode 100644 index 0000000..f3344ab --- /dev/null +++ b/test/programs/dotimes-range.flan @@ -0,0 +1,126 @@ +;;;; dotimes with start, stop and step. +;;;; +;;;; stop is exclusive in every arity, so (dotimes [i 0 n]) is (dotimes [i n]), +;;;; and a negative step counts down and tests with > instead of <. What is +;;;; asserted here rather than in a unit test is what comes out: +;;;; +;;;; 1. Each bound is evaluated exactly once, before the loop, and left to +;;;; right. The counter function prints a marker per call, so a bound read +;;;; twice shows up as an extra marker and a bound read per iteration shows +;;;; up as many. +;;;; +;;;; 2. A body that assigns to what a bound was computed from cannot change the +;;;; trip count — the bounds are in hidden slots by then. +;;;; +;;;; 3. (continue) advances a *down*-counting loop too. The step is the latch +;;;; whichever way it goes, and folded onto the body this hangs rather than +;;;; printing the wrong thing, which is what the watchdog is for. +;;;; +;;;; 4. A step whose sign is only known at run time picks its direction at the +;;;; test, and a run-time step of 0 runs the loop no times at all. + +;; Prints its tag and answers with its value, so the output says how many times +;; and in what order each bound was evaluated. +(defn bump [tag i32 v i32] i32 + (print tag) + v) + +(defn main [] i32 + ;; ── the three arities, counting up ──────────────────────────────── + (dotimes [i 3] (print i)) (println "") ; 012 + (dotimes [i 2 5] (print i)) (println "") ; 234 + (dotimes [i 0 10 3] (print i)) (println "") ; 0369 — uneven, stops short + + ;; (dotimes [i 0 n]) is (dotimes [i n]). One rule, not two. + (dotimes [i 0 4] (print i)) (println "") ; 0123 + + ;; ── counting down ───────────────────────────────────────────────── + (dotimes [i 9 -1 -1] (print i)) (println "") ; 9876543210 + (dotimes [i 10 0 -3] (print i)) (println "") ; 10 7 4 1 + + ;; The last representable i32 is reachable as a stop: it is exclusive, so + ;; the counter reaches -2147483647 and the test ends it there. + (dotimes [i -2147483645 -2147483648 -1] (print i) (println "")) ; three lines + + ;; ── zero-trip loops ─────────────────────────────────────────────── + (dotimes [i 5 5] (print i)) ; nothing + (dotimes [i 0 10 -1] (print i)) ; nothing: already past + (dotimes [i 10 0] (print i)) ; nothing: already past + (println "none") + + ;; ── each bound once, in the order written ───────────────────────── + ;; 7 8 9 for the three bounds, then the two iterations. A bound evaluated + ;; per iteration would interleave; one evaluated twice would repeat. + (dotimes [i (bump 7 0) (bump 8 2) (bump 9 1)] (print i)) + (println "") + + ;; The same for the one-bound form, which is the one that always worked. + (dotimes [i (bump 7 2)] (print i)) + (println "") + + ;; ── a body cannot move the bounds ───────────────────────────────── + (let [n 3] + (dotimes [i 0 n] (set n 0) (print i))) ; 012, not 0 + (println "") + (let [s 1] + (dotimes [i 0 3 s] (set s 5) (print i))) ; 012, not 0 + (println "") + + ;; ── break and continue, in every arity ──────────────────────────── + (dotimes [i 5] (when (= i 3) (break)) (print i)) + (println "") ; 012 + (dotimes [i 5] (when (= i 2) (continue)) (print i)) + (println "") ; 0134 + + (dotimes [i 2 8] (when (= i 5) (break)) (print i)) + (println "") ; 234 + (dotimes [i 2 6] (when (= i 4) (continue)) (print i)) + (println "") ; 235 + + (dotimes [i 0 12 3] (when (= i 9) (break)) (print i)) + (println "") ; 036 + (dotimes [i 0 12 3] (when (= i 6) (continue)) (print i)) + (println "") ; 039 + + ;; Counting down, which is the new path for the latch: the skipped + ;; iteration still subtracts, or this never finishes. + (dotimes [i 5 0 -1] (when (= i 3) (break)) (print i)) + (println "") ; 54 + (dotimes [i 5 0 -1] (when (= i 3) (continue)) (print i)) + (println "") ; 5421 + + ;; Every iteration continues and the trip count is still the trip count. + (let [c 0] + (dotimes [i 4 0 -1] (set c (+ c 1)) (continue)) + (print c)) + (println "") ; 4 + + ;; Labels, on a down-counting loop. + (dotimes :outer [a 2 -1 -1] + (dotimes [b 2 -1 -1] + (when (= b 0) (break :outer)) + (print b))) + (println "") ; 21 + + (dotimes :rows [r 2 -1 -1] + (dotimes [c 2 -1 -1] + (when (= c 1) (continue :rows)) + (print c)) + (println "tail")) + (println "") ; 222, no tail + + ;; ── a step the compiler cannot see the sign of ──────────────────── + (let [up 2 down -2 flat 0] + (dotimes [i 0 7 up] (print i)) ; 0246 + (println "") + (dotimes [i 6 -1 down] (print i)) ; 6420 + (println "") + ;; A step of 0 cannot be refused here — it is a value, not a literal — so + ;; the sign test leaves it with no direction and the loop runs no times. + (dotimes [i 0 7 flat] (print i)) + (println "zero") + ;; And it is evaluated once like the others, so a body that changes it + ;; changes nothing. + (dotimes [i (bump 4 0) (bump 5 6) (bump 6 up)] (print i)) + (println "")) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 097eae9..a3526e1 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -421,6 +421,19 @@ let () = watchdog above is what turns that failure back into a report. *) outputs "break and continue" "programs/loops.flan" "4\n9\n8\n3\n0\n1\n0\n0\n0\n3\n6\nhit\nhit\n2\n"; + (* dotimes with a start, a stop and a step. Three things here fail by + hanging rather than by printing wrongly, which is again the watchdog's + job: a continue in a down-counting loop (the latch must subtract on the + skipped iteration too), a step of 0 that is only a value (no direction, + so no trips), and any bound read per iteration instead of once. The + bump lines are the evaluation count and order — one marker per bound, + ahead of the counter's own output. *) + outputs "dotimes with a range" "programs/dotimes-range.flan" + "012\n234\n0369\n0123\n9876543210\n10741\n\ + -2147483645\n-2147483646\n-2147483647\nnone\n\ + 78901\n701\n012\n012\n\ + 012\n0134\n234\n235\n036\n039\n54\n5421\n4\n21\n222\n\ + 0246\n6420\nzero\n456024\n"; (* loop and recur. The ten-million line is the one that matters: a recur is a jump to the top of a [While] and not a call, so the program returns rather than running out of stack. The swap line is the other — recur diff --git a/test/test_flan.ml b/test/test_flan.ml index ac1b82c..050f00c 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -401,8 +401,19 @@ let () = (* This is the class that silently misparses: it reads fine as a call and means something entirely different. *) (match (parse1 "(dotimes [i 10] (f i))").e with - | Dotimes (None, "i", { e = Int 10L; _ }, [ _ ]) -> () + | Dotimes (None, "i", + { dstart = None; dstop = { e = Int 10L; _ }; dstep = None }, + [ _ ]) -> () | _ -> check "dotimes binds" false); + (* The written bound is always the *stop*, so the shorter forms are the + longer one with its defaults left off. *) + (match (parse1 "(dotimes [i 9 -1 -1] (f i))").e with + | Dotimes (None, "i", + { dstart = Some { e = Int 9L; _ }; + dstop = { e = Int (-1L); _ }; + dstep = Some { e = Int (-1L); _ } }, + [ _ ]) -> () + | _ -> check "dotimes takes start, stop and step" false); (match (parse1 "(fn [x y] x)").e with | Fn ([ "x"; "y" ], [ _ ]) -> () | _ -> check "fn binds" false); @@ -2986,6 +2997,36 @@ let () = "(defn f [] () (while :o true (while true (break :o))))"; accepts "continue in a dotimes" "(defn f [] () (dotimes [i 3] (continue)))"; + (* And in the other two arities, counting either way. The output side of + this is test/programs/dotimes-range.flan; what is checked here is that + nothing about the longer forms disturbs the loop stack. *) + accepts "break and continue in a start/stop dotimes" + "(defn f [] () (dotimes [i 2 5] (when (= i 3) (continue)) (break)))"; + accepts "break and continue in a down-counting dotimes" + "(defn f [] () (dotimes :o [i 9 -1 -1] (when (= i 3) (continue :o)) \ + (break :o)))"; + (* A step of 0 written as a literal is an infinite loop spelled as an + accident, so it is refused where it is written. A step that is only a + value cannot be refused here and runs no times at all — the sign test + that picks the direction leaves it with neither. *) + rejects_check "a literal step of 0" + "(defn f [] () (dotimes [i 0 10 0] (print i)))" + ~needle:"a step of 0 never moves the counter"; + accepts "a step whose sign is not known until run time" + "(defn f [] () (let [s 0] (dotimes [i 0 10 s] (print i))))"; + (* Three bounds is the most there are. A fourth is refused by the parser, + which names all three arities. *) + rejects_check "a dotimes with four bounds" + "(defn f [] () (dotimes [i 0 10 2 1] (print i)))" + ~needle:"(dotimes [name start stop step] body ...)"; + rejects_check "a dotimes with no bound at all" + "(defn f [] () (dotimes [i] (print i)))" + ~needle:"(dotimes [name stop] body ...)"; + (* Every bound is an index, so it is i32 like the one bound always was. + There is no width to join: a wider one is the ordinary type error. *) + rejects_check "a dotimes bound of another width" + "(defn f [] () (let [n (i64 10)] (dotimes [i 0 n] (print i))))" + ~needle:"expected i32, found i64"; rejects_check "break outside a loop" "(defn f [] () (break))" ~needle:"only allowed inside a loop"; rejects_check "continue outside a loop" diff --git a/web/index.html b/web/index.html index 526f41b..995701d 100644 --- a/web/index.html +++ b/web/index.html @@ -715,9 +715,18 @@ whose type matters is named at the top level rather than written inline.

unless runs when the test is false 8 -

dotimes evaluates its bound once into a hidden slot before the loop, so -a body that changes it cannot change the trip count, and the loop variable is not -assignable.

+

dotimes takes one, two or three bounds: +(dotimes [i n]) counts 0 to n-1, (dotimes [i start stop]) counts +start to stop-1, and (dotimes [i start stop step]) steps by step. The stop is +exclusive in every form — so (dotimes [i 0 n]) is (dotimes [i n]) — +and a negative step counts down: (dotimes [i 9 -1 -1]) is 9 down to 0. A step +of 0 written as a literal is a compile error; one that is only known at run time runs the +loop no times.

+ +

Each bound is evaluated exactly once, before the loop and left to right — the start +into the counter itself, the stop into a hidden slot, and the step into one too unless it +is a literal. So a body that changes what a bound came from cannot change the trip count, +and the loop variable is not assignable.

break and continue leave or restart the innermost loop, and take a label when that is not the one meant. loop and