diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..f8292f5 --- /dev/null +++ b/.github/workflows/checks.yml @@ -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 diff --git a/README.md b/README.md index cf9d01c..365c1fd 100644 --- a/README.md +++ b/README.md @@ -118,17 +118,139 @@ be threaded through every caller. See ## Commands +Eleven of them, and the four anyone starts with: + ```text flan check type-check a program -flan run [args...] build and run it -flan build [-o out] [options] build a native executable -flan dev [-s socket] start a live development session +flan run [flags] [-- args...] build and run it +flan build [-o out] [flags] build a native executable +flan dev [-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 ... the forms the reader produced +flan parse ... one line per declaration +flan shim ... the C a (declare-c ...) generated +flan emit [--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 [-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 diff --git a/bin/main.ml b/bin/main.ml index ebb503a..47266d4 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -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 [-o out] [--no-bounds-checks] \ + "usage: flan build [-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) ...\n flan emit [--x86] [--dev] [--debug] [--no-bounds-checks]\n\ \ flan import-c [package.flan...] [clang flags...]\n\ \ flan generate-c \n\ - \ flan build [-o out] [--no-bounds-checks] [--dev] \ + \ flan build [-o out] [-O0|-O1|-O2|-O3] \ + [--no-bounds-checks] [--dev] \ [--debug] [--sanitize] [--x86] [--target=wasm32-wasi|web|js]\n\ - \ flan run [args...]\n\ + \ flan run [build flags...] [--] [program args...]\n\ \ flan reload [-o out.so] [--x86]\n\ \ flan dev [-s socket] [--x86]"; exit 2 diff --git a/docs/DISCUSS.md b/docs/DISCUSS.md index b326f8f..1f05819 100644 --- a/docs/DISCUSS.md +++ b/docs/DISCUSS.md @@ -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 diff --git a/emacs/MANUAL.md b/emacs/MANUAL.md index a33c335..d1a7e04 100644 --- a/emacs/MANUAL.md +++ b/emacs/MANUAL.md @@ -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 diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el index 73e645f..b8fe4ec 100644 --- a/emacs/flan-cnr.el +++ b/emacs/flan-cnr.el @@ -1,5 +1,24 @@ ;;; flan-cnr.el --- What a stopped program is offering -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; 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 diff --git a/emacs/flan-dape.el b/emacs/flan-dape.el index 9ab2b31..8371604 100644 --- a/emacs/flan-dape.el +++ b/emacs/flan-dape.el @@ -1,5 +1,24 @@ ;;; flan-dape.el --- Debug a Flan program with dape and lldb-dap -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; 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. ;; diff --git a/emacs/flan-dev.el b/emacs/flan-dev.el index bbee9e7..1586a26 100644 --- a/emacs/flan-dev.el +++ b/emacs/flan-dev.el @@ -1,5 +1,24 @@ ;;; flan-dev.el --- Talk to a running Flan program -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; 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. diff --git a/emacs/flan-inspect.el b/emacs/flan-inspect.el index c77d2e1..9e065bd 100644 --- a/emacs/flan-inspect.el +++ b/emacs/flan-inspect.el @@ -1,5 +1,24 @@ ;;; flan-inspect.el --- Navigate a running program's values -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; 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, diff --git a/emacs/flan-lower.el b/emacs/flan-lower.el index cd20c29..e289302 100644 --- a/emacs/flan-lower.el +++ b/emacs/flan-lower.el @@ -1,5 +1,24 @@ ;;; flan-lower.el --- Every lowering of one function, in one buffer -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; 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 diff --git a/emacs/flan-mode.el b/emacs/flan-mode.el index 491c1ff..8309bd0 100644 --- a/emacs/flan-mode.el +++ b/emacs/flan-mode.el @@ -1,5 +1,24 @@ ;;; flan-mode.el --- Major mode for Flan -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; 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. diff --git a/emacs/flan-repl.el b/emacs/flan-repl.el index f66256a..762dd79 100644 --- a/emacs/flan-repl.el +++ b/emacs/flan-repl.el @@ -1,5 +1,24 @@ ;;; flan-repl.el --- A prompt for a running Flan program -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; 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 diff --git a/emacs/flan-watch.el b/emacs/flan-watch.el index da26578..f0f6e0f 100644 --- a/emacs/flan-watch.el +++ b/emacs/flan-watch.el @@ -1,5 +1,24 @@ ;;; flan-watch.el --- Watched values, in a pinned buffer or inline -*- lexical-binding: t; -*- +;; Author: Joseph Ferano +;; 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. diff --git a/lib/check.ml b/lib/check.ml index 46c3f10..a4ee2e7 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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 :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 diff --git a/lib/loc.ml b/lib/loc.ml index d79c399..e5bacb2 100644 --- a/lib/loc.ml +++ b/lib/loc.ml @@ -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) diff --git a/lib/prelude.ml b/lib/prelude.ml index 30d6435..78482b7 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -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 diff --git a/test/dune b/test/dune index b808813..6ee3325 100644 --- a/test/dune +++ b/test/dune @@ -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 diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 3b51865..7b29399 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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; diff --git a/test/test_flan.ml b/test/test_flan.ml index 4091203..9d13727 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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 + :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 <> ":1:7" then begin + incr failures; + Printf.printf "FAIL %s\n wanted: %s\n got: %s\n" + name ":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"