The driver refuses in sentences, and CI exists

This commit is contained in:
Joseph Ferano 2026-09-17 22:23:19 +07:00
commit 0fd83dc95e
19 changed files with 803 additions and 47 deletions

148
.github/workflows/checks.yml vendored Normal file
View File

@ -0,0 +1,148 @@
# The thing README.md and test/dune both said did not exist.
#
# `dune build @checks` on every push, which is the whole job. Both of this
# repository's silent failures — two x86 refusals that sat for a month, a page
# of examples that stopped compiling for two days — would have been caught by
# one person typing one command, and the argument for CI here is not process:
# it is that nobody typed it. @x86 parity in particular is *not* under
# `dune test`, so the routine suite does not protect it.
#
# `dune test` stays what it is: seconds, run constantly, unchanged by this
# file. The workflow adds a caller, not an alias.
#
# ── What this does not cover, and why ────────────────────────────────────
#
# Stated plainly, because a green tick that quietly checks less than it looks
# like it does is worse than no tick.
#
# raylib. vendor/raylib/link names `-l:libraylib.so.550` by exact soname,
# which is a Fedora spelling; Ubuntu ships neither that soname nor 5.5.
# So every raylib program is skipped here, on three separate paths that
# all already existed: the acceptance cases probe with `ldconfig -p |
# grep libraylib` and print a skip line; survey.sh records a program that
# will not link as `does-not-compile`, which SURVEY_STRICT deliberately
# does not fail on; and web/examples/check.sh skips shimdemo.flan by name.
# Nothing had to be weakened to make this pass — but the graphics half of
# the corpus is unchecked here and is checked only on a machine with
# raylib installed.
#
# emscripten and the web target. test_web probes for emcc and a raylib
# archive built by vendor/raylib/build-web.sh and skips with the reason.
# Installing an emsdk per run is minutes for a target NEXT.md has
# deprioritised, so it is left out on purpose.
#
# wasm32-wasi. Needs a wasi-libc sysroot and a builtins archive that no
# Ubuntu package supplies. The acceptance case probes by building and
# running the smallest program and skips with what failed, so this is a
# gap in coverage and not a failure.
#
# lldb. The debug-information cases want lldb on PATH and say so when it is
# absent. The DWARF is still emitted and still checked for
# self-consistency; what is skipped is the half that says a person can
# actually sit in a debugger.
#
# @sanitize and @valgrind. Out of @checks by design — tens of minutes each
# — and out of here for the same reason. They are the thing to run before
# a release, by hand, not the thing to run on every push.
#
# One platform. ubuntu-latest only. macOS and Windows portability is
# recorded as real and not current (aligned_alloc, MSG_NOSIGNAL,
# stdatomic), and a matrix that goes red for known reasons teaches people
# to ignore red.
name: checks
on:
push:
pull_request:
# A superseded run is wasted minutes and a confusing status on the commit it
# no longer describes.
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
# clang compiles the IR and the runtime's C; llc and ld are the live
# loop, which never calls the clang driver; as is the --x86 path; emacs
# is what test_emacs and test_cider drive the client with, and they skip
# silently without it, which would be four suites' worth of coverage
# disappearing without a word.
- name: Install the toolchain
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
clang llvm lld binutils emacs-nox
# llc has to come from the same LLVM release as clang. The live loop
# goes llc + ld -shared + dlopen rather than through the clang driver,
# so a mismatch breaks `C-c C-c` and every reload test while `flan
# build` keeps working perfectly — which is a confusing shape of failure
# to debug from a CI log. Ubuntu installs llc under a version suffix and
# may or may not provide the bare name, so the version is read off clang
# and FLAN_LLC is pinned to match it rather than left to PATH order.
- name: Point FLAN_LLC at the llc that matches clang
run: |
set -eu
clang --version
major=$(clang --version | sed -n 's/.*version \([0-9][0-9]*\).*/\1/p' | head -1)
if command -v "llc-$major" > /dev/null; then
llc="llc-$major"
elif command -v llc > /dev/null \
&& llc --version | grep -q "LLVM version $major"; then
llc=llc
else
echo "no llc at LLVM $major; clang is $(clang --version | head -1)" >&2
echo "what is on PATH: $(ls /usr/bin/llc* 2>/dev/null || echo none)" >&2
exit 1
fi
echo "FLAN_LLC=$llc" >> "$GITHUB_ENV"
"$llc" --version | head -3
- uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: "5.2"
# There is no (package ...) stanza yet — the install story is Tier 2
# of docs/REVIEW-production-readiness.md and undecided — so there is
# nothing to pin and nothing to resolve dependencies from. The
# library depends on unix and on nothing else.
opam-pin: false
dune-cache: true
- run: opam install -y dune
- name: Build
run: opam exec -- dune build
# --force because the point of a CI run is to run the tests, not to
# discover that dune's cache already knew the answer.
#
# The log is kept and then read, because a suite that passes while
# leaving an unhandled exception on stderr is a suite telling you
# something and being ignored. That happened here — three suites left a
# message-less `Fatal error: exception Flan.Loc.Error(_)` while
# reporting zero failures — and it was found by a person reading a log,
# which is exactly the kind of finding that should not need a person.
- name: The suite
run: |
set -eu
set -o pipefail
opam exec -- dune test --force 2>&1 | tee suite.log
if grep -n 'Fatal error' suite.log; then
echo "the suite passed but left an unhandled exception above" >&2
exit 1
fi
# The part `dune test` does not cover: the reference page's examples
# still print what the page says, the hand-written x86 backend still
# agrees with LLVM over the whole corpus, and a --dev build still calls
# through its indirection cells.
- name: Everything else that can fail
run: opam exec -- dune build @checks

