print and println go variadic, with Clojure's spacing

Arguments print in order with a single space between each pair, println
ending the line; (println) is the newline alone and (print) is nothing.
The checker's arm renders each argument exactly as it did alone, so typed
and dyn values mix in one call, one-argument sites are byte-identical, and
an unprintable argument is still refused at its own span.
This commit is contained in:
Joseph Ferano 2026-09-20 22:09:06 +07:00
parent 73ab213134
commit 2b91fd2146
6 changed files with 132 additions and 14 deletions

23
FIX.org
View File

@ -423,3 +423,26 @@ rename. typed-flan branch freezes the static language pre-dyn.
All of it dispatches after the x86-dyn lane lands. The struct dyn-field All of it dispatches after the x86-dyn lane lands. The struct dyn-field
refusal (01e60fa) is the stopgap 2 lifts. refusal (01e60fa) is the stopgap 2 lifts.
* println is variadic, 2026-09-20
Author, dogfooding: "println should be variadic" — hit "println takes 1
argument, given 2".
Semantics chosen: Clojure's. Every argument prints in order, a single space
between each pair, println ends the line. (println) is the newline alone,
(print) is nothing. Single-argument call sites are byte-identical to before —
the space is a separator, never a trailer.
Mechanics: no prelude macro. print/println were never functions — they are
the checker's structural walk (lib/check.ml, the "print" | "println" arm;
the walk in lib/render.ml) — so the arm itself went variadic: each argument
is checked and rendered exactly as it was alone, with a one-byte " " write
interleaved. Typed and dyn arguments mix in one call because each gets its
own printer and both sinks share stdio's buffer (flan_write_stdout and
flan_dyn_print both go through stdout). One generic argument still defers
the whole call to instantiation. Diagnostics stay on the argument: each is
checked carrying its own loc and render.ml fails on the expression's loc,
so an unprintable second argument underlines that argument, not the form —
pinned in test_flan.ml. Output pinned by test/programs/println-variadic.flan
and its acceptance row (LLVM), spacing exact, "|" markers so a leaked
trailing space is a visible red.

View File

