From 2d8d9cd5a8d96328c25af115e8ff6c8528a66f4e Mon Sep 17 00:00:00 2001
From: Joseph Ferano
Date: Mon, 21 Sep 2026 07:52:13 +0700
Subject: [PATCH] dotimes counts from where you say, and can count down
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"Is there a way to do dotimes or a loop in reverse?" — the answer was a
hand-written let plus set. Now it is (dotimes [i 9 -1 -1]).
Three arities: [i n], [i start stop], [i start stop step]. The stop is
exclusive in all of them, so [i 0 n] is [i n] — one rule, not two — and a
negative step counts down, testing with > instead of <.
A literal step of 0 is refused where it is written. One that is only a value
cannot be, so the condition asks the sign first and 0 falls out of it as a
loop that runs no times: terminating and deterministic, and free, because a
literal step still emits the single comparison it always did.
Each bound is evaluated once, left to right, before the counter exists: the
start into the counter, the stop into the hidden slot it always had, the
step into one of its own unless it is a literal.
Still a special form, still a Let and a While with the step in the latch, so
neither backend learned anything — the new program prints the same thing
under --x86 and at -O0. load.ml's Form-level walk had to learn more than one
bound for the same reason parse.ml did; it is part of this feature and not a
bug that was sitting there, because before this a three-bound dotimes was a
parse error long before that walk could reach it.
---
FIX.org | 33 ++++++++
docs/BUILT.md | 54 ++++++++++++-
lib/ast.ml | 19 ++++-
lib/check.ml | 105 ++++++++++++++++++++++----
lib/load.ml | 21 ++++--
lib/parse.ml | 24 +++++-
test/programs/dotimes-range.flan | 126 +++++++++++++++++++++++++++++++
test/test_acceptance.ml | 13 ++++
test/test_flan.ml | 43 ++++++++++-
web/index.html | 15 +++-
10 files changed, 424 insertions(+), 29 deletions(-)
create mode 100644 test/programs/dotimes-range.flan
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