156
README.md
View File

@ -118,17 +118,139 @@ be threaded through every caller. See
## Commands
Eleven of them, and the four anyone starts with:
```text
flan check <file.flan> type-check a program
flan run <file.flan> [args...] build and run it
flan build <file.flan> [-o out] [options] build a native executable
flan dev <file.flan> [-s socket] start a live development session
flan run <file.flan> [flags] [-- args...] build and run it
flan build <file.flan> [-o out] [flags] build a native executable
flan dev <file.flan> [-s socket] start a live development session
```
Useful build options include `--debug`, `--sanitize`, `--no-bounds-checks`,
`--x86`, and `--target=wasm32-wasi|web`. `run` is native-only; cross-built
output should be run with an appropriate WASI runtime or browser. A `.wasm`
file is not a tiny native executable in a trench coat.
Useful build options include `-O0``-O3`, `--debug`, `--sanitize`,
`--no-bounds-checks`, `--x86`, and `--target=wasm32-wasi|web`. `run` takes the
same build flags and keeps them: everything after `--` goes to the program, and
a dash-argument before it that `run` does not offer is refused rather than
guessed at. `run` is native-only; cross-built output should be run with an
appropriate WASI runtime or browser. A `.wasm` file is not a tiny native
executable in a trench coat.
The other seven. Four print a stage of the pipeline, which is how you find out
what the compiler thinks it was given:
```text
flan read <file.flan>... the forms the reader produced
flan parse <file.flan>... one line per declaration
flan shim <file.flan>... the C a (declare-c ...) generated
flan emit <file.flan> [--x86] [--dev] [--debug] [--no-bounds-checks]
LLVM IR, or x86-64 assembly under --x86
```
One builds a redefinition module the way `flan dev` does, for scripting the
loop without an editor:
```text
flan reload <program.flan> <forms.flan> [-o out.so] [--debug] [--x86]
```
And two are the C binding tools, which are the most useful thing here that
nothing else documents.
### `flan import-c` — read a header, print the bindings it implies
It builds nothing and writes nothing. It reads a C header (through clang's own
parser), prints the `declare-c` forms it would generate, and then prints every
function it *refused* and the reason — which is the half that earns its keep,
because "this binding is missing" and "this binding cannot exist" are different
problems:
```text
$ flan import-c test/headers/sample.h
(declare-c set-seed [seed u32] "set_seed")
(declare-c add-ints [a i32 b i32] i32 "add_ints")
(declare-c name-length [text string] i32 "name_length")
...
;; 8 imported, 12 refused, of 21 functions in test/headers/sample.h
;; refused name-of: name_of returns char *, and a string only crosses as a
;; parameter — a C function that returns one returns something Flan has no
;; owner for
;; refused printf-like: printf_like is variadic, and a wrapper cannot forward
;; an argument list it does not know the shape of
;; refused file-time: file_time long has a width that differs between this
;; project's own targets (64 bits on x86-64, 32 on wasm32), so no single
;; Flan type is right for it
```
Hand the package's `.flan` files along with the header and it diffs against
them too: a `defstruct` whose layout no longer matches the header's record is
named, which is the failure that otherwise shows up as a wrong pixel.
```text
$ flan import-c vendor/raylib/raylib-5.5.h vendor/raylib/*.flan
```
Anything after the header that is not a `.flan` file is passed to clang, so
`-I` and `-D` work as they do anywhere else.
### `flan generate-c` — write those bindings into the package
The same machinery, committing its answer. It takes a package *directory*, not
a header: the header comes from the package's own `headers` file, at the
version its `link` file names, because which version may be read is a property
of the package and not of the command line. It reads every `.flan` in the
directory except the one it writes, so hand-written declarations keep winning,
and it writes `generated.flan`.
```text
$ flan generate-c vendor/raylib
;; refused get-clipboard-text: GetClipboardText returns char *, and a string
;; only crosses as a parameter — ...
;; refused load-shader: LoadShader Shader is a struct the package does not
;; describe — add a defstruct for it, or keep a hand-written declare-c
```
It exits non-zero and writes nothing if the package and the header disagree.
That is the point of it: the committed file is the one thing in the package
with no second opinion, so the moment of writing it is the last moment at which
the installed library can contradict it.
## Environment
Thirteen variables the toolchain reads, none of which has to be set on a
machine with clang, LLVM and binutils on `PATH`. Each exists for a machine
where the thing is somewhere else, or is the wrong one.
| Variable | What it replaces | Read at |
|---|---|---|
| `FLAN_CLANG` | `clang`, which compiles the IR and the runtime's C | `lib/build.ml:14` |
| `FLAN_LLC` | `llc`, used only by the live loop | `lib/build.ml:886` |
| `FLAN_LD` | `ld`, used only by the live loop | `lib/build.ml:887` |
| `FLAN_AS` | `as`, used only by `--x86` | `lib/build.ml:978` |
| `FLAN_OBJDUMP` | `objdump`, used only by the disassembly verb | `lib/dev.ml:2018` |
| `FLAN_OCAMLFIND` | `ocamlfind`, which a merged `flan dev` needs **at run time** | `lib/dev.ml:2898` |
| `FLAN_EMCC` | `emcc`, for `--target=web` | `lib/build.ml:23` |
| `FLAN_LIBDIR` | where `flan.cmxa` and `flan.a` are, if not beside the binary | `lib/dev.ml:3187` |
| `FLAN_CACHE_DIR` | the object cache, default `$XDG_CACHE_HOME/flan/objcache` | `lib/build.ml:84` |
| `FLAN_WASM_SYSROOT` | the wasi-libc sysroot, default `/usr/wasm32-wasi` | `lib/build.ml:239` |
| `FLAN_WASM_BUILTINS` | `libclang_rt.builtins-wasm32.a`, which is not in clang's resource directory on Fedora | `lib/build.ml:272` |
| `FLAN_WEB_SHELL` | the HTML shell a `--target=web` build wraps the module in | `lib/build.ml:458` |
| `FLAN_DEV_LEAKS` | set (to anything) to have a `--dev` build print what it still held at exit | `runtime/flan_dev.c:1084` |
`${FLAN_RAYLIB_WEB}` is not read by the compiler: it is expanded inside
`vendor/raylib/link`, which is where a package writes a linker argument that
has to differ per target. Any `${NAME}` in a `link` file expands from the
environment and an unset one is refused by name.
**`llc` and `ld` have to match `clang`.** The live loop does not call the clang
driver at all — it goes `llc` + `ld -shared` + `dlopen`, which is what makes
`C-c C-c` cost milliseconds. So an `llc` from a different LLVM release than
`clang` breaks the dev loop while `flan build` keeps working perfectly, which
is a confusing shape of failure to meet without warning.
Variables beginning `FLAN_DEV_` other than `FLAN_DEV_LEAKS`, plus
`FLAN_AGENT_SOCKET` and `FLAN_COMPILER_STAMP`, are internal: `flan dev` sets
them across its own `exec` to hand the merged binary what it needs. Setting
them by hand is not supported.
## Checking it
@ -146,13 +268,19 @@ hand-written x86 backend still agrees with LLVM, and a `--dev` build still calls
through its indirection cells.
The two are separate on purpose. A suite that goes red because prose drifted
teaches you to skim past red. But `@checks` only helps if it is run, and nothing
runs it for you — there is no CI here. The convention that has to carry it is
that a lane's handoff quotes `@checks`, the way the x86 handoffs already quote
the survey's counts. Both of this repository's silent failures — two backend
refusals that sat for a month, a page of examples that stopped compiling for two
days — were found by accident, and neither would have survived one person
typing one command.
teaches you to skim past red. Both of this repository's silent failures — two
backend refusals that sat for a month, a page of examples that stopped
compiling for two days — were found by accident, and neither would have
survived one person typing one command.
`.github/workflows/checks.yml` is now that person: it runs `dune build`,
`dune test --force` and `dune build @checks` on every push. What it cannot run
is written down in the workflow itself rather than left to be discovered —
raylib, emscripten, wasm32-wasi and lldb are all absent from an Ubuntu runner,
and every one of them was already a self-skip, so the tick is green over less
than it is on a machine with those installed. The habit that covers the rest is
still worth keeping: a lane's handoff quotes `@checks`, the way the x86
handoffs already quote the survey's counts.
## Project map

