flan/test/programs/macros.flan
Joseph Ferano f3a0e435fd unless is not a special form any more
plan.org milestone 5 says when, unless, until, cond and dotimes are special
forms only until macros land. This is the first one to stop being one, and
running test/programs/macro-unless.flan means the compiler built a shared
object, dlopened it into itself and called a Flan function to find out what
(unless c a b) means.

unless is the one that moved because it is the one nothing else needs: zero
uses in the prelude, so moving it cannot make the prelude depend on the
expander that compiles it. Its coverage is sand.flan, seven calls, compiled
through Session in test_session -- which is the in-process path and the reason
lib/dune now passes -linkall. Say plainly what that coverage is not: nothing
in test/programs used unless before today, so macro-unless.flan is a test
written after the feature. The corpus that was written before it is sand.flan
and web/examples/control.flan, and both compile unchanged.

lib/macro.ml is the half of expansion that has to compile something. Expand is
the image format and the quasiquote desugaring and depends on nothing above
Form; this needs Check, Build and Emit, so it sits above the parser it feeds
and arrives through Parse.expander.

What it does, in order:

- Collects every defmacro from the prelude and from the file. Not from an
  imported package: Load learns a package's imports by parsing it, so
  collecting from one means a second import resolver over Forms, and that is a
  bigger thing than this.

- Builds them in rounds, because a macro's body may call a macro and a body
  with an unexpanded call in it will not compile at all -- the call is a name
  nothing defines. Round 0 takes every macro that names no macro still
  waiting; round 1 expands the rest against round 0's module. A round that
  takes nothing while macros remain is a ring and is named. macros.flan has
  the round-1 case and macro-cycle.flan has the ring, and the distinction
  between them is the one thing here that is easy to get wrong: a call inside
  a quasiquote is *not* a compile-order dependency. It is part of what the
  macro answers, and the answer is expanded again after it returns. The first
  macro-cycle.flan written for this commit quasiquoted, and it was not a cycle
  at all -- it hit the fuel instead, correctly.

- Walks bottom up, so a macro never sees a call to another macro in what it is
  handed, and re-expands what comes back, so a macro that expands into a call
  to itself keeps going. That loop is bounded at 200 and says which macro ran
  out: macro-spin.flan.

- Skips all of it when the file names no macro, which is nearly every file.
  Otherwise every build in the suite would pay a clang driver to answer a
  question nobody asked. When it does build, the module is cached under the
  object cache and keyed by the prelude's source plus the file's defmacros, so
  a second process pays a dlopen.

lib/dune passes -linkall, which is the one line in another lane's file. The
module installs itself into Parse.expander at initialisation and nothing
references it, so without -linkall the linker drops it from every executable
that does not name the module -- bin/main.exe among them -- and a program
calling a macro fails with an unknown name. The alternative was an install
call at every entry point, including ones in files this lane must not touch.

The one thing a macro cannot do that parse.ml could is give a reason. A macro
runs inside the compiler and anything it signals aborts the compile with no
location, so a malformed (unless) answers a name nothing defines and the
report is "unknown name unless-takes-a-test-and-a-body" at the call site --
right place, wrong sentence. NEXT.md says so.

test_flan.ml's "unless -> if(not)" assertion is gone, because it asserted a
desugaring in a file that no longer does one. Nothing else in the suite
changed.
2026-09-12 20:59:12 +07:00

83 lines
3.3 KiB
Plaintext

;;;; Macros: a defmacro in the file, called from the file.
;;;;
;;;; There is no interpreter, so every macro below was compiled into a shared
;;;; object and dlopened into the compiler before this file's first line was
;;;; parsed. What arrives here is the expansion; nothing at run time knows a
;;;; macro was involved.
;;;;
;;;; A macro takes one parameter, the slice of forms written at its call site,
;;;; and answers one form. That is where variadics come from in a language with
;;;; no &rest: (len args) is how many were written.
;; The simplest one there is: two forms, in order. It proves the call site's
;; arguments arrive as forms and come back as code.
(defmacro both [args]
`(do ~(at args 0) ~(at args 1)))
;; Splicing, which is the only reason ~@ exists: the body is however many forms
;; were written, and they go where a list is expected.
(defmacro when2 [args]
`(if ~(at args 0) (do ~@(form-rest args 1))))
;; Expansion is not hygienic -- Common Lisp's rule and Clojure's, settled in
;; plan.org's open decision 2 -- so a macro that needs a name of its own asks
;; for one. gensym is a prelude function the loaded module runs while it runs,
;; and the name it answers starts with ~, which is a delimiter, so no symbol
;; the reader can produce is able to collide with it.
;;
;; Without this, `twice` would bind `tmp` and the caller's own `tmp` would be
;; shadowed inside it. The two calls below are the difference.
(defmacro twice [args]
(let [v (gensym)]
`(let [~v ~(at args 0)]
(+ ~v ~v))))
;; A macro that answers a call to another macro. This costs the pre-pass
;; nothing: `both` is inside the quasiquote, so it is part of what this macro
;; *returns* and is expanded again after it returns, and `announce` can be
;; compiled without `both` existing.
(defmacro announce [args]
`(both (print "-> ") ~(at args 0)))
;; This is the one that makes the pre-pass a fixpoint rather than a sweep. The
;; call to `id` is not inside a quasiquote, so it runs while *this macro is
;; being compiled* -- which means `id` has to be compiled and dlopened first,
;; and until it is, `id` is a name nothing defines and this body will not
;; compile at all. So round 0 takes `id`, round 1 expands this against it, and
;; the module that finally answers a call holds both.
(defmacro id [args]
(at args 0))
(defmacro quiet [args]
(id `(println "a macro that called a macro")))
;; And a macro that expands into a call to itself, which is what every
;; conditional macro in every Lisp is. It gets smaller each time and stops at
;; the empty case, so the expander's fuel never comes into it.
(defmacro all-of [args]
(if (= (len args) 0)
`true
`(if ~(at args 0) (all-of ~@(form-rest args 1)) false)))
(defn main [] i32
(both (print "a") (println "b"))
(when2 true (print "c") (println "d"))
(when2 false (println "not printed"))
;; 21 + 21. The argument is evaluated once, into the gensym'd binding.
(print (twice 21)) (println "")
;; The caller's own `tmp` is untouched by the one the macro bound, because
;; the macro did not bind `tmp`.
(let [tmp 5]
(print (twice tmp)) (print " ") (print tmp) (println ""))
(announce (println "announced"))
(quiet)
(print (all-of)) (println "")
(print (all-of true true true)) (println "")
(print (all-of true false true)) (println "")
0)