@ -5175,12 +5175,18 @@ and named_call ctx ~want loc name args =
cannot be told from the punctuation. The split is exactly top level vs cannot be told from the punctuation. The split is exactly top level vs
nested, which is why it lives here and not in the walk. *) nested, which is why it lives here and not in the walk. *)
| "print" | "println" -> | "print" | "println" ->
arity loc name 1 args; (* Variadic, with Clojure's spacing: every argument prints in order with a
single space between each pair, and [println] ends the line. No arity
check (println) is the newline alone and (print) is nothing, which is
also Clojure's answer. Each argument gets the same walk it would get
alone, so one call mixes typed and dyn values freely, and a bad argument
is refused at its own location: the arguments are checked one by one
below, each carrying its own loc, and render.ml fails on the
expression's loc rather than the form's. *)
(* Printing is a read, not a move: the walk goes over the value and keeps (* Printing is a read, not a move: the walk goes over the value and keeps
nothing. Without this, (println v) would consume a Vec and every nothing. Without this, (println v) would consume a Vec and every
printing of one would be its last. *) printing of one would be its last. *)
let target = List.hd args in let checked = List.map (fun target -> check ctx target) args in
let a = check ctx target in
(* ── The allow-list, and what it takes to get on it ─────────────── (* ── The allow-list, and what it takes to get on it ───────────────
plan.org names [println] as the one compiler-provided exception it plan.org names [println] as the one compiler-provided exception it
"selects a structural printer at each concrete instantiation" and "selects a structural printer at each concrete instantiation" and
@ -5223,8 +5229,10 @@ and named_call ctx ~want loc name args =
The node produced here is a unit no-op, thrown away with the rest of The node produced here is a unit no-op, thrown away with the rest of
the abstract pass. The real printer is selected when the copy is the abstract pass. The real printer is selected when the copy is
checked with [t] concrete. *) checked with [t] concrete. One generic argument defers the whole call:
if generic_ty a.Tast.ty then the printers for its neighbours would be re-selected at instantiation
anyway, so building them here would be work thrown away twice. *)
if List.exists (fun a -> generic_ty a.Tast.ty) checked then
mk loc Types.Unit Tast.Unit mk loc Types.Unit Tast.Unit
else else
let bslice = Types.Slice (Types.Int Types.U8) in let bslice = Types.Slice (Types.Int Types.U8) in
@ -5264,12 +5272,26 @@ and named_call ctx ~want loc name args =
ptrs = None; ptrs = None;
alloc = (fun ty -> fresh_slot ctx ty) } alloc = (fun ty -> fresh_slot ctx ty) }
in in
let parts = let render_one a =
match a.Tast.ty with match a.Tast.ty with
| Types.String | Types.Slice (Types.Int Types.U8) -> | Types.String | Types.Slice (Types.Int Types.U8) ->
[ write (mk loc bslice (Tast.Prim (Tast.Bytes, [ a ]))) ] [ write (mk loc bslice (Tast.Prim (Tast.Bytes, [ a ]))) ]
| _ -> Render.render rc 0 a | _ -> Render.render rc 0 a
in in
(* Built fresh per use rather than shared: nothing else in this file puts
one node in two places of a tree, and a pass that hangs state off a
node would be entitled to assume it appears once. *)
let space () =
write (mk loc bslice
(Tast.Prim (Tast.Bytes, [ mk loc Types.String (Tast.Str " ") ])))
in
let parts =
match checked with
| [] -> []
| first :: rest ->
render_one first
@ List.concat_map (fun a -> space () :: render_one a) rest
in
let nl = let nl =
if String.equal name "println" then if String.equal name "println" then
[ write [ write
@ -5913,12 +5935,14 @@ let builtins : (string * string * string) list =
("write-stdout", "write-stdout [[u8]] ()", ("write-stdout", "write-stdout [[u8]] ()",
"Writes the bytes to standard output exactly as given: no newline and \ "Writes the bytes to standard output exactly as given: no newline and \
no formatting."); no formatting.");
("print", "print [T] ()", ("print", "print [T ...] ()",
"The structural printer, selected for the argument's concrete type. A \ "The structural printer, selected for each argument's concrete type; \
string prints raw at the top level and quoted inside a structure, and \ arguments print in order with a single space between them. A string \
a Ptr or a Handle prints its address rather than being followed. \ prints raw at the top level and quoted inside a structure, and a Ptr \
Printing is a read, so it does not consume the value."); or a Handle prints its address rather than being followed. Printing \
("println", "println [T] ()", "print, with a newline after it."); is a read, so it does not consume the value.");
("println", "println [T ...] ()",
"print, with a newline after it — (println) alone is the newline.");
("exit", "exit [i32] never", ("exit", "exit [i32] never",
"Ends the process with this status. It has no value, so nothing written \ "Ends the process with this status. It has no value, so nothing written \
after it runs."); after it runs.");

View File

@ -19,8 +19,10 @@
No printing function is here at all any more. [print] and [println] are No printing function is here at all any more. [print] and [println] are
the whole printing surface, and neither is a function: both are the whole printing surface, and neither is a function: both are
compiler-provided and structural, a walk over the concrete type at the compiler-provided and structural, a walk over each argument's concrete
call site (check.ml, and the walk itself in render.ml). That is plan.org's type at the call site (check.ml, and the walk itself in render.ml). Both
are variadic with Clojure's spacing -- arguments in order, one space
between each pair, [println] ending the line. That is plan.org's
Milestone 5 item, and it needed none of the rest of milestone 5 -- there Milestone 5 item, and it needed none of the rest of milestone 5 -- there
is nothing to dispatch on at run time and no user-supplied printer to is nothing to dispatch on at run time and no user-supplied printer to
choose between, so no type variables are involved. The earlier note here choose between, so no type variables are involved. The earlier note here

View File

@ -0,0 +1,33 @@
;;;; print and println are variadic with Clojure's spacing: every argument
;;;; prints in order, a single space between each pair, println ending the
;;;; line. (println) alone is the newline; (print) alone is nothing.
;;;;
;;;; What the pins are for. The one-argument lines must be byte-identical to
;;;; what they printed before the forms went variadic — no trailing space is
;;;; the thing an eye cannot see in a diff, so every print line ends in a "|"
;;;; marker that would move if a space leaked. And the arguments are typed and
;;;; dyn in the same call: each gets the walk it would get alone, the typed
;;;; ones selected at check time and the dyn ones handed to the runtime, so
;;;; the interleaving on one line is what proves the two sinks share a buffer.
(defvar boxed dyn 21)
(defvar dlabel dyn "mid")
(defn main [] ()
;; println at every arity
(println) ; bare newline
(println "one") ; the old single-argument form, unchanged
(println "a" "b")
(println "x:" 5 true)
;; mixed machine types in one call
(println 1 2.5 "s" false)
;; dyn and typed in the same call, dyn in the middle
(println "pre" boxed "post")
(println dlabel 7)
;; print: zero args is nothing, and no separator ever trails
(print)
(print "|")
(print "p")
(print "|")
(print "q" "r" 3)
(println "|"))

View File

@ -3291,6 +3291,16 @@ level "1"
there. 1005 is the defer, 6 is the value the restart produced. *) there. 1005 is the defer, 6 is the value the restart produced. *)
outputs "dyn: a defer on the transfer path" "programs/dyn-defer.flan" outputs "dyn: a defer on the transfer path" "programs/dyn-defer.flan"
"1005\n6\n"; "1005\n6\n";
(* print and println are variadic with Clojure's spacing — arguments in
order, one space between each pair, println ending the line, (println)
the newline alone and (print) nothing. The program's comment says what
each line pins; the string here is the whole of the spacing rule, and
the "|" markers are what make a leaked trailing space a visible red
rather than an invisible byte. The dyn lines are typed and dyn
arguments in one call, which is the two printers sharing one buffer. *)
outputs "print and println are variadic"
"programs/println-variadic.flan"
"\none\na b\nx: 5 true\n1 2.5 s false\npre 21 post\nmid 7\n|p|q r 3|\n";
outputs ~opt:"-O0" "dyn: a defer on the transfer path, -O0" outputs ~opt:"-O0" "dyn: a defer on the transfer path, -O0"
"programs/dyn-defer.flan" "1005\n6\n"; "programs/dyn-defer.flan" "1005\n6\n";
(* And through the other backend, where the defer and the roots meet in a (* And through the other backend, where the defer and the roots meet in a

View File

@ -2785,6 +2785,32 @@ let () =
"(defn show [x $t] () {:where (equal? $t)} (println x))"; "(defn show [x $t] () {:where (equal? $t)} (println x))";
accepts "and so is print" accepts "and so is print"
"(defn show [x $t] () {:where (equal? $t)} (print x))"; "(defn show [x $t] () {:where (equal? $t)} (print x))";
(* One generic argument defers the whole call, neighbours included. *)
accepts "a variadic println with a type variable among the arguments"
"(defn show [x $t] () {:where (equal? $t)} (println \"x:\" x 1))";
(* Variadic print/println: any number of arguments, zero included. The
acceptance program pins what comes out; these pin only what the checker
admits, and the part a program cannot show where a refusal lands. *)
accepts "println with no arguments" "(defn f [] () (println))";
accepts "print with no arguments" "(defn f [] () (print))";
accepts "println with three arguments of three types"
"(defn f [] () (println \"x:\" 5 true))";
(* An unprintable argument is refused at its own span, not the form's:
each argument is checked carrying its own loc, and render.ml fails on
the expression's loc. Column 43 is the [m], not the [(println]. *)
(let name = "an unprintable argument is refused at the argument" in
match checked "(defn f [m (Map i32 i32)] () (println \"x\" m))" with
| _ ->
incr failures;
Printf.printf "FAIL %s: expected a type error\n" name
| exception Loc.Error { Loc.dloc; dmsg; _ } ->
let got = Loc.to_string dloc in
if got <> "<test>:1:43" || not (contains dmsg "no printer for") then begin
incr failures;
Printf.printf "FAIL %s\n wanted: %s (no printer for)\n got: %s (%s)\n"
name "<test>:1:43" got dmsg
end);
(* A predicate a body relies on has to be carried by every signature between (* A predicate a body relies on has to be carried by every signature between
it and the call site, or the refusal moves into code the caller did not it and the call site, or the refusal moves into code the caller did not