diff --git a/FIX.org b/FIX.org index 41b2ef7..27b02fd 100644 --- a/FIX.org +++ b/FIX.org @@ -4025,3 +4025,25 @@ sand.flan calls ~(rl/key-pressed? :r)~ and ~(rl/mouse-button-down? :left)~ test/programs/sand-headless.flan imports it). The file is the author's live WIP and was not touched, so those two tests are red on this branch until the three keywords there become ~:key-r~ / ~:mouse-left~. +* 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. diff --git a/lib/check.ml b/lib/check.ml index 32207aa..f4f8b47 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -7582,12 +7582,18 @@ and named_call ?(qualified = false) ctx ~want loc name args = 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. *) | "print" | "println" -> - arity ctx 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 nothing. Without this, (println v) would consume a Vec and every printing of one would be its last. *) - let target = List.hd args in - let a = check ctx target in + let checked = List.map (fun target -> check ctx target) args in (* ── The allow-list, and what it takes to get on it ─────────────── plan.org names [println] as the one compiler-provided exception — it "selects a structural printer at each concrete instantiation" — and @@ -7630,8 +7636,10 @@ and named_call ?(qualified = false) ctx ~want loc name args = 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 - checked with [t] concrete. *) - if generic_ty a.Tast.ty then + checked with [t] concrete. One generic argument defers the whole call: + 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 else let bslice = Types.Slice (Types.Int Types.U8) in @@ -7671,12 +7679,26 @@ and named_call ?(qualified = false) ctx ~want loc name args = ptrs = None; alloc = (fun ty -> fresh_slot ctx ty) } in - let parts = + let render_one a = match a.Tast.ty with | Types.String | Types.Slice (Types.Int Types.U8) -> [ write (mk loc bslice (Tast.Prim (Tast.Bytes, [ a ]))) ] | _ -> Render.render rc 0 a 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 = if String.equal name "println" then [ write @@ -8811,12 +8833,14 @@ let builtins : (string * string * string) list = ("write-stdout", "write-stdout [[u8]] ()", "Writes the bytes to standard output exactly as given: no newline and \ no formatting."); - ("print", "print [T] ()", - "The structural printer, selected for the argument's concrete type. A \ - string prints raw at the top level and quoted inside a structure, and \ - a Ptr or a Handle prints its address rather than being followed. \ - Printing is a read, so it does not consume the value."); - ("println", "println [T] ()", "print, with a newline after it."); + ("print", "print [T ...] ()", + "The structural printer, selected for each argument's concrete type; \ + arguments print in order with a single space between them. A string \ + prints raw at the top level and quoted inside a structure, and a Ptr \ + or a Handle prints its address rather than being followed. Printing \ + 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", "Ends the process with this status. It has no value, so nothing written \ after it runs."); diff --git a/lib/prelude.ml b/lib/prelude.ml index ce2eb22..d896150 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -19,8 +19,10 @@ No printing function is here at all any more. [print] and [println] are the whole printing surface, and neither is a function: both are - compiler-provided and structural, a walk over the concrete type at the - call site (check.ml, and the walk itself in render.ml). That is plan.org's + compiler-provided and structural, a walk over each argument's concrete + 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 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 diff --git a/test/programs/println-variadic.flan b/test/programs/println-variadic.flan new file mode 100644 index 0000000..3cade8b --- /dev/null +++ b/test/programs/println-variadic.flan @@ -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 "|")) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 0c9c3d7..be59c98 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -4454,6 +4454,16 @@ level "1" there. 1005 is the defer, 6 is the value the restart produced. *) outputs "dyn: a defer on the transfer path" "programs/dyn-defer.flan" "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" "programs/dyn-defer.flan" "1005\n6\n"; (* And through the other backend, where the defer and the roots meet in a diff --git a/test/test_flan.ml b/test/test_flan.ml index 67e8d85..1d9d57f 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -5066,6 +5066,32 @@ let () = "(defn show [x $t] () {:where (equal? $t)} (println x))"; accepts "and so is print" "(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 <> ":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 ":1:43" got dmsg + end); (* 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