View File

@ -39,6 +39,31 @@ let with_errors path f =
prerr_endline ("flan: " ^ m);
ignore path;
exit 1
(* A file that is not there, or that cannot be read. The exception already
carries the path and the reason the operating system gave, which is the
whole of what anybody can act on, so it is passed through as it is
[lib/dev.ml]'s [message_of_exn] does the same for the same reason, and
the daemon has had this arm since before the CLI did. Without it
[flan check nosuch.flan] ends in [Fatal error: exception Sys_error(...)],
which is the compiler telling the user it did not expect to be asked. *)
| Sys_error m ->
prerr_endline ("flan: " ^ m);
ignore path;
exit 1
(* The backstop. Nothing in this binary reaches it today: every [Hashtbl.find]
on a path a command can take is guarded by a [find_opt]. It is here so
that the day one is not, the failure is a sentence naming the file being
worked on rather than a bare [Fatal error: exception Not_found] which
says nothing at all, not even which file. Its own exit status, because a
compiler bug is not a refusal of the program and a script should be able
to tell the two apart. *)
| Not_found ->
prerr_endline
("flan: internal error — a lookup failed with no name to report, while \
working on " ^ path
^ ". This is a bug in the compiler and not a fault in the program; \
please report the file that provoked it.");
exit 4
let summarise (d : Flan.Ast.decl) =
let open Flan.Ast in
@ -151,8 +176,43 @@ let flags =
usage error. *)
let target_prefix = "--target="
(* The optimisation level, which until now had no spelling at all: [Build.default]
pinned -O2 and [--debug] was the only route to anything else. Four levels and
not five -Os is clang's and llc rejects it outright ("invalid optimization
level"), and the same string reaches both ([Build] at the clang compile and
again at the llc step of the live loop), so offering a level one of the two
tools does not know would be a flag that works for [flan build] and breaks
[C-c C-c].
Last one wins, which is what every compiler does with a repeated -O and the
only rule that does not need explaining. *)
let opt_levels = [ "-O0"; "-O1"; "-O2"; "-O3" ]
let opt_of args =
List.fold_left
(fun acc a -> if List.mem a opt_levels then Some a else acc)
None args
let is_flag a =
List.mem a flags || String.starts_with ~prefix:target_prefix a
List.mem a flags || List.mem a opt_levels
|| String.starts_with ~prefix:target_prefix a
(* [--debug] forces -O0 in [Build] and says why there: [llvm.dbg.declare]
describes an alloca and mem2reg deletes the alloca, so a debug build at -O2
has a line table over code whose locals are gone. That is a good rule, and
it makes [--debug -O2] a request that cannot be honoured so it is refused
here by name instead of being quietly overruled two modules away. *)
let check_opt_against_debug ~debug ~opt =
match (debug, opt) with
| true, Some o when o <> "-O0" ->
prerr_endline
("flan: --debug and " ^ o
^ " ask for opposite things — a debug build is -O0 because \
llvm.dbg.declare describes an alloca and mem2reg at any higher level \
deletes it, leaving a line table over locals that are not there. \
Drop one of the two.");
exit 2
| _ -> ()
let target_of args =
List.find_map
@ -515,6 +575,8 @@ let () =
let debug = List.mem debug_flag rest in
let sanitize = List.mem sanitize_flag rest in
let x86 = List.mem x86_flag rest in
let opt = opt_of rest in
check_opt_against_debug ~debug ~opt;
let target = target_of rest in
let out =
match List.filter (fun a -> not (is_flag a)) rest with
@ -535,7 +597,8 @@ let () =
| _ -> base)
| _ ->
prerr_endline
"usage: flan build <file.flan> [-o out] [--no-bounds-checks] \
"usage: flan build <file.flan> [-o out] [-O0|-O1|-O2|-O3] \
[--no-bounds-checks] \
[--dev] [--debug] [--sanitize] [--target=wasm32-wasi|web|js]";
exit 2
in
@ -549,7 +612,9 @@ let () =
let p, csrcs, lflags = Flan.Reach.link ~dev l p in
ignore (Flan.Build.executable
~opts:{ Flan.Build.default with checks; dev; debug; sanitize;
target; x86 }
target; x86;
opt = Option.value opt
~default:Flan.Build.default.Flan.Build.opt }
~csrcs ~lflags ~pnames:(if debug then param_names l else [])
p ~out))
(* The daemon an editor talks to: one session, the program it belongs to
@ -637,6 +702,57 @@ let () =
this host can exec. Use flan build --target=... and a wasm runtime.";
exit 2
| _ :: "run" :: path :: args ->
(* One command, two argument lists, and until now no rule saying which was
which: [flan run game.flan --debug] built at -O2 and handed the game a
[--debug] it had never heard of. Nothing reported that, because neither
side thought it had been given anything wrong.
The rule, in one line: a build flag is the build's, [--] ends the build
flags, and everything after [--] is the program's whatever it looks
like. Before [--], an argument that starts with a dash and is not a
build flag this command offers is refused by name not guessed at,
because guessing is the failure this exists to stop, and a program of
one's own that wants [-v] has [--] to ask for it. Plain arguments need
no ceremony: they were never ambiguous and they still go straight
through, so [flan run calc-me.flan "1+2"] is unchanged. *)
(* --dev is deliberately not on this list, and its absence is the point:
a dev build's indirection cells exist so that something can attach and
redefine through them, and nothing can attach to a process this command
builds, execs, waits for and deletes. Leaving it off means it lands in
the refusal below with a sentence, rather than quietly producing a
spelling [Build] says does not exist [flan build]'s --x86 arm records
that `flan dev` never reaches its fork because --x86 is read only by
[flan build], and an --x86 --dev route through here would have made that
sentence false. *)
let run_flags =
[ no_checks_flag; debug_flag; sanitize_flag; x86_flag ] @ opt_levels
in
let build_args, prog_args =
let rec split acc = function
| "--" :: rest -> (List.rev acc, rest)
| a :: rest when String.length a > 1 && a.[0] = '-' ->
if List.mem a run_flags then split (a :: acc) rest
else begin
prerr_endline
("flan run: " ^ a
^ " is not a flag this command offers, and it will not be \
guessed at a build flag belongs to the build and \
anything else belongs to the program. Write it after -- to \
send it to the program: flan run "
^ Filename.basename path ^ " -- " ^ a);
exit 2
end
| a :: rest -> let l, r = split acc rest in (l, a :: r)
| [] -> (List.rev acc, [])
in
split [] args
in
let checks = not (List.mem no_checks_flag build_args) in
let debug = List.mem debug_flag build_args in
let sanitize = List.mem sanitize_flag build_args in
let x86 = List.mem x86_flag build_args in
let opt = opt_of build_args in
check_opt_against_debug ~debug ~opt;
with_errors path (fun () ->
let exe =
Filename.concat (Filename.get_temp_dir_name ())
@ -645,9 +761,16 @@ let () =
let l = load path in
let p = Flan.Check.program_all l.decls in
let p, csrcs, lflags = Flan.Reach.link l p in
ignore (Flan.Build.executable ~csrcs ~lflags p ~out:exe);
ignore (Flan.Build.executable
~opts:{ Flan.Build.default with checks; debug; sanitize;
x86;
opt = Option.value opt
~default:Flan.Build.default.Flan.Build.opt }
~csrcs ~lflags ~pnames:(if debug then param_names l else [])
p ~out:exe);
let code =
Sys.command (String.concat " " (List.map Filename.quote (exe :: args)))
Sys.command
(String.concat " " (List.map Filename.quote (exe :: prog_args)))
in
(try Sys.remove exe with Sys_error _ -> ());
exit code)
@ -656,9 +779,10 @@ let () =
"usage: flan (read|parse|check|emit|shim) <file.flan>...\n flan emit <file.flan> [--x86] [--dev] [--debug] [--no-bounds-checks]\n\
\ flan import-c <header.h> [package.flan...] [clang flags...]\n\
\ flan generate-c <package-dir>\n\
\ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] \
\ flan build <file.flan> [-o out] [-O0|-O1|-O2|-O3] \
[--no-bounds-checks] [--dev] \
[--debug] [--sanitize] [--x86] [--target=wasm32-wasi|web|js]\n\
\ flan run <file.flan> [args...]\n\
\ flan run <file.flan> [build flags...] [--] [program args...]\n\
\ flan reload <program.flan> <forms.flan> [-o out.so] [--x86]\n\
\ flan dev <program.flan> [-s socket] [--x86]";
exit 2

View File

@ -759,13 +759,22 @@ Against `tast.ml`'s `expr_kind`, in four buckets:
| **Mechanical** | `While` `Break` `Continue` (the jump patching exists), `Global` `Str` `Zero` `Uninit`, the rest of `place`, `Field` `Deref` `Addr`, `Arr`, `Some_` `None_` `UnwrapSome`, `Match` on a tag |
| **Bulky, not hard** | floats — a second register file, SSE encodings, `Cast`'s eight conversions, and the SSE half of the calling convention. Perhaps a third of the total instruction work for a small fraction of the programs |
| **Fiddly** | aggregate copy on assignment (a struct `store` *is* the copy `spec-memory.md` requires), `Make` `MakeCase` `CaseField` over the payload blob, `CallPtr`, `FnAddr`'s three cases and the cell load behind `Fnval` |
| **No plan** | `Handled` `Signal` `RestartCase` `InvokeRestart` `WithAlloc`, the transfer-channel guard after every call, the landing pad, and `fdefers` on the transfer exit path |
| **~~No plan~~ Done** | `Handled` `Signal` `RestartCase` `InvokeRestart` `WithAlloc`, the transfer-channel guard after every call, the landing pad, and `fdefers` on the transfer exit path |
The last row is the one to take seriously. The spike never emitted a guard or a pad, and the guard is on *every call
site* in the real thing — `emit.ml`'s `guard`, `current_pad`, `emit_restart_case` and `emit_with_alloc` are several
hundred lines of control flow that a second backend reimplements from the spec rather than copies. Conditions are not
an advanced feature to defer: `spec-conditions.md` is load-bearing in the prelude already.
**Correction, and it matters because this row argues the opposite of the truth.** The last row was written before the
backend existed and it is no longer the state of anything. `x86.ml:1587-1615` lowers the whole condition family —
`Handled`, `Signal`, `RestartCase`, `InvokeRestart`, `WithAlloc`, the per-call-site transfer guard, the landing pad and
the defers on the transfer exit — and the parity survey is **104 / 104 MATCH, 0 DIFFER**. Item 16 below is where that
was built and item 17 is where conditions and bounds checks were taken through the whole corpus rather than a sample;
this row is kept struck through rather than deleted because other files cite this table by position. Anyone reading it
for a production decision should read items 16, 17 and 18 instead: the second backend refuses nothing this one does,
and "no plan" has not been true since.
### Question 3 — the SysV boundary, and the obstacle nobody named
**The C boundary is the easy half, and `BUILT.md` is why.** `check.ml` rejects an aggregate in a `declare` signature

View File

@ -30,9 +30,34 @@ works without dape installed, and `C-c C-g` only exists once you load it.
(require 'flan-dape) ; only if you have dape
```
Or install it as a package, which gets you autoloads and byte-compiled files
without a `load-path` line. `package-install-file` names the package after the
directory, so the directory has to be called `flan-mode` — symlink it once and
point Emacs at the link:
```sh
ln -s ~/Development/flan/emacs ~/.emacs.d/flan-mode
```
```
M-x package-install-file RET ~/.emacs.d/flan-mode/ RET
```
Every file carries `Version:` and `Package-Requires: ((emacs "29.1"))`, which is
what package.el reads. dape is deliberately not in that list: `flan-dape.el`
reaches it through `declare-function`, so everything else installs and works
without it.
You also need the `flan` binary on your `PATH`. If it is somewhere else, set
`flan-dev-command`.
Two settings worth knowing about before you need them. `flan-dev-start-timeout`
(60s) bounds the wait for a daemon to come up, and `flan-dev-reply-timeout`
(30s) bounds one request once it has. The second is the one a long first
compile can exhaust: the message says so and names `*flan-dev*`, where the
daemon's own build log is, so you can see whether it is still working before
you raise it.
---
## Starting a program

View File

@ -1,5 +1,24 @@
;;; flan-cnr.el --- What a stopped program is offering -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; `C-c C-b' is a `completing-read' over restart names. That is the whole UI
;; for the one moment the dev loop exists to make survivable, and it is thin in
;; a way that is worth being specific about: it shows the names and nothing

View File

@ -1,5 +1,24 @@
;;; flan-dape.el --- Debug a Flan program with dape and lldb-dap -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; The other half of the dev loop. flan-dev.el is about a program that keeps
;; running while you change it; this is about stopping one and reading it.
;;

View File

@ -1,5 +1,24 @@
;;; flan-dev.el --- Talk to a running Flan program -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; The editor half of Flan's dev loop. `flan dev program.flan' compiles the
;; program, launches it, and listens on .flan-dev.sock beside the source; this
;; connects to that socket and sends it forms. M-x flan-dev starts that
@ -77,6 +96,29 @@ that quietly did nothing, which is why it is on."
"Buffer the running program's own output is appended to."
:type 'string)
(defcustom flan-dev-daemon-buffer "*flan-dev*"
"Buffer the daemon's own output goes to.
This is where a build that failed says so: the daemon compiles the program
before it binds its socket, so a program that does not compile produces no
socket at all and this buffer is the only account of why. It is also where a
build that is merely slow can be watched, which is what the reply timeout
below points at."
:type 'string)
(defcustom flan-dev-reply-timeout 30
"Seconds to wait for one reply from the daemon before giving up.
This bounds a single request, not a session. Thirty seconds is generous
for an evaluation and tight for a first compile of a large program on a
cold object cache, which is the one case that legitimately runs long so
it is a setting rather than a constant.
Giving up here never resends. If the daemon took the request and died
before replying the evaluation may well already have happened, and sending
it again would install a body twice or run a side-effecting expression
twice; reconnecting happens before a send and never after one."
:type 'number)
(defvar flan-dev--connection nil
"The open connection, or nil.")
@ -149,7 +191,7 @@ Point is in the process buffer, and the frame is known to be complete."
(defun flan-dev--read-reply (proc)
"Block until PROC sends one complete framed message, and read it."
(with-current-buffer (process-buffer proc)
(let ((deadline (+ (float-time) 30)))
(let ((deadline (+ (float-time) flan-dev-reply-timeout)))
;; The header first: digits up to a newline.
(while (and (not (save-excursion (goto-char (point-min))
(re-search-forward "\\`\\([0-9]+\\)\n" nil t)))
@ -161,9 +203,17 @@ Point is in the process buffer, and the frame is known to be complete."
;; before replying, the evaluation may well have happened — sending it
;; again would install it twice, or run a side-effecting expression
;; twice. Reconnecting happens before a send, never after one.
;; What a person does next, not just what failed. A live daemon that
;; has not answered is nearly always still working — a first compile
;; of a large program on a cold object cache is the case that runs
;; long — and the daemon's own log says which step it is on, so the
;; message names the buffer to look in and the setting to raise
;; rather than leaving both to be discovered.
(if (process-live-p proc)
(error "flan dev: no reply in 30s from %s"
(abbreviate-file-name (or flan-dev--socket "the daemon")))
(error "flan dev: no reply in %ss from %s; the daemon may still be building — see %s for its log, and raise `flan-dev-reply-timeout' if this build is simply long"
flan-dev-reply-timeout
(abbreviate-file-name (or flan-dev--socket "the daemon"))
flan-dev-daemon-buffer)
(error
"flan dev: the daemon on %s closed the connection; not resent, because it may already have run"
(abbreviate-file-name (or flan-dev--socket "?")))))
@ -511,12 +561,10 @@ With no argument, look for `flan-dev-socket-name' up from this buffer."
A name is looked up on `exec-path'; a path is used as given."
:type 'string)
(defcustom flan-dev-daemon-buffer "*flan-dev*"
"Buffer the daemon's own output goes to.
This is where a build that failed says so: the daemon compiles the program
before it binds its socket, so a program that does not compile produces no
socket at all and this buffer is the only account of why."
:type 'string)
;; `flan-dev-daemon-buffer' belongs to this section and is declared with the
;; other buffer names at the top of the file instead, because the reply reader
;; -- which runs long before any of this -- names it in the message it gives
;; when a request times out, and the byte-compiler reads a file in order.
(defcustom flan-dev-start-timeout 60
"Seconds to wait for a daemon started from Emacs to accept a connection.

View File

@ -1,5 +1,24 @@
;;; flan-inspect.el --- Navigate a running program's values -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; `C-x C-e' renders a value once and puts it in the echo area. This is the
;; interactive version of the same walk: the fields laid out one per line, RET
;; to go into one, `l' to come back, `g' to read it again. CIDER's inspector,

View File

@ -1,5 +1,24 @@
;;; flan-lower.el --- Every lowering of one function, in one buffer -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; `spike/x86/dump.sh' prints four lowerings of one function side by side --
;; the LLVM IR the frontend emits, what `llc' makes of it at -O0 and at -O2,
;; and what the hand-written x86 backend emits. Reading one against another is

View File

@ -1,5 +1,24 @@
;;; flan-mode.el --- Major mode for Flan -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; Derived from `prog-mode', borrowing `lisp-mode''s machinery for the parts
;; that are simply s-expressions: sexp motion, paren matching and
;; `beginning-of-defun' already do the right thing.

View File

@ -1,5 +1,24 @@
;;; flan-repl.el --- A prompt for a running Flan program -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; A buffer to type expressions at, sent to the program `flan dev' is running
;; and answered with the value they had *there*. It adds no protocol and no
;; compiler support: every line goes through the same `eval-expr' request that

View File

@ -1,5 +1,24 @@
;;; flan-watch.el --- Watched values, in a pinned buffer or inline -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; A HUD for a running Flan program: a buffer that always shows the current
;; frame's values and nothing else. Ported from the author's Clojure
;; `clj-watch', with one thing kept and one thing inverted.

View File

@ -6336,7 +6336,27 @@ let check_global env (d : Ast.decl) : Tast.global option =
(* The entry point, plan.org: (defn main [args [string]] i32), with both the
parameter and the return type optional. *)
let check_main env =
(* Where [main] was written. [env.locs] is the table of where each *type* was
declared [collect] fills it for structs, data types, unions and enums and
for nothing else so a function's own location is not in it and the
[find_opt] idiom the rest of this file uses does not apply here. The
declaration list does have it, and the caller is holding the list anyway.
Without this both refusals below opened with <unknown>:0:0, which tells a
reader that a rule exists and not where they broke it, and gives
[next-error] nothing to jump to. A [main] that arrived some other way a
[declare], say still has no [defn] to point at, so that case keeps the
unknown span rather than inventing one. *)
let main_loc (decls : Ast.decl list) =
let is_main (d : Ast.decl) =
match d.Ast.d with Ast.Defn fn -> fn.Ast.name = "main" | _ -> false
in
match List.find_opt is_main decls with
| Some { Ast.d = Ast.Defn fn; _ } -> fn.Ast.nloc
| _ -> Loc.unknown
let check_main env decls =
let at = main_loc decls in
match Hashtbl.find_opt env.fns "main" with
| None -> () (* a library, or a file being checked on its own *)
| Some (params, ret) ->
@ -6347,12 +6367,12 @@ let check_main env =
| _ -> false
in
if not ok_params then
fail Loc.unknown
fail at
"main takes no parameters or one [string], not (%s)"
(String.concat " " (List.map Types.to_string params));
if not (Types.equal ret Types.Unit || Types.equal ret (Types.Int Types.I32))
then
fail Loc.unknown "main returns i32 or nothing, not %s"
fail at "main returns i32 or nothing, not %s"
(Types.to_string ret)
(* The environment as well as the program. A session needs it to check an
@ -6385,7 +6405,7 @@ let build_program ~keep_going (decls : Ast.decl list) : Tast.program * env =
check_finite env;
check_union_members env;
let s = Loc.sink ~on:keep_going in
ignore (Loc.caught s (fun () -> check_main env));
ignore (Loc.caught s (fun () -> check_main env decls));
(* Every generic body, checked once with its variables left abstract, and
the result thrown away. This is the pass plan.org's rule needs and Odin
has no equivalent of: Odin checks a polymorphic body only per

View File

@ -359,8 +359,8 @@ let report (d : diag) =
it found. *)
let report_all (ds : diag list) =
(* Source order, with the placeless ones last. A diagnostic the checker
raised against [unknown] a wrong [main] signature is the one that
happens has line 0, and sorting on the number alone would put it at the
raised against [unknown] a rule about a declaration that is not in this
file to point at has line 0, and sorting on the number alone would put it at the
top of the list, above every error that can actually be clicked. It is a
real error and it is not anywhere, so it goes after the ones that are. *)
let placed (d : diag) = d.dloc.line > 0 in
@ -376,3 +376,24 @@ let report_all (ds : diag list) =
let n = List.length ds in
String.concat "\n" (List.map report ds)
^ Printf.sprintf "\n%d error%s" n (if n = 1 then "" else "s")
(* The last line of defence, and the one nobody plans to reach. Every driver in
this tree catches [Error] and [Errors] and prints [report]; a process that
does not a test binary walking the corpus, a tool written in an afternoon
dies through OCaml's default handler, and the default handler knows nothing
about this record. What it prints is [Fatal error: exception
Flan.Loc.Error(_)]: the name of a constructor, an underscore, and not one
word of the diagnostic that was carefully built to say what was wrong and
where. That is how three suite runs came to leave a message-less fatal on
stderr while reporting that they had passed.
Registering a printer costs nothing and cannot change control flow the
exception still propagates and still kills whatever did not catch it. All it
changes is that the corpse says which file and which line, which is the
whole of what the diagnostic was for. Drivers that do catch are unaffected:
they never ask [Printexc] anything. *)
let () =
Printexc.register_printer (function
| Error d -> Some (report d)
| Errors ds -> Some (report_all ds)
| _ -> None)

View File

@ -5,9 +5,17 @@
above it is Flan. That is what keeps a second backend cheap a primitive
is the only thing implemented twice.
It lives here as a string rather than as a file because there is no package
loader yet; at milestone 3 it becomes an ordinary [core:] package and this
module goes away. The acceptance programs may call anything defined here.
It lives here as a string rather than as a file, and every program gets it
prepended by [Check.build_program] whether it asked for one or not. That is
a real limit and it is stated as one: the prelude cannot be read as Flan,
extended, or replaced without rebuilding the compiler. There *is* a package
loader now [lib/load.ml], and [vendor/raylib] is a package that uses it
so the obstacle is no longer the mechanism. What is missing is the decision
about what a [core:] package would mean for a program that imports nothing,
and until that is made this docstring does not promise one. (It used to say
the prelude became a [core:] package "at milestone 3". Milestone 3 came and
went; the package did not.) The acceptance programs may call anything
defined here.
No printing function is here at all any more. [print] and [println] are
the whole printing surface, and neither is a function: both are

View File

@ -319,13 +319,19 @@
; Valgrind present, and folding them in would make @checks the thing you do not
; have time for -- which is the disease, not the cure.
;
; It is honest to say what this does not do. It does not run itself. Nothing
; here runs itself, because there is no CI, and a commit hook that costs two
; minutes gets switched off in a week. What it buys is that deciding to check
; and checking everything are now the same act, so the gap between "somebody
; wondered" and "everything was verified" is one command instead of five. The
; habit that closes the rest of the gap is written down in README.md: a lane's
; handoff quotes this alias, the way the x86 handoffs already quote survey.sh.
; What it buys is that deciding to check and checking everything are now the
; same act, so the gap between "somebody wondered" and "everything was
; verified" is one command instead of five.
;
; It used to say here that nothing runs this, because there was no CI. There is
; now: .github/workflows/checks.yml runs `dune build`, `dune test --force` and
; this alias on every push. That does not make the local run redundant, and the
; workflow says why in as many words — an Ubuntu runner has no raylib, no
; emscripten, no wasi sysroot and no lldb, so every one of those cases takes
; the skip path it already had and the tick is green over less than this alias
; covers here. The habit written down in README.md still carries the rest: a
; lane's handoff quotes this alias, the way the x86 handoffs already quote
; survey.sh.
(alias
(name checks)
(deps

View File

@ -3727,6 +3727,71 @@ ERR@7 unexpected token: not the kind the caller was reading
outputs ~opt:"-O0" "utf-8, splitting and ascii case, -O0"
"programs/utf8.flan" utf8_out;
(* ── The driver's own refusals ─────────────────────────────────────
Not about compiled code at all: about what the CLI does when it is
handed something it cannot do. Each of these was an escape before
an OCaml exception printed by the default handler, or a build flag
silently forwarded to the program and each is pinned here because
"it prints a sentence" is exactly the kind of claim that rots without
a test to hold it. *)
let cli args =
let out = Filename.concat scratch "flan-cli.out" in
let code =
Sys.command
(Printf.sprintf "../bin/main.exe %s > %s 2>&1" args (Filename.quote out))
in
let text = In_channel.with_open_bin out In_channel.input_all in
(try Sys.remove out with Sys_error _ -> ());
(code, text)
in
let cli_case name args ~code:want_code ~says =
let code, text = cli args in
let bad =
code <> want_code
|| List.exists (fun n -> not (contains text n)) says
(* The point of half of these: no arm here may end in OCaml's default
handler, whatever else it does. *)
|| contains text "Fatal error"
in
if bad then begin
incr failures;
Printf.printf "FAIL %s\n got: %S (exit %d)\n wanted exit %d with %s\n"
name text code want_code (String.concat ", " says)
end
in
(* A path that is not there. This used to be
[Fatal error: exception Sys_error("...")] the compiler reporting that
it had not expected to be asked. *)
cli_case "check on a file that is not there"
"check no-such-file.flan" ~code:1
~says:[ "no-such-file.flan"; "No such file or directory" ];
(* And every other front end takes the same route, since the arm is on the
one wrapper they all go through. *)
cli_case "build on a file that is not there"
"build no-such-file.flan -o /dev/null" ~code:1
~says:[ "no-such-file.flan" ];
(* [flan run] and its two argument lists. -O0 is the build's, so calc-me
never sees it and answers the expression that follows; before the split
it was handed "-O0" as the expression and said it could not parse it. *)
cli_case "run keeps a build flag out of the program's argv"
"run ../calc-me.flan -O0 '1+2'" ~code:0 ~says:[ "3" ];
(* -- hands the rest over whatever it looks like, which is what makes the
refusal below affordable. *)
cli_case "run passes everything after -- to the program"
"run ../calc-me.flan -O0 -- '3*4'" ~code:0 ~says:[ "12" ];
(* And an unknown dash argument is refused by name rather than guessed at
in either direction. *)
cli_case "run refuses a flag it does not offer"
"run ../calc-me.flan --lint" ~code:2
~says:[ "--lint"; "will not be guessed at"; "--" ];
(* The one pair of flags that cannot both be honoured: --debug is -O0 in
[Build] and says why, so asking for it alongside a higher level is a
request with two answers. *)
cli_case "--debug and an explicit -O are refused together"
"build ../calc-me.flan --debug -O2 -o /dev/null" ~code:2
~says:[ "--debug"; "-O2"; "Drop one of the two" ];
if !failures = 0 then print_endline "acceptance: all tests passed"
else begin
Printf.printf "\n%d failure(s)\n" !failures;

View File

@ -945,6 +945,27 @@ let () =
~needle:"main takes no parameters";
rejects_check "main returning the wrong type" "(defn main [] bool true)"
~needle:"main returns i32";
(* And both of them point at the [main] that is wrong. They used to open with
<unknown>:0:0 the checker's rule about the entry point is the one place
that had a name and no span, because [env.locs] records where a *type* was
declared and a function is not in it. The span is the whole difference
between a message you can act on and a message you have to go looking for,
so it is pinned here rather than left to the reader of a report. *)
let main_at name src =
match checked src with
| _ ->
incr failures;
Printf.printf "FAIL %s: expected a type error\n" name
| exception Loc.Error { Loc.dloc; _ } ->
let got = Loc.to_string dloc in
if got <> "<test>:1:7" then begin
incr failures;
Printf.printf "FAIL %s\n wanted: %s\n got: %s\n"
name "<test>:1:7" got
end
in
main_at "a wrong main parameter points at main" "(defn main [n i32] ())";
main_at "a wrong main return type points at main" "(defn main [] bool true)";
(* ── Unconstrained operators, and everything past milestone 2 ──── *)
rejects_check "no built-in = on strings"