Lisp based flan
This commit is contained in:
parent
2c232dd874
commit
e9cdbb321b
1
bin/dune
1
bin/dune
@ -1,4 +1,3 @@
|
|||||||
(executable
|
(executable
|
||||||
(public_name flan)
|
|
||||||
(name main)
|
(name main)
|
||||||
(libraries flan))
|
(libraries flan))
|
||||||
|
|||||||
23
bin/main.ml
23
bin/main.ml
@ -1,9 +1,18 @@
|
|||||||
open Flan.Parse
|
(* flan — milestone 2 driver. Right now: read a file and print the forms back,
|
||||||
|
which is the first thing worth having and the first thing worth testing. *)
|
||||||
|
|
||||||
let () =
|
let () =
|
||||||
let source_str = Flan.Examples.let_bind_int in
|
match Array.to_list Sys.argv with
|
||||||
let lexbuf = Lexing.from_string source_str in
|
| _ :: "read" :: files when files <> [] ->
|
||||||
Printf.printf "Convert source \"%s\" ->\n" source_str;
|
List.iter
|
||||||
match parse_program lexbuf with
|
(fun path ->
|
||||||
| Ok ast -> print_ast ast
|
try
|
||||||
| Error msg -> print_endline ("ERROR: \n" ^ msg)
|
Flan.Reader.read_file path
|
||||||
|
|> List.iter (fun f -> print_endline (Flan.Form.to_string f))
|
||||||
|
with Flan.Loc.Error (loc, msg) ->
|
||||||
|
Printf.eprintf "%s: %s\n" (Flan.Loc.to_string loc) msg;
|
||||||
|
exit 1)
|
||||||
|
files
|
||||||
|
| _ ->
|
||||||
|
prerr_endline "usage: flan read <file.flan>...";
|
||||||
|
exit 2
|
||||||
|
|||||||
123
calc-me.flan
Normal file
123
calc-me.flan
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
;;;; calc-me — THE FIRST ACCEPTANCE PROGRAM (build sequence milestone 2).
|
||||||
|
;;;;
|
||||||
|
;;;; $ calc-me "1 + 2 * (3 - 0.5) / 2"
|
||||||
|
;;;; 3.5
|
||||||
|
;;;;
|
||||||
|
;;;; Chosen to be the smallest program that is still a real one. What it needs:
|
||||||
|
;;;; functions, recursion, structs, (Ptr T) and `addr`, byte slices, `at`/`len`,
|
||||||
|
;;;; `while`, `set` on locals and on fields, `cond`, `match`, Option, i32/u8/f64,
|
||||||
|
;;;; and argv. What it deliberately does NOT need: an allocator, Vec, Map, any
|
||||||
|
;;;; generic function, any macro the user wrote, FFI beyond argv and stdout,
|
||||||
|
;;;; a window, or a frame loop. It runs headless, so it is the same test on
|
||||||
|
;;;; native and on wasm32 — which is how the second target gets proven early.
|
||||||
|
;;;;
|
||||||
|
;;;; No `ns` form and no package declaration: the package name is inferred from
|
||||||
|
;;;; the directory. `(package calc)` is written only when the name must differ
|
||||||
|
;;;; from the directory name. See plan.org "Modules".
|
||||||
|
|
||||||
|
;; ── Cursor over the input. A plain value struct; recursive descent shares
|
||||||
|
;; ── one by pointer. `addr` takes the address of a local; the pointer never
|
||||||
|
;; ── outlives the frame, so no allocator is involved.
|
||||||
|
(defstruct Cursor
|
||||||
|
[src [u8] ; non-owning slice into argv — calc-me never owns a byte
|
||||||
|
pos i32]) ; no initialiser means zeroed
|
||||||
|
|
||||||
|
(defn peek [c (Ptr Cursor)] u8
|
||||||
|
(if (< (.pos c) (len (.src c)))
|
||||||
|
(at (.src c) (.pos c))
|
||||||
|
0)) ; 0 doubles as end-of-input
|
||||||
|
|
||||||
|
(defn advance [c (Ptr Cursor)]
|
||||||
|
(set (.pos c) (+ (.pos c) 1))) ; field access auto-derefs one level
|
||||||
|
|
||||||
|
(defn skip-spaces [c (Ptr Cursor)]
|
||||||
|
(while (= (peek c) \space)
|
||||||
|
(advance c)))
|
||||||
|
|
||||||
|
(defn digit? [b u8] bool
|
||||||
|
(and (>= b \0) (<= b \9)))
|
||||||
|
|
||||||
|
;; ── number := digit+ ("." digit+)? ────────────────────────────────────
|
||||||
|
(defn parse-number [c (Ptr Cursor)] (Option f64)
|
||||||
|
(skip-spaces c)
|
||||||
|
(let [start (.pos c)]
|
||||||
|
(while (digit? (peek c))
|
||||||
|
(advance c))
|
||||||
|
(when (= (peek c) \.)
|
||||||
|
(advance c)
|
||||||
|
(while (digit? (peek c))
|
||||||
|
(advance c)))
|
||||||
|
(if (= start (.pos c))
|
||||||
|
None
|
||||||
|
(Some (bytes->f64 (slice (.src c) start (.pos c)))))))
|
||||||
|
|
||||||
|
;; ── primary := number | "(" expr ")" | "-" primary ────────────────────
|
||||||
|
;; `some` unwraps Some and early-returns None from THIS function. It and
|
||||||
|
;; Option are the only error handling here; Result, try and errdefer wait.
|
||||||
|
(defn parse-primary [c (Ptr Cursor)] (Option f64)
|
||||||
|
(skip-spaces c)
|
||||||
|
(cond
|
||||||
|
(= (peek c) \-)
|
||||||
|
(do (advance c)
|
||||||
|
(Some (- 0.0 (some (parse-primary c)))))
|
||||||
|
|
||||||
|
(= (peek c) \()
|
||||||
|
(do (advance c)
|
||||||
|
(let [v (some (parse-expr c 1))]
|
||||||
|
(skip-spaces c)
|
||||||
|
(if (= (peek c) \))
|
||||||
|
(do (advance c) (Some v))
|
||||||
|
None))) ; unbalanced paren
|
||||||
|
|
||||||
|
:else
|
||||||
|
(parse-number c)))
|
||||||
|
|
||||||
|
(defn precedence [op u8] i32
|
||||||
|
(cond
|
||||||
|
(or (= op \+) (= op \-)) 1
|
||||||
|
(or (= op \*) (= op \/)) 2
|
||||||
|
:else 0)) ; 0 means "not an operator"
|
||||||
|
|
||||||
|
(defn apply-op [op u8 l f64 r f64] f64
|
||||||
|
(cond
|
||||||
|
(= op \+) (+ l r)
|
||||||
|
(= op \-) (- l r)
|
||||||
|
(= op \*) (* l r)
|
||||||
|
:else (/ l r)))
|
||||||
|
|
||||||
|
;; ── expr := primary (op primary)*, precedence climbing ────────────────
|
||||||
|
;; Left-associative: the right operand is parsed at prec+1, so 1-2-3 is
|
||||||
|
;; (1-2)-3 and not 1-(2-3). Mutually recursive with parse-primary; top-level
|
||||||
|
;; names in a package are order-independent, so no forward declaration.
|
||||||
|
(defn parse-expr [c (Ptr Cursor) min-prec i32] (Option f64)
|
||||||
|
(let [lhs (some (parse-primary c))]
|
||||||
|
(skip-spaces c)
|
||||||
|
(let [prec (precedence (peek c))]
|
||||||
|
(while (and (> prec 0) (>= prec min-prec))
|
||||||
|
(let [op (peek c)]
|
||||||
|
(advance c)
|
||||||
|
(set lhs (apply-op op lhs (some (parse-expr c (+ prec 1))))))
|
||||||
|
(skip-spaces c)
|
||||||
|
(set prec (precedence (peek c)))))
|
||||||
|
(Some lhs)))
|
||||||
|
|
||||||
|
;; ── Whole input, or nothing. Trailing junk is an error, not ignored. ──
|
||||||
|
(defn evaluate [src [u8]] (Option f64)
|
||||||
|
(let [c (Cursor {:src src})] ; pos omitted: zeroed
|
||||||
|
(let [v (some (parse-expr (addr c) 1))]
|
||||||
|
(skip-spaces (addr c))
|
||||||
|
(if (= (peek (addr c)) 0)
|
||||||
|
(Some v)
|
||||||
|
None))))
|
||||||
|
|
||||||
|
;; Entry point: (defn main [args [string]] i32). Both the parameter and the
|
||||||
|
;; return type are optional — sand.flan uses the bare (defn main []) form.
|
||||||
|
;; print-str/print-f64/print-line are Flan functions over the write-stdout
|
||||||
|
;; primitive, NOT an overloaded println: compile-time overloading waits for
|
||||||
|
;; milestone 5, so until then the acceptance programs name the type.
|
||||||
|
(defn main [args [string]] i32
|
||||||
|
(if (< (len args) 2)
|
||||||
|
(do (print-line "usage: calc-me \"1 + 2 * 3\"") 1)
|
||||||
|
(match (evaluate (bytes (nth args 1)))
|
||||||
|
(Some v) (do (print-f64 v) (print-line "") 0)
|
||||||
|
None (do (print-line "calc-me: cannot parse") 1))))
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# Overview
|
|
||||||
|
|
||||||
Concepts:
|
|
||||||
|
|
||||||
- Primitives
|
|
||||||
- u8, i32, f32, bool, char
|
|
||||||
- lists, slice, fixed-length array builtin, matrices
|
|
||||||
- Control flow
|
|
||||||
- for, while, break
|
|
||||||
- Structs & tuples
|
|
||||||
- Let bindings
|
|
||||||
- `Ptr a`
|
|
||||||
- Pattern matching
|
|
||||||
- ADTs
|
|
||||||
- Mutability
|
|
||||||
- const by default?
|
|
||||||
- Functions
|
|
||||||
- Array syntax
|
|
||||||
- ranges `arr[1..]`, `arr[..3]`
|
|
||||||
- index `arr[4]`
|
|
||||||
- Stdlib
|
|
||||||
- string
|
|
||||||
- vec/dynarray
|
|
||||||
- hashtable
|
|
||||||
- option/result
|
|
||||||
|
|
||||||
C-like with Roc syntax.
|
|
||||||
Start with interpreter. Output C later down the track
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let slice = &[u32];
|
|
||||||
|
|
||||||
[1,23,45,4,1]
|
|
||||||
let new_vec: Vec<i32> = iterator.iter_mut()
|
|
||||||
-> map(|x| *x = 100 ) -- allocs?
|
|
||||||
-> filter -- allocs?
|
|
||||||
-> filter -- allocs?
|
|
||||||
.collect()
|
|
||||||
```
|
|
||||||
28
dune-project
28
dune-project
@ -1,27 +1,7 @@
|
|||||||
(lang dune 3.15)
|
(lang dune 3.15)
|
||||||
(using menhir 3.0)
|
|
||||||
|
|
||||||
(name flan)
|
(name flan)
|
||||||
|
|
||||||
(generate_opam_files true)
|
; No menhir, no ocamllex: the reader is hand-written. S-expressions do not need
|
||||||
|
; a parser generator, hand-written gives better source locations, and every
|
||||||
(source
|
; dependency here is something that would have to be reimplemented in Flan if
|
||||||
(github username/reponame))
|
; the compiler is ever self-hosted.
|
||||||
|
|
||||||
(authors "Author Name")
|
|
||||||
|
|
||||||
(maintainers "Maintainer Name")
|
|
||||||
|
|
||||||
(license LICENSE)
|
|
||||||
|
|
||||||
(documentation https://url/to/documentation)
|
|
||||||
|
|
||||||
(package
|
|
||||||
(name flan)
|
|
||||||
(synopsis "A short synopsis")
|
|
||||||
(description "A longer description")
|
|
||||||
(depends ocaml dune menhir)
|
|
||||||
(tags
|
|
||||||
(topics "to describe" your project)))
|
|
||||||
|
|
||||||
; See the complete stanza docs at https://dune.readthedocs.io/en/stable/dune-files.html#dune-project
|
|
||||||
|
|||||||
32
flan.opam
32
flan.opam
@ -1,32 +0,0 @@
|
|||||||
# This file is generated by dune, edit dune-project instead
|
|
||||||
opam-version: "2.0"
|
|
||||||
synopsis: "A short synopsis"
|
|
||||||
description: "A longer description"
|
|
||||||
maintainer: ["Maintainer Name"]
|
|
||||||
authors: ["Author Name"]
|
|
||||||
license: "LICENSE"
|
|
||||||
tags: ["topics" "to describe" "your" "project"]
|
|
||||||
homepage: "https://github.com/username/reponame"
|
|
||||||
doc: "https://url/to/documentation"
|
|
||||||
bug-reports: "https://github.com/username/reponame/issues"
|
|
||||||
depends: [
|
|
||||||
"ocaml"
|
|
||||||
"dune" {>= "3.15"}
|
|
||||||
"menhir"
|
|
||||||
"odoc" {with-doc}
|
|
||||||
]
|
|
||||||
build: [
|
|
||||||
["dune" "subst"] {dev}
|
|
||||||
[
|
|
||||||
"dune"
|
|
||||||
"build"
|
|
||||||
"-p"
|
|
||||||
name
|
|
||||||
"-j"
|
|
||||||
jobs
|
|
||||||
"@install"
|
|
||||||
"@runtest" {with-test}
|
|
||||||
"@doc" {with-doc}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
dev-repo: "git+https://github.com/username/reponame.git"
|
|
||||||
5
lib/dune
5
lib/dune
@ -1,7 +1,2 @@
|
|||||||
(library
|
(library
|
||||||
(name flan))
|
(name flan))
|
||||||
|
|
||||||
(menhir
|
|
||||||
(modules oflan))
|
|
||||||
|
|
||||||
(ocamllex olexer)
|
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
(** Examples of syntax / programs as strings that can be imported and tested *)
|
|
||||||
|
|
||||||
let let_bind_int = "let x = 10"
|
|
||||||
let let_bind_str = "let s = \"hello\" "
|
|
||||||
40
lib/form.ml
Normal file
40
lib/form.ml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
(** The reader's output: syntax, before any typing or macro expansion.
|
||||||
|
|
||||||
|
Deliberately dumb. [true], [false] and [nil] are ordinary symbols here and
|
||||||
|
are resolved later; the reader knows nothing about special forms. *)
|
||||||
|
|
||||||
|
type t = {
|
||||||
|
v : value;
|
||||||
|
loc : Loc.t;
|
||||||
|
}
|
||||||
|
|
||||||
|
and value =
|
||||||
|
| Sym of string (* foo rl/draw-fps .pos + *)
|
||||||
|
| Kw of string (* :space :else (leading : dropped) *)
|
||||||
|
| Int of int64 (* 42 -1 0xE6B800FF *)
|
||||||
|
| Float of float (* 0.05 *)
|
||||||
|
| Str of string (* "SAND" *)
|
||||||
|
| Byte of int (* \space \0 \( (0..255) *)
|
||||||
|
| List of t list (* (f x) *)
|
||||||
|
| Vec of t list (* [1 2 3] and every binding/type bracket *)
|
||||||
|
| Map of t list (* {:key v} in value position, {K V} in type position *)
|
||||||
|
|
||||||
|
let make v loc = { v; loc }
|
||||||
|
|
||||||
|
let rec to_string f =
|
||||||
|
let seq l = String.concat " " (List.map to_string l) in
|
||||||
|
match f.v with
|
||||||
|
| Sym s -> s
|
||||||
|
| Kw s -> ":" ^ s
|
||||||
|
| Int i -> Int64.to_string i
|
||||||
|
| Float x -> Printf.sprintf "%g" x
|
||||||
|
| Str s -> Printf.sprintf "%S" s
|
||||||
|
| Byte b ->
|
||||||
|
(match Char.chr b with
|
||||||
|
| ' ' -> "\\space"
|
||||||
|
| '\t' -> "\\tab"
|
||||||
|
| '\n' -> "\\newline"
|
||||||
|
| c -> Printf.sprintf "\\%c" c)
|
||||||
|
| List l -> "(" ^ seq l ^ ")"
|
||||||
|
| Vec l -> "[" ^ seq l ^ "]"
|
||||||
|
| Map l -> "{" ^ seq l ^ "}"
|
||||||
19
lib/loc.ml
Normal file
19
lib/loc.ml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
(** Source locations. Every form carries one: error messages, the step debugger
|
||||||
|
and nREPL's find-definition all need them, and retrofitting locations onto a
|
||||||
|
reader is far worse than carrying them from the start. *)
|
||||||
|
|
||||||
|
type t = {
|
||||||
|
file : string;
|
||||||
|
line : int; (* 1-based *)
|
||||||
|
col : int; (* 1-based *)
|
||||||
|
}
|
||||||
|
|
||||||
|
let make file line col = { file; line; col }
|
||||||
|
let unknown = { file = "<unknown>"; line = 0; col = 0 }
|
||||||
|
|
||||||
|
let to_string t = Printf.sprintf "%s:%d:%d" t.file t.line t.col
|
||||||
|
|
||||||
|
(** Raised by every stage of the frontend. *)
|
||||||
|
exception Error of t * string
|
||||||
|
|
||||||
|
let fail loc fmt = Printf.ksprintf (fun msg -> raise (Error (loc, msg))) fmt
|
||||||
@ -1,65 +0,0 @@
|
|||||||
/* Declarations */
|
|
||||||
|
|
||||||
%{
|
|
||||||
open Omniflan.Ast
|
|
||||||
%}
|
|
||||||
|
|
||||||
%token Eof
|
|
||||||
%token Newline
|
|
||||||
%token Let
|
|
||||||
|
|
||||||
%token False
|
|
||||||
%token True
|
|
||||||
%token If
|
|
||||||
%token Then
|
|
||||||
%token Else
|
|
||||||
%token Print
|
|
||||||
%token <string> Ident
|
|
||||||
%token <int> Int
|
|
||||||
%token <float> F32
|
|
||||||
|
|
||||||
%token LParen
|
|
||||||
%token RParen
|
|
||||||
%token LBrace
|
|
||||||
%token RBrace
|
|
||||||
%token LBracket
|
|
||||||
%token RBracket
|
|
||||||
%token Dot
|
|
||||||
%token Comma
|
|
||||||
%token Colon
|
|
||||||
%token Semicolon
|
|
||||||
%token Plus
|
|
||||||
%token Minus
|
|
||||||
%token Star
|
|
||||||
%token Slash
|
|
||||||
%token Bang
|
|
||||||
%token Equal
|
|
||||||
%token EqualEqual
|
|
||||||
%token BangEqual
|
|
||||||
%token LT
|
|
||||||
%token GT
|
|
||||||
%token LTE
|
|
||||||
%token GTE
|
|
||||||
|
|
||||||
%start <Omniflan.Ast.program> prog
|
|
||||||
|
|
||||||
%%
|
|
||||||
/* Grammar */
|
|
||||||
|
|
||||||
expr:
|
|
||||||
| i = Int; { Int i }
|
|
||||||
|
|
||||||
stmt:
|
|
||||||
| Let; var_name = Ident; Equal; bound_expr = expr
|
|
||||||
{ Let {
|
|
||||||
loc = $startpos;
|
|
||||||
var_name = var_name;
|
|
||||||
bindee = bound_expr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toplevel_item:
|
|
||||||
| stmt = stmt { Stmt stmt }
|
|
||||||
|
|
||||||
prog:
|
|
||||||
| prog = separated_list(Newline, toplevel_item); Eof { prog }
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
open Lexing
|
|
||||||
open Oflan
|
|
||||||
|
|
||||||
exception SyntaxError of string
|
|
||||||
|
|
||||||
let next_line lexbuf =
|
|
||||||
let pos = lexbuf.lex_curr_p in
|
|
||||||
lexbuf.lex_curr_p <-
|
|
||||||
{ pos with pos_bol = lexbuf.lex_curr_pos;
|
|
||||||
pos_lnum = pos.pos_lnum + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let digit = ['0'-'9']
|
|
||||||
let digits = digit*
|
|
||||||
let alpha = ['a'-'z' 'A'-'Z']
|
|
||||||
let ident = (alpha) (alpha|digit|'_')* (* regex for identifier *)
|
|
||||||
let whitespace = [' ' '\t']+
|
|
||||||
let newline = '\r' | '\n' | "\r\n"
|
|
||||||
|
|
||||||
let int = digits
|
|
||||||
|
|
||||||
rule read =
|
|
||||||
parse
|
|
||||||
| whitespace { read lexbuf }
|
|
||||||
| newline { next_line lexbuf; read lexbuf }
|
|
||||||
| int { Int (int_of_string (Lexing.lexeme lexbuf))}
|
|
||||||
| "let" { Let }
|
|
||||||
| "if" { If }
|
|
||||||
| "then" { Then }
|
|
||||||
| "else" { Else }
|
|
||||||
| "print" { Print }
|
|
||||||
| ident { Ident (Lexing.lexeme lexbuf) }
|
|
||||||
| '(' { LParen }
|
|
||||||
| ')' { RParen }
|
|
||||||
| '[' { LBracket }
|
|
||||||
| ']' { RBracket }
|
|
||||||
| '{' { LBrace }
|
|
||||||
| '}' { RBrace }
|
|
||||||
| '.' { Dot }
|
|
||||||
| ',' { Comma }
|
|
||||||
| ':' { Colon }
|
|
||||||
| ';' { Semicolon }
|
|
||||||
| '+' { Plus }
|
|
||||||
| '-' { Minus }
|
|
||||||
| '*' { Star }
|
|
||||||
| '/' { Slash }
|
|
||||||
| '!' { Bang }
|
|
||||||
| '=' { Equal }
|
|
||||||
| "==" { EqualEqual }
|
|
||||||
| "!=" { BangEqual }
|
|
||||||
| '<' { LT }
|
|
||||||
| '>' { GT }
|
|
||||||
| "<=" { LTE }
|
|
||||||
| ">=" { GTE }
|
|
||||||
| eof { Eof }
|
|
||||||
| _ { raise (SyntaxError ("Unexpected char: " ^ Lexing.lexeme lexbuf)) }
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
(*
|
|
||||||
Notes
|
|
||||||
|
|
||||||
For now everything will be split into modules inside this one big file while prototyping.
|
|
||||||
*)
|
|
||||||
|
|
||||||
module Ast = struct
|
|
||||||
type loc = Lexing.position
|
|
||||||
type unary_op = Negate
|
|
||||||
type binary_op = Add | Subtract | Multiply | Divide
|
|
||||||
type literal = Int of int
|
|
||||||
|
|
||||||
type expr = Int of int
|
|
||||||
(* | Literal of literal *)
|
|
||||||
(* | BinaryOp of { lhs: expr; rhs: expr; operator: binary_op } *)
|
|
||||||
(* | IfElse of { condition: expr; if_expr: expr; else_expr: expr } *)
|
|
||||||
|
|
||||||
and stmt =
|
|
||||||
| Let of { loc : loc; var_name : string; bindee : expr } (* Let binding "let x = 5" *)
|
|
||||||
| FuncDecl (* TODO: arguments *)
|
|
||||||
|
|
||||||
and toplevel_item = Stmt of stmt
|
|
||||||
|
|
||||||
type builtin_type = I32 | F32 | Bool | Char
|
|
||||||
type program = toplevel_item list
|
|
||||||
end
|
|
||||||
|
|
||||||
module Typer = struct end
|
|
||||||
(** This module helps take an untyped AST and produce a typed AST *)
|
|
||||||
31
lib/parse.ml
31
lib/parse.ml
@ -1,31 +0,0 @@
|
|||||||
open Lexing
|
|
||||||
|
|
||||||
exception SyntaxError of string
|
|
||||||
|
|
||||||
(* Prints the line number and character number where the error occurred.*)
|
|
||||||
let print_error_position lexbuf =
|
|
||||||
let pos = lexbuf.lex_curr_p in
|
|
||||||
Printf.sprintf "Line:%d Position:%d" pos.pos_lnum (pos.pos_cnum - pos.pos_bol + 1)
|
|
||||||
|
|
||||||
let parse_program lexbuf =
|
|
||||||
try Ok (Oflan.prog Olexer.read lexbuf) with
|
|
||||||
| SyntaxError msg ->
|
|
||||||
let error_msg = Printf.sprintf "%s: %s\n" (print_error_position lexbuf) msg in
|
|
||||||
Error error_msg
|
|
||||||
| Oflan.Error ->
|
|
||||||
let error_msg = Printf.sprintf "%s: syntax error\n" (print_error_position lexbuf) in
|
|
||||||
Error error_msg
|
|
||||||
|
|
||||||
open Omniflan.Ast
|
|
||||||
|
|
||||||
let string_of_expr expr = match expr with Int i -> "Int " ^ string_of_int i
|
|
||||||
|
|
||||||
let string_of_stmt stmt =
|
|
||||||
match stmt with
|
|
||||||
| Let s -> Printf.sprintf "(%d) Let %s = %s" s.loc.pos_lnum s.var_name (string_of_expr s.bindee)
|
|
||||||
| FuncDecl -> failwith "TODO"
|
|
||||||
|
|
||||||
let print_ast prog =
|
|
||||||
List.iter
|
|
||||||
(fun toplevel -> match toplevel with Stmt stmt -> print_endline (string_of_stmt stmt))
|
|
||||||
prog
|
|
||||||
197
lib/reader.ml
Normal file
197
lib/reader.ml
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
(** S-expression reader.
|
||||||
|
|
||||||
|
Hand-written rather than ocamllex/menhir: a Lisp needs no parser generator,
|
||||||
|
locations come out cleaner, and it keeps the compiler dependency-free.
|
||||||
|
|
||||||
|
['x] reads as [(quote x)]. That is not macro support — it is here because
|
||||||
|
restart names are quoted symbols ([(invoke-restart 'skip-form)]) and without
|
||||||
|
it the apostrophe would silently become part of the symbol's name.
|
||||||
|
|
||||||
|
Not handled yet: quasiquote/unquote (milestone 5, with macros) and metadata
|
||||||
|
([^:async]). Metadata is rejected rather than read as a symbol, so it cannot
|
||||||
|
rot into a silently-wrong name the way quote would have. *)
|
||||||
|
|
||||||
|
type state = {
|
||||||
|
src : string;
|
||||||
|
file : string;
|
||||||
|
mutable pos : int;
|
||||||
|
mutable line : int;
|
||||||
|
mutable col : int;
|
||||||
|
}
|
||||||
|
|
||||||
|
let of_string ~file src = { src; file; pos = 0; line = 1; col = 1 }
|
||||||
|
|
||||||
|
let here st = Loc.make st.file st.line st.col
|
||||||
|
let at_end st = st.pos >= String.length st.src
|
||||||
|
let peek st = if at_end st then '\000' else st.src.[st.pos]
|
||||||
|
let peek2 st =
|
||||||
|
if st.pos + 1 >= String.length st.src then '\000' else st.src.[st.pos + 1]
|
||||||
|
|
||||||
|
let advance st =
|
||||||
|
if not (at_end st) then begin
|
||||||
|
if st.src.[st.pos] = '\n' then (st.line <- st.line + 1; st.col <- 1)
|
||||||
|
else st.col <- st.col + 1;
|
||||||
|
st.pos <- st.pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
(* Symbol constituents. Note '-' and '?' and '!' and '/' and '.' are all
|
||||||
|
ordinary: `empty-at?`, `rl/draw-fps`, `.pos`, `->>` are single symbols. *)
|
||||||
|
let is_delimiter = function
|
||||||
|
| '(' | ')' | '[' | ']' | '{' | '}' | '"' | ';' | '\000' -> true
|
||||||
|
| c -> c = ' ' || c = '\t' || c = '\n' || c = '\r' || c = ','
|
||||||
|
|
||||||
|
let is_digit c = c >= '0' && c <= '9'
|
||||||
|
|
||||||
|
let rec skip_trivia st =
|
||||||
|
match peek st with
|
||||||
|
| ' ' | '\t' | '\n' | '\r' | ',' -> advance st; skip_trivia st
|
||||||
|
| ';' ->
|
||||||
|
while (not (at_end st)) && peek st <> '\n' do advance st done;
|
||||||
|
skip_trivia st
|
||||||
|
| _ -> ()
|
||||||
|
|
||||||
|
let take_while st pred =
|
||||||
|
let start = st.pos in
|
||||||
|
while (not (at_end st)) && pred (peek st) do advance st done;
|
||||||
|
String.sub st.src start (st.pos - start)
|
||||||
|
|
||||||
|
(* ── Atoms ─────────────────────────────────────────────────────────── *)
|
||||||
|
|
||||||
|
let read_string st =
|
||||||
|
let loc = here st in
|
||||||
|
advance st; (* opening quote *)
|
||||||
|
let buf = Buffer.create 16 in
|
||||||
|
let rec go () =
|
||||||
|
if at_end st then Loc.fail loc "unterminated string"
|
||||||
|
else match peek st with
|
||||||
|
| '"' -> advance st
|
||||||
|
| '\\' ->
|
||||||
|
advance st;
|
||||||
|
let c = peek st in
|
||||||
|
advance st;
|
||||||
|
Buffer.add_char buf
|
||||||
|
(match c with
|
||||||
|
| 'n' -> '\n' | 't' -> '\t' | 'r' -> '\r'
|
||||||
|
| '\\' -> '\\' | '"' -> '"' | '0' -> '\000'
|
||||||
|
| c -> Loc.fail loc "unknown string escape \\%c" c);
|
||||||
|
go ()
|
||||||
|
| c -> advance st; Buffer.add_char buf c; go ()
|
||||||
|
in
|
||||||
|
go ();
|
||||||
|
Form.make (Form.Str (Buffer.contents buf)) loc
|
||||||
|
|
||||||
|
(* \space \tab \newline \return \nul, or \<any single char> *)
|
||||||
|
let read_byte st =
|
||||||
|
let loc = here st in
|
||||||
|
advance st; (* backslash *)
|
||||||
|
if at_end st then Loc.fail loc "expected a character after \\";
|
||||||
|
let first = peek st in
|
||||||
|
advance st;
|
||||||
|
let rest = take_while st (fun c -> not (is_delimiter c)) in
|
||||||
|
let name = String.make 1 first ^ rest in
|
||||||
|
let code = match name with
|
||||||
|
| "space" -> 32
|
||||||
|
| "tab" -> 9
|
||||||
|
| "newline" -> 10
|
||||||
|
| "return" -> 13
|
||||||
|
| "nul" -> 0
|
||||||
|
| n when String.length n = 1 -> Char.code n.[0]
|
||||||
|
| n -> Loc.fail loc "unknown character literal \\%s" n
|
||||||
|
in
|
||||||
|
Form.make (Form.Byte code) loc
|
||||||
|
|
||||||
|
(* A token that started with a digit, or with '-'/'+' followed by a digit. *)
|
||||||
|
let read_number st =
|
||||||
|
let loc = here st in
|
||||||
|
let text = take_while st (fun c -> not (is_delimiter c)) in
|
||||||
|
let is_hex =
|
||||||
|
String.length text > 2
|
||||||
|
&& text.[0] = '0'
|
||||||
|
&& (text.[1] = 'x' || text.[1] = 'X')
|
||||||
|
in
|
||||||
|
if is_hex then
|
||||||
|
match Int64.of_string_opt text with
|
||||||
|
| Some i -> Form.make (Form.Int i) loc
|
||||||
|
| None -> Loc.fail loc "malformed hex literal %s" text
|
||||||
|
else if String.contains text '.' || String.contains text 'e' then
|
||||||
|
match float_of_string_opt text with
|
||||||
|
| Some f -> Form.make (Form.Float f) loc
|
||||||
|
| None -> Loc.fail loc "malformed float literal %s" text
|
||||||
|
else
|
||||||
|
match Int64.of_string_opt text with
|
||||||
|
| Some i -> Form.make (Form.Int i) loc
|
||||||
|
| None -> Loc.fail loc "malformed integer literal %s" text
|
||||||
|
|
||||||
|
let read_symbol_or_keyword st =
|
||||||
|
let loc = here st in
|
||||||
|
let text = take_while st (fun c -> not (is_delimiter c)) in
|
||||||
|
if text = "" then Loc.fail loc "unexpected character %C" (peek st);
|
||||||
|
if text.[0] = ':' then begin
|
||||||
|
if String.length text = 1 then Loc.fail loc "empty keyword";
|
||||||
|
Form.make (Form.Kw (String.sub text 1 (String.length text - 1))) loc
|
||||||
|
end else
|
||||||
|
Form.make (Form.Sym text) loc
|
||||||
|
|
||||||
|
(* ── Forms ─────────────────────────────────────────────────────────── *)
|
||||||
|
|
||||||
|
let closer = function
|
||||||
|
| '(' -> ')' | '[' -> ']' | '{' -> '}'
|
||||||
|
| _ -> assert false
|
||||||
|
|
||||||
|
let wrap open_c items =
|
||||||
|
match open_c with
|
||||||
|
| '(' -> Form.List items
|
||||||
|
| '[' -> Form.Vec items
|
||||||
|
| '{' -> Form.Map items
|
||||||
|
| _ -> assert false
|
||||||
|
|
||||||
|
let rec read_form st =
|
||||||
|
skip_trivia st;
|
||||||
|
let loc = here st in
|
||||||
|
match peek st with
|
||||||
|
| '\000' -> Loc.fail loc "unexpected end of input"
|
||||||
|
| '(' | '[' | '{' as open_c -> read_seq st open_c loc
|
||||||
|
| ')' | ']' | '}' as c -> Loc.fail loc "unbalanced %C" c
|
||||||
|
| '"' -> read_string st
|
||||||
|
| '\\' -> read_byte st
|
||||||
|
| '\'' ->
|
||||||
|
advance st;
|
||||||
|
let quoted = read_form st in
|
||||||
|
Form.make (Form.List [ Form.make (Form.Sym "quote") loc; quoted ]) loc
|
||||||
|
| '^' ->
|
||||||
|
Loc.fail loc "metadata (^) is not supported yet"
|
||||||
|
|
||||||
|
| c when is_digit c -> read_number st
|
||||||
|
| ('-' | '+') when is_digit (peek2 st) -> read_number st
|
||||||
|
| _ -> read_symbol_or_keyword st
|
||||||
|
|
||||||
|
and read_seq st open_c loc =
|
||||||
|
advance st;
|
||||||
|
let want = closer open_c in
|
||||||
|
let rec go acc =
|
||||||
|
skip_trivia st;
|
||||||
|
if at_end st then
|
||||||
|
Loc.fail loc "unclosed %C, expected %C" open_c want
|
||||||
|
else
|
||||||
|
let c = peek st in
|
||||||
|
if c = want then (advance st; List.rev acc)
|
||||||
|
else if c = ')' || c = ']' || c = '}' then
|
||||||
|
Loc.fail (here st) "expected %C to close %C, found %C" want open_c c
|
||||||
|
else go (read_form st :: acc)
|
||||||
|
in
|
||||||
|
Form.make (wrap open_c (go [])) loc
|
||||||
|
|
||||||
|
(** All top-level forms in a source string. *)
|
||||||
|
let read_all ~file src =
|
||||||
|
let st = of_string ~file src in
|
||||||
|
let rec go acc =
|
||||||
|
skip_trivia st;
|
||||||
|
if at_end st then List.rev acc else go (read_form st :: acc)
|
||||||
|
in
|
||||||
|
go []
|
||||||
|
|
||||||
|
let read_file path =
|
||||||
|
let ic = open_in_bin path in
|
||||||
|
Fun.protect ~finally:(fun () -> close_in ic) (fun () ->
|
||||||
|
let n = in_channel_length ic in
|
||||||
|
read_all ~file:path (really_input_string ic n))
|
||||||
54
overview.md
Normal file
54
overview.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Overview — superseded
|
||||||
|
|
||||||
|
This was the first brainstorm. It is kept for history and is **no longer
|
||||||
|
accurate**. Read instead:
|
||||||
|
|
||||||
|
- `plan.org` — the design, the build sequence, and the open decisions
|
||||||
|
- `spec-memory.md` — ownership, containers, places, generics, function values
|
||||||
|
- `spec-conditions.md` — conditions and restarts, operational semantics
|
||||||
|
- `sand.flan` — the first acceptance program
|
||||||
|
- `syntax-sketch.flan` — the syntax, annotated with the decisions above
|
||||||
|
|
||||||
|
## What changed since
|
||||||
|
|
||||||
|
| This document said | Now |
|
||||||
|
|---|---|
|
||||||
|
| "C-like with Roc syntax" | S-expressions, Clojure's brackets; C's value model |
|
||||||
|
| "Start with interpreter, output C later" | Frontend → typed IR → LLVM IR as text → `clang`. An interpreter is acceptable for milestone 2 only |
|
||||||
|
| "lists, slice, fixed-length array, matrices" | Four container types, distinct ownership: `[n T]`, `[T]`, `(Vec T)`, `(Map K V)` — see spec-memory.md |
|
||||||
|
| `arr[4]`, `arr[1..]` index syntax | `(at a i)`, `(as-slice a lo hi)` — no infix, no bracket indexing |
|
||||||
|
| `const by default?` | Locals are assignable places; parameters are not; `const` qualifies slices and pointers |
|
||||||
|
| Rust-style iterator chains ending in `.collect()` | `->>` threading over slices; every collecting operation allocates from an explicit allocator, usually the frame arena |
|
||||||
|
| `Ptr a` | Kept, as `(Ptr a)`. Cross-references use `(Handle a)` instead |
|
||||||
|
| option/result in the stdlib | Kept, and layered with conditions — see plan.org "Error handling, layered" |
|
||||||
|
|
||||||
|
## Original text
|
||||||
|
|
||||||
|
```
|
||||||
|
Concepts:
|
||||||
|
|
||||||
|
- Primitives
|
||||||
|
- u8, i32, f32, bool, char
|
||||||
|
- lists, slice, fixed-length array builtin, matrices
|
||||||
|
- Control flow
|
||||||
|
- for, while, break
|
||||||
|
- Structs & tuples
|
||||||
|
- Let bindings
|
||||||
|
- `Ptr a`
|
||||||
|
- Pattern matching
|
||||||
|
- ADTs
|
||||||
|
- Mutability
|
||||||
|
- const by default?
|
||||||
|
- Functions
|
||||||
|
- Array syntax
|
||||||
|
- ranges `arr[1..]`, `arr[..3]`
|
||||||
|
- index `arr[4]`
|
||||||
|
- Stdlib
|
||||||
|
- string
|
||||||
|
- vec/dynarray
|
||||||
|
- hashtable
|
||||||
|
- option/result
|
||||||
|
|
||||||
|
C-like with Roc syntax.
|
||||||
|
Start with interpreter. Output C later down the track
|
||||||
|
```
|
||||||
687
plan.org
Normal file
687
plan.org
Normal file
@ -0,0 +1,687 @@
|
|||||||
|
#+TITLE: Flan — Design Plan
|
||||||
|
#+DATE: 2026-09-10
|
||||||
|
|
||||||
|
* Specs
|
||||||
|
Two documents are normative and are settled ahead of implementation. Anything in
|
||||||
|
this plan that contradicts them is out of date.
|
||||||
|
- [[file:spec-memory.md][spec-memory.md]] — ownership, the four container types,
|
||||||
|
copies and moves, assignable places, generics without type classes, function
|
||||||
|
values.
|
||||||
|
- [[file:spec-conditions.md][spec-conditions.md]] — the six hard cases of
|
||||||
|
conditions/restarts: what ~signal~ returns, no-handler behaviour, restart
|
||||||
|
signatures, name shadowing, cleanup during a transfer, and crossing
|
||||||
|
compiler-generated and foreign frames.
|
||||||
|
|
||||||
|
* What Flan is
|
||||||
|
A minimal Lisp for game development. Clojure's brackets and a small slice of its
|
||||||
|
API, C's memory and value model. No GC.
|
||||||
|
|
||||||
|
Not a Common Lisp, not a Clojure. In one line: *Odin with a Lisp frontend and a
|
||||||
|
live REPL.*
|
||||||
|
|
||||||
|
** References
|
||||||
|
- *Odin* — closest existing language. LLVM backend, manual memory, no GC, ships
|
||||||
|
amd64 + arm64 + wasm32 (~js_wasm32~, ~wasi_wasm32~, ~freestanding_wasm32~).
|
||||||
|
Proves this exact pipeline. Take directly:
|
||||||
|
- ~context.allocator~ / ~context.temp_allocator~ (already this plan's design;
|
||||||
|
~free_all(context.temp_allocator)~ per frame /is/ the frame arena)
|
||||||
|
- ~$T~ compile-time parametric polymorphism — monomorphisation without a heavy
|
||||||
|
type system
|
||||||
|
- ~#soa~ struct-of-arrays syntax
|
||||||
|
- fixed arrays with component-wise ops and swizzles (~[4]f32~, ~v.xyzw~),
|
||||||
|
built-in ~matrix~ type
|
||||||
|
- ~defer~, tagged unions, ~distinct~ types, bit sets
|
||||||
|
- ~vendor:raylib~
|
||||||
|
Flan diverges by adding what Odin deliberately lacks: s-expressions, macros,
|
||||||
|
conditions/restarts, hot reload, interactive development.
|
||||||
|
- *SBCL* — indirection cells for redefinition, conditions/restarts, the break loop.
|
||||||
|
- *Janet* — reference only; its immutable/mutable split is rejected (see Data model).
|
||||||
|
- *Carp* — statically typed Lisp with inference, no GC (ownership-based), compiles
|
||||||
|
to C, aimed at games. Closest precedent for the /language/ shape, as Odin is for
|
||||||
|
the /implementation/ shape.
|
||||||
|
- *jank* — cautionary; see Hot reload.
|
||||||
|
|
||||||
|
* Non-goals
|
||||||
|
- Numeric tower (no bignums, rationals, complex)
|
||||||
|
- CLOS/MOP, ~format~, pathnames, streams, sequences-over-anything, CL reader
|
||||||
|
- Clojure's lazy seqs, JVM interop, ~core.async~
|
||||||
|
- Persistent collections, and immutable collection types generally (see Data model)
|
||||||
|
- Live-image development at SBCL's level
|
||||||
|
- Consoles
|
||||||
|
|
||||||
|
* Memory — no GC
|
||||||
|
Allocators all the way down; ~malloc~ hidden behind them.
|
||||||
|
|
||||||
|
| Tier | Strategy | Cost |
|
||||||
|
|-----------+---------------------------------+----------------|
|
||||||
|
| Frame | arena, bulk reset each frame | free |
|
||||||
|
| Entities | pool + generational handles | free |
|
||||||
|
| Subsystem | region, freed wholesale | free |
|
||||||
|
| Dev/REPL | leaks by design, reset on reload| dev only |
|
||||||
|
|
||||||
|
- Allocator is part of the calling convention, so a refcounted allocator can be
|
||||||
|
added later without a language change.
|
||||||
|
- Generational handles instead of pointers for cross-references: a stale
|
||||||
|
reference is detectable, not undefined behaviour.
|
||||||
|
- Symbols and code live in a permanent arena that only grows.
|
||||||
|
|
||||||
|
** Why no persistent collections
|
||||||
|
Structure sharing destroys clear ownership, which is the only thing that forces a
|
||||||
|
collector. Replaced by value structs that copy on assignment — see Data model.
|
||||||
|
|
||||||
|
** The consequence that drives everything
|
||||||
|
No GC means *no object headers*. Every struct is exactly its C layout, arrays are
|
||||||
|
C arrays, so there is no marshalling layer and no wrapper allocation.
|
||||||
|
|
||||||
|
FFI is still a boundary — ownership, who allocates, string and slice
|
||||||
|
representation, struct padding, callbacks into Flan, error handling, and the
|
||||||
|
platform ABI all remain real work. What C layout buys is that the *data* crosses
|
||||||
|
for free. See spec-conditions.md §6 for the one hard rule: a restart transfer
|
||||||
|
cannot cross a foreign frame.
|
||||||
|
|
||||||
|
* Data model
|
||||||
|
No immutable collection types. Value semantics plus ~const~, as in C/Zig/Odin —
|
||||||
|
not Janet's tuple/struct vs array/table split, which was an answer to a GC'd
|
||||||
|
world.
|
||||||
|
|
||||||
|
- Four container types, distinct in both type and ownership — see spec-memory.md,
|
||||||
|
which is normative:
|
||||||
|
| Notation | Layout | Assignment | Owns |
|
||||||
|
|-------------+-----------------+------------------+------|
|
||||||
|
| ~[n T]~ | inline, n items | copies | no |
|
||||||
|
| ~[T]~ | ptr+len | copies the view | no |
|
||||||
|
| ~(Vec T)~ | ptr+len+cap | *moves* | yes |
|
||||||
|
| ~(Map K V)~ | open addressing | *moves* | yes |
|
||||||
|
~Vec~ and ~Map~ are monomorphic on element type and record their allocator. Not
|
||||||
|
a Lua-style array/hash hybrid — that is what makes Lua's layout and performance
|
||||||
|
unpredictable.
|
||||||
|
- Operations: ~get~, ~put~, ~remove~, ~push~, ~pop~, ~nth~, ~len~, ~update~.
|
||||||
|
Copying is explicit: ~(clone m)~, and owning containers move rather than copy on
|
||||||
|
assignment. No ~!~ convention — nothing is immutable, so it
|
||||||
|
would carry no information. No ~assoc~; it only existed as the copy-returning form.
|
||||||
|
- ~const~ qualifier on references and slices: compile-time contract that a
|
||||||
|
callee will not mutate. Zero runtime cost.
|
||||||
|
- Value structs copy on assignment — but only *value* structs. Ownership is
|
||||||
|
structural: a struct is a value type iff every field is, so one ~Vec~ field
|
||||||
|
makes it move-only. This is what keeps "copies on assignment" from meaning a
|
||||||
|
shallow copy that aliases owned storage. Deep copies are always explicit:
|
||||||
|
~(clone x)~. Value structs are the snapshot / undo / replay story; they need no
|
||||||
|
separate type.
|
||||||
|
- Literals live in read-only memory.
|
||||||
|
- Struct literals name fields: ~(Cursor {:src src :pos 0})~. *Omitted fields are
|
||||||
|
zeroed*, as in Odin — the same rule as a declaration with no initialiser, so
|
||||||
|
~(Cursor {:src src})~ is complete and means ~pos~ is 0.
|
||||||
|
- *Zero is initialisation (ZII), with an opt-out.* No initialiser means
|
||||||
|
all-bytes-zero. ~(defvar buf [65536 u8] uninit)~ skips it, exactly as Odin's
|
||||||
|
~---~ does, for a large buffer that is about to be overwritten. ~uninit~ is
|
||||||
|
greppable and rare by design; reading an ~uninit~ value before writing it is
|
||||||
|
undefined, and dev builds poison the memory so the bug is loud.
|
||||||
|
- Slices as ptr+len, non-owning; raw pointers ~(Ptr T)~ with explicit ~deref~;
|
||||||
|
visible casts. ~resolve~ yields ~(Ptr a)~ where ~deref~ yields a value — that is
|
||||||
|
how a matched struct is mutated in place rather than as a copy.
|
||||||
|
- Flat and SoA arrays.
|
||||||
|
- No implicit allocation anywhere in the core.
|
||||||
|
|
||||||
|
Immutability also serves the optimiser: a value known never to be mutated can be
|
||||||
|
copied into registers and stack-allocated freely. Mutability is what forces heap
|
||||||
|
identity.
|
||||||
|
|
||||||
|
* Types
|
||||||
|
*Statically typed.* Not forced — tagging and boxing give dynamic typing without a
|
||||||
|
collector, as Forth-lineage and refcounted dynamic languages show. It is chosen,
|
||||||
|
and the reason is that dynamic typing would require paying a tag word on every
|
||||||
|
value, which is exactly the header cost dropping the GC was meant to avoid. Under
|
||||||
|
static typing the tag is paid only where it is asked for, in ~any~.
|
||||||
|
|
||||||
|
- Types are mandatory; *inference* makes them feel optional. Annotate function
|
||||||
|
signatures, infer locals — Odin/Zig/Rust ergonomics.
|
||||||
|
- Signatures are annotated as inline name/type pairs, as in ~let~ and
|
||||||
|
~defstruct~: ~(defn area [s Shape] f32 ...)~. No separate ~declare~ form —
|
||||||
|
~declare~ is kept only where there is no body (forward declarations, FFI).
|
||||||
|
- Annotations at function boundaries are unavoidable, because compile-time
|
||||||
|
overloading is incompatible with full inference. Locals are inferred.
|
||||||
|
- An omitted return type means ~Unit~ — a real zero-sized type with one value,
|
||||||
|
not C's ~void~. Generic code over it works, so there is no ~Action~/~Func~ split.
|
||||||
|
- Every type notation reads as exactly one data item: ~[f32]~, ~[4 f32]~,
|
||||||
|
~(Vec f32)~, ~{string i32}~, ~(Ptr World)~, ~(Fn [f32] bool)~, ~(Option a)~,
|
||||||
|
~(Handle a)~.
|
||||||
|
- ~i8..i64~, ~u8..u64~, ~f32~, ~f64~ as real machine types; wrapping arithmetic.
|
||||||
|
- Vector width 128-bit. Fixed arrays with component-wise ops and swizzles.
|
||||||
|
- Parametric polymorphism by monomorphisation (Odin's model, no type classes, no
|
||||||
|
HKTs). Lowercase type names are variables, Capitalized are concrete — no sigil.
|
||||||
|
This is what makes ~map~/~filter~/~reduce~ and the monomorphic containers work.
|
||||||
|
The price, made explicit in spec-memory.md: with no constraints, a type variable
|
||||||
|
supports only what every type supports. ~=~, ~<~, ~+~, ~hash~ and ~print~ over an
|
||||||
|
unconstrained ~a~ are rejected, not silently instantiated — they are passed in as
|
||||||
|
function values. Compile-time interfaces, if they are ever wanted, come after the
|
||||||
|
base checker is stable.
|
||||||
|
- Function values split three ways (spec-memory.md): ~(Fn [T1 T2] R)~ is a plain
|
||||||
|
pointer with no environment — the only kind that crosses FFI or sits in a reload
|
||||||
|
cell; a *non-escaping* ~fn~ captures enclosing locals by value into a stack
|
||||||
|
environment, which is what ~reduce~ callbacks and ~handler-bind~ handlers use;
|
||||||
|
an *escaping* closure needs a heap environment and is still an open decision.
|
||||||
|
- No monads, no HKTs, no type classes. Effects are direct; error handling is
|
||||||
|
conditions plus ~Option~ and ~or-else~. Monadic sequencing, if ever wanted, is a
|
||||||
|
macro.
|
||||||
|
- ~any~ is an explicit opt-in tagged union, for heterogeneous containers and debug
|
||||||
|
printing. It is the only place a tag word is paid.
|
||||||
|
- Tagged unions, ~distinct~ types, bit sets.
|
||||||
|
|
||||||
|
** Consequences
|
||||||
|
- Multimethods are *compile-time overload resolution*, not runtime dispatch.
|
||||||
|
Genuine runtime dispatch is on an explicit enum or tagged union.
|
||||||
|
- Conditions still work: a condition is a struct, the signal channel is a tagged
|
||||||
|
union, ~handler-case~ type matching resolves at compile time.
|
||||||
|
- The REPL works — the compiler knows each site's type and emits the right printer,
|
||||||
|
as in the OCaml and Haskell REPLs.
|
||||||
|
- Macros are unaffected; they run on syntax before typing.
|
||||||
|
|
||||||
|
* Semantics kept
|
||||||
|
** Conditions and restarts
|
||||||
|
The headline feature. Four operators: ~handler-bind~, ~handler-case~,
|
||||||
|
~restart-case~, ~invoke-restart~. No condition class hierarchy — struct types plus
|
||||||
|
predicate matching. *Operational semantics: spec-conditions.md, which is
|
||||||
|
normative.* The ~500-line estimate below was for the operators alone and does not
|
||||||
|
include the explicit transfer lowering, which is compiler work.
|
||||||
|
|
||||||
|
~signal~ does not mean "fail". It means: here is something notable, here is the
|
||||||
|
data, and here are the ways I know how to continue. Restarts are a menu; a handler
|
||||||
|
installed by an outer caller reads the data and picks one — or picks nothing and
|
||||||
|
returns, in which case the signaller simply carries on.
|
||||||
|
|
||||||
|
| Handler does | Behaviour you get |
|
||||||
|
|---------------------+-------------------------|
|
||||||
|
| returns normally | accumulate and continue |
|
||||||
|
| invokes a restart | recover / retry / substitute |
|
||||||
|
| unwinds (~handler-case~) | try/catch |
|
||||||
|
|
||||||
|
*Why this replaces most error types.* A condition signalled deep in a call stack
|
||||||
|
never appears in intermediate signatures. Nothing to thread, no ~From~ conversions,
|
||||||
|
no ~anyhow~ equivalent. Compare Rust, where every layer must name every error type
|
||||||
|
it passes through.
|
||||||
|
|
||||||
|
*Accumulation.* A handler that records and invokes a ~continue~-style restart gives
|
||||||
|
error collection with no applicative or monad. This is how CL compilers report every
|
||||||
|
error in one pass.
|
||||||
|
|
||||||
|
*Costs nothing when unused.* The handler stack is a linked list of stack-allocated
|
||||||
|
frames: ~handler-bind~ is a couple of stores, ~signal~ with no handler is a null
|
||||||
|
check. No allocation, safe inside a frame loop.
|
||||||
|
|
||||||
|
*Condition objects live on the signalling frame's stack*, since nothing unwinds
|
||||||
|
before the handler runs. Because conditions are value structs, accumulating one into
|
||||||
|
an outliving array copies it. A pointer-to-condition would dangle.
|
||||||
|
|
||||||
|
*Under static typing.* Restarts are dynamically scoped and named, so
|
||||||
|
~(invoke-restart 'skip-form)~ cannot be fully checked at compile time. Accept a
|
||||||
|
runtime error initially; a statically tracked restart set (as Zig tracks error sets)
|
||||||
|
is a nice-to-have, not a blocker. ~signal~ has type ~Unit~, ~invoke-restart~ and
|
||||||
|
~error~ have type ~Never~, and every restart clause shares one type with the
|
||||||
|
~restart-case~ body — so a ~restart-case~ in value position needs a fall-through
|
||||||
|
that produces the type or diverges.
|
||||||
|
|
||||||
|
*Unwinding* is only the /transfer/ — invoking an outer restart. Lowered explicitly,
|
||||||
|
see Compilation.
|
||||||
|
|
||||||
|
** Error handling, layered
|
||||||
|
1. ~Option~ for expected absence: lookup miss, empty collection, end of stream.
|
||||||
|
2. Conditions for exceptional failure where a caller may have a recovery policy.
|
||||||
|
3. ~Result~ where failure should be visible in the signature (parsers, fallible
|
||||||
|
pure functions). Error sets *inferred* from the body (Zig's model) so ~try~
|
||||||
|
widens callers automatically; explicit sets required on exported functions.
|
||||||
|
|
||||||
|
Rule of thumb: if you can name the one correct recovery at the point of failure,
|
||||||
|
return a ~Result~. If the answer is "depends who is calling", signal a condition.
|
||||||
|
|
||||||
|
Mechanisms — two unwrap operators, because they are two different things (Zig's
|
||||||
|
split):
|
||||||
|
- ~try~ — unwrap ~Ok~, else early-return ~Err~, widening the enclosing error set
|
||||||
|
- ~some~ — unwrap ~Some~, else early-return ~None~
|
||||||
|
- ~(ok-or opt err)~ / ~(ok res)~ — conversions, always *explicit*. This is the one
|
||||||
|
thing Rust got right and ~From~/~anyhow~ got wrong: the noise is not ~?~, it is
|
||||||
|
the implicit conversion machinery ~?~ demands. ~try~ does *not* accept an
|
||||||
|
~Option~ in a ~Result~-returning function.
|
||||||
|
- ~some->~ (short-circuiting thread), ~or-else~, ~if-let~
|
||||||
|
- ~errdefer~ — cleanup on the failure path only, pairs with arenas
|
||||||
|
|
||||||
|
Async composes by nesting, no new mechanism: ~(try (await (http-get url)))~ —
|
||||||
|
~await~ unwraps the task, ~try~ unwraps the ~Result~ inside it.
|
||||||
|
|
||||||
|
~try~ and ~some~ are macros expanding to early returns, and early return is the
|
||||||
|
explicit non-local-exit lowering, so all of this compiles identically on native and
|
||||||
|
wasm32.
|
||||||
|
|
||||||
|
*Restarts go at the resync point, once* — the loop over top-level forms in a
|
||||||
|
parser, not inside every function below it. Intermediate frames stay silent about
|
||||||
|
restarts for the same reason they stay silent about conditions.
|
||||||
|
|
||||||
|
No monads, no HKTs. Chaining that would want do-notation is a macro.
|
||||||
|
|
||||||
|
*Async coupling:* with a state-machine transform the handler stack must live in the
|
||||||
|
/task/ state, not thread-local, or a handler established before an ~await~ is out of
|
||||||
|
scope after resumption. Cheap if designed in, painful later.
|
||||||
|
|
||||||
|
** Multimethods
|
||||||
|
Compile-time overload resolution on argument types. No precedence lists, no method
|
||||||
|
combination, no MOP, no runtime dispatch table — see Types.
|
||||||
|
|
||||||
|
** Macros
|
||||||
|
- Needs a ~&env~ equivalent: macros must see lexical environment (names of
|
||||||
|
locals in scope). Required by the step debugger. Decide now, painful to retrofit.
|
||||||
|
- Hygiene model: open decision.
|
||||||
|
|
||||||
|
* Host language
|
||||||
|
*OCaml.* The compiler only; the runtime and stdlib are Flan with a few C
|
||||||
|
primitives, and are never bootstrapped away.
|
||||||
|
|
||||||
|
The LLVM question does not bear on this: the release backend emits LLVM IR *as
|
||||||
|
text* and shells out to ~clang~, so no language needs LLVM bindings, and C++ or
|
||||||
|
Rust buy nothing here. What the choice actually turns on is that milestones 2–5
|
||||||
|
are a reader, a typed IR, a checker and a tree-walking interpreter — variants and
|
||||||
|
exhaustive pattern matching, which is the one domain where OCaml is not a
|
||||||
|
preference but a clear win. There is also a menhir lexer/parser already started
|
||||||
|
in ~old-ocaml/~.
|
||||||
|
|
||||||
|
The honest alternative is Rust, and it wins on exactly one axis: if the compiler
|
||||||
|
is a language you will not enjoy maintaining in three months, that outweighs
|
||||||
|
being 30% shorter. Nothing technical breaks either way.
|
||||||
|
|
||||||
|
*Self-hosting is not a goal* and must not drive this. It appears nowhere in the
|
||||||
|
build sequence. For a game language it buys dogfooding at the price of a second
|
||||||
|
compiler to maintain forever. Choose as if the host language is permanent.
|
||||||
|
|
||||||
|
* Milestone-2 primitives
|
||||||
|
The interpreter provides these; everything else is written in Flan. Keeping the
|
||||||
|
list short is the whole strategy — it is what makes the LLVM backend and the
|
||||||
|
wasm32 target cheap, because a primitive is the only thing implemented twice.
|
||||||
|
|
||||||
|
| Primitive | Notes |
|
||||||
|
|------------------------+-------|
|
||||||
|
| ~argv~ | ~[string]~, borrowed, never freed |
|
||||||
|
| ~write-stdout~ | takes ~[u8]~; the ONE output primitive |
|
||||||
|
| ~exit~ | ~i32~ status |
|
||||||
|
| ~len~ ~at~ ~slice~ | on fixed arrays and slices |
|
||||||
|
| ~bytes~ | ~string~ → ~[u8]~, a view, no copy |
|
||||||
|
| ~bytes->f64~ ~bytes->i64~ | and the inverses, for printing |
|
||||||
|
| ~addr~ | address of a place |
|
||||||
|
| arithmetic, comparison, casts | per machine type |
|
||||||
|
|
||||||
|
Printing is *not* a primitive. ~print-str~, ~print-f64~ and friends are Flan
|
||||||
|
functions over ~write-stdout~. A single overloaded ~println~ waits for milestone
|
||||||
|
5 — until then the acceptance programs name the type, because compile-time
|
||||||
|
overloading before the checker is stable is how a small language stops being one.
|
||||||
|
|
||||||
|
*Entry point.* ~(defn main [args [string]] i32)~. Both the parameter and the
|
||||||
|
return type are optional: omitting ~args~ means the program ignores argv,
|
||||||
|
omitting the return type means ~Unit~ and an exit status of 0. sand.flan uses
|
||||||
|
the short form, calc-me the long one.
|
||||||
|
|
||||||
|
*RNG is ours, not libc's.* ~rand-f32~ is a seeded PRNG implemented in Flan
|
||||||
|
(xoshiro or PCG), because a grid hash is only a regression test if the sequence
|
||||||
|
is byte-identical on native and wasm32. Decided here rather than at milestone 4,
|
||||||
|
since a headless deterministic sand run is the cross-target test.
|
||||||
|
|
||||||
|
* Modules
|
||||||
|
There *are* modules — Odin calls them packages, and so does Flan. What is removed
|
||||||
|
is Clojure's ~ns~ form: no path that must mirror the directory, no
|
||||||
|
~:require~/~:refer~/~:as~/~:import~ vocabulary, no per-file namespace object.
|
||||||
|
|
||||||
|
- *The directory is the package.* Every file in a directory shares one top-level
|
||||||
|
scope. Files in a package do not import each other, and top-level names are
|
||||||
|
order-independent, so mutually recursive functions need no forward declaration.
|
||||||
|
- *The package declaration is optional*, which is the one place Flan diverges
|
||||||
|
from Odin — Odin requires ~package foo~ as the first line of every file and
|
||||||
|
requires it to agree across the directory. Flan infers the package name from
|
||||||
|
the directory name, and ~(package parser)~ is written only when the name must
|
||||||
|
differ from the directory (a directory named ~flan-parser~, a scratch directory
|
||||||
|
with a name that is not an identifier). When present it must agree across the
|
||||||
|
directory, as in Odin.
|
||||||
|
- *A loose file in ~~/scratch/~ is a package of one.* No project file, no
|
||||||
|
manifest, no declaration. Open it, connect the REPL, start working. The
|
||||||
|
ceremony budget for "new file, running REPL" is zero — this is the requirement
|
||||||
|
the whole scheme is designed around, and it is why the declaration is optional
|
||||||
|
rather than mandatory.
|
||||||
|
- *Cross-package:* ~(import rl "vendor:raylib")~, and everything from it is
|
||||||
|
qualified ~rl/foo~. One form, one meaning, no unqualified-import mode.
|
||||||
|
- Collections in the path (~vendor:~, ~core:~) are Odin's, and are just
|
||||||
|
root-directory aliases.
|
||||||
|
|
||||||
|
* Compilation
|
||||||
|
*Two backends and three paths.* The split is not dev-vs-release; it is
|
||||||
|
/does this code have a frame budget/.
|
||||||
|
|
||||||
|
#+begin_src
|
||||||
|
expression eval: flan → typed IR → interpreter ~1ms
|
||||||
|
dev redefinition: flan → typed IR → .ll → llc → ld -shared → dlopen → cell store
|
||||||
|
~16ms (MEASURED)
|
||||||
|
release build: flan → typed IR → .ll → clang --target={native,wasm32}
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
*Hard requirement: eval is immediate.* Not "fast enough for a build" — immediate,
|
||||||
|
because the whole point of the live loop is that you see the result. 16ms is one
|
||||||
|
frame at 60fps and under the ~50ms threshold where a response stops feeling
|
||||||
|
instantaneous. The rule that buys it: *never invoke the ~clang~ driver on the dev
|
||||||
|
path.*
|
||||||
|
|
||||||
|
*Expression eval* — ~C-c C-e~, calling a function, inspecting a var, running a
|
||||||
|
test — goes to the tree-walking interpreter. Sub-millisecond, no subprocess. This
|
||||||
|
is the permanent REPL backend, not a milestone-2 scaffold.
|
||||||
|
|
||||||
|
*Dev redefinition* — ~C-c C-c~ on a function inside a running game — cannot use
|
||||||
|
the interpreter, because that code has an 8ms frame budget. It recompiles the one
|
||||||
|
function, links it, and does the atomic indirection-cell store. This is what the
|
||||||
|
Hot reload section has always described; the interpreter does not replace it.
|
||||||
|
|
||||||
|
*Release* is whole-program AOT with direct calls and no cells.
|
||||||
|
|
||||||
|
** Measured redefinition latency
|
||||||
|
Single function, x86-64, clang 20.1.8, 20 iterations each:
|
||||||
|
|
||||||
|
| Step | Per call | Dev path? |
|
||||||
|
|------------------------------------------+----------+-----------|
|
||||||
|
| ~clang -shared~ (driver: compile + link) | 52.0ms | *no* |
|
||||||
|
| ~clang -c~ (driver: compile only) | 22.5ms | no |
|
||||||
|
| ~llc -filetype=obj~ | 13.9ms | yes |
|
||||||
|
| ~ld -shared~ from the ~.o~ | 2.4ms | yes |
|
||||||
|
|
||||||
|
~llc~ + ~ld~ + ~dlopen~ ≈ *16ms*. The clang driver is the cost, not codegen —
|
||||||
|
it forks a second process and re-does argument and target resolution. Codegen
|
||||||
|
itself barely scales with function size: 721 lines of IR took 16.7ms against
|
||||||
|
13.9ms for 8 lines, because ~10ms is ~llc~ startup loading libLLVM. A realistic
|
||||||
|
redefined function lands in the same 15–17ms.
|
||||||
|
|
||||||
|
This is why an in-process ORC JIT is not needed. It would take 16ms to ~3ms; the
|
||||||
|
difference is below perception, and the price is a version-pinned libLLVM and C++
|
||||||
|
linkage from the host language, forever.
|
||||||
|
|
||||||
|
** Redefinition must not stutter the running game
|
||||||
|
The 16ms is /not/ paid by the game thread. ~llc~ and ~ld~ are already separate
|
||||||
|
processes running on other cores. What the game process does is smaller:
|
||||||
|
|
||||||
|
| Step | Cost | Game thread? |
|
||||||
|
|----------------------------------+-----------+--------------|
|
||||||
|
| ~llc~, ~ld~ | 16.3ms | no, separate processes |
|
||||||
|
| ~dlopen~ the new ~.so~ | ~0.1–1ms | *must not be* |
|
||||||
|
| atomic store into the cell | ns | yes, and free |
|
||||||
|
|
||||||
|
Two design choices are load-bearing, and neither is automatic:
|
||||||
|
|
||||||
|
1. *~dlopen~ happens on the reload thread.* It mmaps, relocates and takes the
|
||||||
|
loader lock; off-thread it blocks nobody, because the game thread is not doing
|
||||||
|
dynamic linking. Load with ~RTLD_NOW~ so lazy PLT resolution cannot ambush the
|
||||||
|
game thread on a later first call.
|
||||||
|
2. *Publish at a frame boundary, in a batch.* This matters more than the
|
||||||
|
threading. Storing each cell the moment it is ready lets the game observe a
|
||||||
|
half-applied redefinition — two functions that changed together applied one
|
||||||
|
frame apart, or a function swapped mid-frame with half the entities already
|
||||||
|
updated by the old code. Instead the reload thread stages the complete set of
|
||||||
|
new pointers and sets a flag; the game loop tests the flag once at the top of
|
||||||
|
the frame and does N stores. One relaxed atomic load per frame when nothing
|
||||||
|
changed.
|
||||||
|
|
||||||
|
Residual cost: the first call into new code page-faults and misses i-cache. Tens
|
||||||
|
of microseconds, not visible.
|
||||||
|
|
||||||
|
*** Dev architecture: daemon plus agent
|
||||||
|
- *Compiler daemon*, a separate process: the OCaml frontend, the nREPL server,
|
||||||
|
and the ~llc~/~ld~ invocations. Editors talk to this.
|
||||||
|
- *Reload agent*, linked into the game binary: a socket listener, ~dlopen~, and
|
||||||
|
the frame-boundary cell publisher. A few hundred lines, and no OCaml runtime
|
||||||
|
in the game.
|
||||||
|
|
||||||
|
This is why *the dev runtime is multithreaded* — it needs the reload thread. That
|
||||||
|
is settled, and is independent of whether the /language/ exposes threads, which
|
||||||
|
is still open decision #4.
|
||||||
|
|
||||||
|
It also bears on whether the interpreter survives: if the agent can ~dlopen~ and
|
||||||
|
call anything in 16ms, then even "eval this expression against live game state"
|
||||||
|
can be a compiled ~.so~, and no interpreter is needed inside the game process.
|
||||||
|
|
||||||
|
** Why LLVM IR as text
|
||||||
|
| | text ~.ll~ → ~clang~ | libLLVM bindings | emit C |
|
||||||
|
|---+---+---+---|
|
||||||
|
| Build dependency | a ~clang~ on PATH | matching libLLVM, version-pinned, C++ linkage | any C compiler |
|
||||||
|
| Breaks on LLVM upgrade | no | routinely | no |
|
||||||
|
| Debuggable | ~.ll~ is readable | print-from-memory | readable, but lies about origin |
|
||||||
|
| In-process JIT | *no* | yes (ORC) | no |
|
||||||
|
| Control of layout / ABI / tail calls | full | full | poor |
|
||||||
|
|
||||||
|
The only column text loses is the JIT one, and the measurement above shows the
|
||||||
|
loss is ~13ms — below perception. ORC remains addable later behind the same typed
|
||||||
|
IR without touching the language, but nothing currently argues for it.
|
||||||
|
|
||||||
|
** The interpreter cannot run sand
|
||||||
|
Do not plan around it. 200 × 280 = 56,000 cells, scanned by ~game-update~ and
|
||||||
|
again by ~game-draw~ — ~112,000 interpreted cell-visits per frame against an
|
||||||
|
8.3ms budget at 120fps. At an optimistic 100ns per visit (environment
|
||||||
|
allocation, argument binding, two index computations, a compare) that is 11ms
|
||||||
|
before ~settle~, ~paint~, or a single raylib call. Expect 20–30fps.
|
||||||
|
|
||||||
|
This is an estimate, not a measurement, which is why *milestone 2 exits with a
|
||||||
|
measured interpreter throughput number* — before milestone 4 depends on it.
|
||||||
|
Milestone 4's interactive acceptance test runs on the compiled dev build; the
|
||||||
|
interpreter is not in that loop.
|
||||||
|
|
||||||
|
*** Open: does the interpreter survive milestone 3?
|
||||||
|
Now that compiled redefinition is measured at 16ms, the case for a /permanent/
|
||||||
|
interpreter is weaker than it looked. 16ms is perceptually instant for expression
|
||||||
|
eval too, and one backend removes a standing obligation — two backends must agree
|
||||||
|
on observable behaviour forever, and every divergence is a bug that reproduces in
|
||||||
|
only one of them.
|
||||||
|
|
||||||
|
Against dropping it: the interpreter is clearly right for milestone 2 (far less
|
||||||
|
work than an LLVM backend, better error messages, no linking), and the
|
||||||
|
instrumentation-based step debugger wants it. Decide at milestone 3 exit on
|
||||||
|
measured numbers, not now.
|
||||||
|
|
||||||
|
All three paths share the frontend and the typed IR and must agree on observable
|
||||||
|
behaviour. That agreement is what the acceptance programs test.
|
||||||
|
|
||||||
|
- Non-local exit lowered *explicitly* (result propagation + branch targets), not
|
||||||
|
via platform unwinding. Same on both targets, no dependency on the WASM
|
||||||
|
exception-handling proposal. Escape analysis narrows which functions pay for it.
|
||||||
|
- Stdlib written *in Flan*, not the host language. A few hundred primitives per
|
||||||
|
backend, everything else on top. This is what keeps a second backend cheap.
|
||||||
|
|
||||||
|
* Targets
|
||||||
|
- *Desktop*: AOT to native, x86-64 and arm64. Primary development target.
|
||||||
|
- *WASM*: AOT build artifact only, *not interactive-first* — no REPL, no hot
|
||||||
|
reload, no debugger there. That is not the same as untested: every runtime or
|
||||||
|
ABI feature ships with automated wasm32 tests in CI from the first one, because
|
||||||
|
a divergence found at ship time is a rewrite.
|
||||||
|
- Host binary links raylib natively; web links raylib via emscripten.
|
||||||
|
|
||||||
|
** One narrow host ABI, implemented twice
|
||||||
|
The portability risk is the host interface, not the language. Divergence points:
|
||||||
|
- Filesystem — pack assets, one abstraction, never touch paths
|
||||||
|
- Threads — decide now; retrofitting is worse than the reverse
|
||||||
|
- Blocking — browser main thread cannot block
|
||||||
|
- Audio/input/window — constrain to the raylib subset identical on both
|
||||||
|
|
||||||
|
* Dev vs release builds
|
||||||
|
Deliberately different.
|
||||||
|
|
||||||
|
| | Dev | Release |
|
||||||
|
|---------+---------------------------+------------|
|
||||||
|
| Backend | interpreter /and/ LLVM | LLVM/clang |
|
||||||
|
| Calls | indirection cells | direct |
|
||||||
|
| Code | never freed | static |
|
||||||
|
| Frames | shadow stack | none |
|
||||||
|
| Structs | version word | none |
|
||||||
|
| Reload | yes | no |
|
||||||
|
|
||||||
|
Build and run the release config regularly, not just at ship time.
|
||||||
|
|
||||||
|
* Hot reload
|
||||||
|
Every cross-function call goes through an *indirection cell*; redefinition is one
|
||||||
|
atomic pointer store. *Old code is never unloaded*, so a thread mid-execution
|
||||||
|
finishes safely in the old version.
|
||||||
|
|
||||||
|
This is the fix for jank issue #947 (segfault redefining a running loop's
|
||||||
|
function plus its callee — their JIT relinks and unloads under a running thread).
|
||||||
|
|
||||||
|
** What redefinition cannot do
|
||||||
|
Patch a mid-execution frame and continue at the same PC — its register
|
||||||
|
allocation belongs to the old compilation. No implementation does this. "Resume"
|
||||||
|
means re-entering from an established restart point.
|
||||||
|
|
||||||
|
* Tooling
|
||||||
|
Server speaks *nREPL* (bencode over socket) — the transport and the core ops
|
||||||
|
(~eval~, ~load-file~, ~describe~, ~interrupt~) are genuinely reusable, and that is
|
||||||
|
what the ~500–1000 lines buys. It does *not* buy CIDER/Conjure/Calva
|
||||||
|
compatibility: their useful operations assume Clojure-shaped vars, namespaces,
|
||||||
|
nses-of-symbols and middleware. Treat "speaks nREPL" as milestone 7a and "an
|
||||||
|
editor client that is pleasant" as a separate milestone 7b. In the dev runtime:
|
||||||
|
- eval string in package; compile form/file with source locations
|
||||||
|
- completion, arglist, describe, find-definition
|
||||||
|
- backtrace + restarts; interrupt
|
||||||
|
|
||||||
|
** Emacs client
|
||||||
|
Focused client, ~3–5k lines. Do *not* fork CIDER (~30k lines elisp, deeply
|
||||||
|
Clojure-coupled) — reference it. ~clojure-mode~-derived major mode, overlay
|
||||||
|
rendering, hydra for stepping bindings (~transient~ is the maintained
|
||||||
|
alternative).
|
||||||
|
|
||||||
|
** Step debugger
|
||||||
|
Instrumentation-based, like CIDER's — macroexpansion wraps subforms with a
|
||||||
|
breakpoint that messages the editor and blocks. No native debug info needed.
|
||||||
|
Limit: only instrumented code.
|
||||||
|
|
||||||
|
** Pause on exception
|
||||||
|
Better than CIDER's, because ~handler-bind~ has not unwound the stack. Dev-mode
|
||||||
|
global handler messages the editor and blocks with the full live stack and all
|
||||||
|
restarts available. Fix the function, resume via restart.
|
||||||
|
|
||||||
|
* Build sequence
|
||||||
|
Deliberately ordered so each step is runnable and the next one cannot start until
|
||||||
|
the previous checker is stable. The failure mode this exists to prevent is
|
||||||
|
building the whole live environment at once.
|
||||||
|
|
||||||
|
1. *Freeze the model.* spec-memory.md and spec-conditions.md — done before any
|
||||||
|
code. Fixed arrays, non-owning slices, move-only ~Vec~/~Map~, allocators,
|
||||||
|
~Ptr~, explicit ~clone~; the six restart cases. /Done./
|
||||||
|
2. *Run calc-me.flan on the interpreter.* Reader, typed IR, checker,
|
||||||
|
tree-walking backend. /Exit criterion includes a measured throughput number/
|
||||||
|
— interpreted calls per second on a tight loop — because milestone 4's frame
|
||||||
|
budget depends on it (see Compilation). Packages, structs, ~(Ptr T)~ + ~addr~, byte slices,
|
||||||
|
~at~/~len~, ~while~, ~set~ on the fixed place list, ~cond~, ~match~, ~Option~
|
||||||
|
+ ~some~, ~i32~/~u8~/~f64~, recursion, argv, stdout. No allocator, no ~Vec~,
|
||||||
|
no generics, no user macros, no FFI, no window. Headless, so the acceptance
|
||||||
|
test is a table of expression/result pairs.
|
||||||
|
3. *Emit LLVM IR and pass the same calc-me test AOT*, on native and wasm32 in CI.
|
||||||
|
Both backends, one test table, one narrow host ABI (argv, stdout, exit). This
|
||||||
|
is where the second target gets proven — while there is almost nothing to port.
|
||||||
|
4. *Run sand.flan.* Fixed 2-D arrays, ~dotimes~, ~defer~, and typed FFI to
|
||||||
|
raylib including keyword→enum coercion. Acceptance test twice: headless (N
|
||||||
|
frames, hash the grid — runnable in CI on both targets) and interactive at
|
||||||
|
120 fps.
|
||||||
|
5. *Generics and macro expansion*, once the base checker is stable. ~defmacro~,
|
||||||
|
~&env~, hygiene. Until here, ~when~/~unless~/~until~/~cond~/~dotimes~ are
|
||||||
|
special forms in the compiler.
|
||||||
|
6. *Allocators, ~Vec~/~Map~, ~Result~/~try~/~errdefer~, then conditions and
|
||||||
|
restarts* against spec-conditions.md, with dedicated tests per numbered case.
|
||||||
|
7. *Hot reload* — free in the interpreter, indirection cells for compiled dev
|
||||||
|
builds, with the compatibility limits written down and enforced: signature
|
||||||
|
changes, struct layout changes, live callbacks held by C, captured environments.
|
||||||
|
8. *Debugger, nREPL, async* — last, and 8 splits into transport (8a) and editor
|
||||||
|
client (8b).
|
||||||
|
|
||||||
|
Milestones 1–4 are the project. Everything from 5 on is optional in the sense
|
||||||
|
that a language that stops there is still usable; nothing before 5 is.
|
||||||
|
|
||||||
|
Ordering note: sand cannot be first even though it is the better demo, because
|
||||||
|
it needs raylib FFI, keyword→enum coercion and a window before a single line of
|
||||||
|
it runs. calc-me needs argv and stdout.
|
||||||
|
|
||||||
|
* Runtime budget
|
||||||
|
Scoped to *milestones 1–3 only*. The earlier version of this table put the whole
|
||||||
|
plan — conditions, explicit transfer lowering, macros, hot reload, debugger
|
||||||
|
support — at 15–25k, which is not credible: each of those is architecture work
|
||||||
|
that touches the frontend, the IR and the backend at once.
|
||||||
|
|
||||||
|
| Piece | Lines | Milestone |
|
||||||
|
|-----------------------------------+--------+-----------|
|
||||||
|
| Allocators | 2–3k | 2 |
|
||||||
|
| Core data (fixed/slice/Vec/Map) | 2–3k | 2 |
|
||||||
|
| Reader + frontend + checker | 5–8k | 2 |
|
||||||
|
| LLVM IR lowering | 3–5k | 2 |
|
||||||
|
| raylib FFI + host ABI ×2 | 1–2k | 3 |
|
||||||
|
| *Subtotal, a language that runs sand* | 13–21k | |
|
||||||
|
|
||||||
|
Beyond that, estimated but not budgeted, because these are the parts that are
|
||||||
|
architecture rather than volume:
|
||||||
|
|
||||||
|
| Piece | Note |
|
||||||
|
|----------------------------------------+------|
|
||||||
|
| Macroexpander + ~&env~ | touches the reader and the checker |
|
||||||
|
| Generics / monomorphisation | touches the whole checker |
|
||||||
|
| Conditions + explicit transfer lowering | frontend *and* IR *and* backend |
|
||||||
|
| Hot reload cells + compatibility rules | changes the calling convention |
|
||||||
|
| Debugger instrumentation + nREPL | needs ~&env~ and source locations |
|
||||||
|
|
||||||
|
Janet is 36k including a bytecode VM, and Janet has no static types, no
|
||||||
|
monomorphisation, no restarts and no reload.
|
||||||
|
|
||||||
|
* Open decisions
|
||||||
|
None of these block milestone 2. The milestone each one must be answered by is
|
||||||
|
marked.
|
||||||
|
|
||||||
|
1. *Host language: OCaml or Rust* — the only thing blocking the scaffold. See
|
||||||
|
Host language. /Milestone 2./
|
||||||
|
2. Macro hygiene model. /Milestone 5./
|
||||||
|
3. Escape analysis: automatic promotion of escaping frame-arena values, or
|
||||||
|
explicit. /Milestone 6./
|
||||||
|
4. Threads *in the language*: yes or no, decided before the host ABI. (The dev
|
||||||
|
/runtime/ is multithreaded regardless — it needs a reload thread. Settled; see
|
||||||
|
Compilation.) /Milestone 3, before the host ABI./
|
||||||
|
5. /Escaping/ closures. /Milestone 6./ Settled in spec-memory.md:
|
||||||
|
~(Fn ...)~ is a bare pointer with no environment (callbacks, reload cells, FFI),
|
||||||
|
and a *non-escaping* ~fn~ captures by value into a stack environment — which is
|
||||||
|
what makes ~handler-bind~ handlers able to see enclosing locals, without which
|
||||||
|
conditions are not worth building. Still open: a closure that is stored,
|
||||||
|
returned, or pushed into a container. Which allocator owns its environment, and
|
||||||
|
what happens when the frame arena resets? Decided together with #3, because
|
||||||
|
the same escape analysis classifies both.
|
||||||
|
6. Hot-reload compatibility rules. /Milestone 7./ Cells cover a function
|
||||||
|
body changing. Not covered: a changed signature, a changed struct layout with
|
||||||
|
live instances, a function pointer already handed to C, a captured environment,
|
||||||
|
and redefining a ~defvar~. Each needs an answer of the form "rejected",
|
||||||
|
"accepted with a migration", or "accepted and the old code keeps running".
|
||||||
|
7. Does the interpreter survive milestone 3, or is the compiled path the only
|
||||||
|
backend? /Milestone 3, on measured numbers./ See Compilation.
|
||||||
|
8. ~(Option a)~ /settled:/ an ordinary stdlib union with ~Some~/~None~; the
|
||||||
|
compiler niche-optimises ~(Option (Ptr T))~ to a nullable pointer. The
|
||||||
|
CL-vs-Clojure truthiness question is moot under static typing.
|
||||||
|
|
||||||
|
*Settled since the first draft* (see the specs):
|
||||||
|
- Module system → the directory is the package, Odin's model. No ~ns~ form. See
|
||||||
|
Modules.
|
||||||
|
- Zero values → ZII by default, with an opt-out. A declaration with no
|
||||||
|
initialiser is all-bytes-zero; ~(zeroed)~ re-zeroes something later; ~uninit~
|
||||||
|
opts out for large buffers about to be overwritten. It is a ~memset~, not a
|
||||||
|
~memcpy~; zeroed globals live in BSS and cost nothing.
|
||||||
|
- Package declaration → optional, inferred from the directory name. See Modules.
|
||||||
|
- Keywords at typed call sites → yes. ~:space~ resolves at compile time against
|
||||||
|
the parameter's enum type, ~rl/key-space~ names the same value, and a typo is
|
||||||
|
a compile error checked against the enum's members. No runtime cost. Needs the
|
||||||
|
FFI enum declared, so it lands with milestone 4.
|
||||||
|
- Dev redefinition latency → ~16ms, measured: ~llc~ + ~ld -shared~ + ~dlopen~,
|
||||||
|
never the ~clang~ driver, ~dlopen~ off the game thread, cells published in a
|
||||||
|
batch at a frame boundary. See Compilation.
|
||||||
|
- Dev backend → interpreter for milestone 2 certainly. Whether it /survives/
|
||||||
|
milestone 3 is open, not settled — see Compilation.
|
||||||
|
- ~set~ on places → a fixed list of assignable forms, not ~setf~.
|
||||||
|
- Loop story → imperative ~while~/~for~ with ~break~/~continue~ and ~return~;
|
||||||
|
~loop~/~recur~ only if it later earns its place. sand.flan is ported.
|
||||||
|
- Generic parameters → inferred at call sites, no explicit instantiation; and no
|
||||||
|
type classes, so unconstrained operators over a type variable are rejected.
|
||||||
|
|
||||||
|
* Unverified claims in this plan
|
||||||
|
- *Scope is the biggest risk, not any single feature.* A language, inference,
|
||||||
|
monomorphisation, macros, an explicit-memory runtime, conditions/restarts, hot
|
||||||
|
reload, a debugger, nREPL tooling, native and WASM — each is reasonable, the
|
||||||
|
set is not one project. The build sequence above exists because of this; if
|
||||||
|
something has to give, it gives from milestone 4 upward.
|
||||||
|
- Line-count estimates are extrapolations.
|
||||||
|
- LLVM → wasm32 with manual memory is validated by Odin shipping it; not yet
|
||||||
|
validated for s-expression macros + conditions/restarts on top.
|
||||||
151
sand.flan
Normal file
151
sand.flan
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
;;;; Falling sand — Flan port of the Odin/Janet/Lisp/jank versions in ~/Development/fnm.
|
||||||
|
;;;;
|
||||||
|
;;;; THE SECOND ACCEPTANCE PROGRAM — build sequence milestone 4. calc-me.flan
|
||||||
|
;;;; comes first: sand cannot run at all until raylib FFI, keyword->enum
|
||||||
|
;;;; coercion and a window exist, and none of those should be on the critical
|
||||||
|
;;;; path to "the language runs something".
|
||||||
|
;;;;
|
||||||
|
;;;; It is tested twice: headless (N frames, hash the grid — the version CI runs
|
||||||
|
;;;; on native and wasm32) and interactive at 120 fps.
|
||||||
|
;;;;
|
||||||
|
;;;; Note what it still deliberately does not use: no Vec, no Map, no generics,
|
||||||
|
;;;; no user-written macros, no conditions, no allocator other than the stack
|
||||||
|
;;;; and static storage.
|
||||||
|
;;;;
|
||||||
|
;;;; Notation reminders (see plan.org and spec-memory.md):
|
||||||
|
;;;; [n T] fixed array, length n, element T — a VALUE, copies
|
||||||
|
;;;; [T] slice, ptr+len, non-owning (Vec T) owning, move-only
|
||||||
|
;;;; (Ptr T) pointer (Handle T) generational handle
|
||||||
|
;;;; types are inline name/type pairs, as in `let` and `defstruct`
|
||||||
|
;;;; an omitted return type means Unit
|
||||||
|
;;;; lowercase in a TYPE position is a type variable; in a LENGTH position
|
||||||
|
;;;; it is an ordinary compile-time value, so [rows [cols u32]] is unambiguous
|
||||||
|
|
||||||
|
(import rl "vendor:raylib") ; directory = package; declaration optional
|
||||||
|
|
||||||
|
(defconst screen-width 1400)
|
||||||
|
(defconst screen-height 1000)
|
||||||
|
(defconst cell-size 5)
|
||||||
|
(defconst gravity 0.05)
|
||||||
|
(defconst rows (/ screen-height cell-size))
|
||||||
|
(defconst cols (/ screen-width cell-size))
|
||||||
|
(defconst brush-size 10)
|
||||||
|
|
||||||
|
;; Packed 0xRRGGBBAA. A cell of 0 means empty, so no Option and no tag word.
|
||||||
|
(defconst colors [4 u32] [0xE6B800FF 0x3B6E8CFF 0xA83232FF 0xCC6B1FFF])
|
||||||
|
|
||||||
|
;; Flat, unboxed, statically sized. No headers, so these are exactly
|
||||||
|
;; rows*cols*4 bytes each — the same memory the Odin port has. Fixed arrays are
|
||||||
|
;; values, so `(set grid (zeroed))` overwrites in place rather than reallocating.
|
||||||
|
;; No initialiser means all-bytes-zero (plan.org, zero values), so these are
|
||||||
|
;; BSS and cost nothing to start. `(zeroed)` below is the explicit spelling for
|
||||||
|
;; re-zeroing later — a memset, not an allocation.
|
||||||
|
(defvar grid [rows [cols u32]])
|
||||||
|
(defvar velocity [rows [cols f32]])
|
||||||
|
(defvar current-color u32)
|
||||||
|
|
||||||
|
(defn clear-grid []
|
||||||
|
(set grid (zeroed))
|
||||||
|
(set velocity (zeroed)))
|
||||||
|
|
||||||
|
(defn empty-at? [row i32 col i32] bool
|
||||||
|
(= 0 (at grid row col)))
|
||||||
|
|
||||||
|
;; Locals are assignable places (spec-memory.md); parameters are not.
|
||||||
|
(defn paint []
|
||||||
|
(let [m (rl/get-mouse-position)
|
||||||
|
row (/ (i32 (.y m)) cell-size)
|
||||||
|
col (/ (i32 (.x m)) cell-size)
|
||||||
|
half (/ brush-size 2)]
|
||||||
|
(dotimes [x brush-size]
|
||||||
|
(dotimes [y brush-size]
|
||||||
|
(let [r (+ y (- row half))
|
||||||
|
c (+ x (- col half))]
|
||||||
|
(when (and (>= r 0) (< r (- rows 1))
|
||||||
|
(>= c 0) (< c (- cols 1))
|
||||||
|
(empty-at? r c)
|
||||||
|
(< (rand-f32) 0.5))
|
||||||
|
(set (at grid r c) (nth colors current-color))
|
||||||
|
(set (at velocity r c) 1.0)))))))
|
||||||
|
|
||||||
|
(defn move-grain [from-row i32 from-col i32
|
||||||
|
to-row i32 to-col i32
|
||||||
|
vel f32]
|
||||||
|
(set (at grid to-row to-col) (at grid from-row from-col))
|
||||||
|
(set (at grid from-row from-col) 0)
|
||||||
|
(set (at velocity to-row to-col) vel)
|
||||||
|
(set (at velocity from-row from-col) 0.0))
|
||||||
|
|
||||||
|
;; Move the grain at [row col] as far down as it can, sliding to a free
|
||||||
|
;; diagonal neighbour when the cell below is taken.
|
||||||
|
;;
|
||||||
|
;; Imperative `while` with early `return`, not loop/recur — see plan.org
|
||||||
|
;; "Loop story". The recur version read as a tail call but was a countdown
|
||||||
|
;; over a mutable scan position, which is what a while loop is.
|
||||||
|
(defn settle [row i32 col i32]
|
||||||
|
(let [vel (+ gravity (at velocity row col))
|
||||||
|
y (min (- rows 1) (+ row (i32 vel)))]
|
||||||
|
(while (> y row)
|
||||||
|
(when (empty-at? y col)
|
||||||
|
(move-grain row col y col vel)
|
||||||
|
(return))
|
||||||
|
(let [left? (and (> col 0) (empty-at? y (- col 1)))
|
||||||
|
right? (and (< col (- cols 1)) (empty-at? y (+ col 1)))]
|
||||||
|
(when (or left? right?)
|
||||||
|
(let [side (cond
|
||||||
|
(not left?) 1
|
||||||
|
(not right?) -1
|
||||||
|
:else (if (< (rand-f32) 0.5) 1 -1))]
|
||||||
|
(move-grain row col y (+ col side) vel)
|
||||||
|
(return))))
|
||||||
|
(set y (- y 1)))
|
||||||
|
;; Nowhere to fall: reset the accumulated velocity and stay put.
|
||||||
|
(set (at velocity row col) 0.0)))
|
||||||
|
|
||||||
|
;; Every cross-function call in a dev build routes through an indirection cell,
|
||||||
|
;; so redefining this from the REPL reaches the running loop on the next frame.
|
||||||
|
;; No `varfn` (Janet), no `let update = ref` (OCaml), no var-routing (jank).
|
||||||
|
;; Release builds compile the same source to direct calls.
|
||||||
|
;;
|
||||||
|
;; A cell holds an (Fn ...) — a plain function pointer, no captured environment;
|
||||||
|
;; this one is (Fn [] Unit), `settle`'s is (Fn [i32 i32] Unit). Redefining
|
||||||
|
;; `settle` while `game-update` is mid-frame is safe
|
||||||
|
;; because old code is never unloaded; changing its SIGNATURE is not, and the
|
||||||
|
;; reload rejects it. See plan.org "What redefinition cannot do".
|
||||||
|
(defn game-update []
|
||||||
|
(when (rl/key-pressed? :r) (clear-grid))
|
||||||
|
(when (rl/key-down? :space) (paint))
|
||||||
|
(when (rl/key-released? :space)
|
||||||
|
(set current-color (% (+ current-color 1) (len colors))))
|
||||||
|
;; Bottom-up, so a grain settles at most once per frame.
|
||||||
|
(let [row (- rows 2)]
|
||||||
|
(while (>= row 0)
|
||||||
|
(dotimes [col cols]
|
||||||
|
(unless (empty-at? row col)
|
||||||
|
(settle row col)))
|
||||||
|
(set row (- row 1)))))
|
||||||
|
|
||||||
|
(defn game-draw []
|
||||||
|
(rl/clear-background rl/black)
|
||||||
|
(dotimes [row rows]
|
||||||
|
(dotimes [col cols]
|
||||||
|
(let [c (at grid row col)]
|
||||||
|
(unless (= 0 c)
|
||||||
|
(rl/draw-rectangle (i32 (* col cell-size))
|
||||||
|
(i32 (* row cell-size))
|
||||||
|
cell-size cell-size
|
||||||
|
(rl/get-color c))))))
|
||||||
|
(rl/draw-fps 20 20))
|
||||||
|
|
||||||
|
(defn main []
|
||||||
|
(rl/set-trace-log-level :warning)
|
||||||
|
(rl/init-window screen-width screen-height "SAND")
|
||||||
|
(defer (rl/close-window))
|
||||||
|
(rl/set-target-fps 120)
|
||||||
|
;; Bare (defn main []) — argv and the i32 status are both optional.
|
||||||
|
;; Nothing in this loop allocates, so context/temp is never even touched.
|
||||||
|
(until (rl/window-should-close?)
|
||||||
|
(game-update)
|
||||||
|
(rl/begin-drawing)
|
||||||
|
(game-draw)
|
||||||
|
(rl/end-drawing)))
|
||||||
115
spec-conditions.md
Normal file
115
spec-conditions.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# Spec 2 — Conditions and restarts, operational semantics
|
||||||
|
|
||||||
|
Status: **frozen** for the six hard cases below. Everything not listed here is
|
||||||
|
still open, but nothing in the implementation may depend on the unlisted parts.
|
||||||
|
|
||||||
|
Four operators: `handler-bind`, `handler-case`, `restart-case`, `invoke-restart`.
|
||||||
|
No condition class hierarchy — condition types are structs, matching is by type
|
||||||
|
plus an optional predicate.
|
||||||
|
|
||||||
|
## 1. `signal` returns `Unit`
|
||||||
|
|
||||||
|
`(signal c)` has type `Unit`, always. When every applicable handler returns
|
||||||
|
normally without transferring, `signal` returns `Unit` and the signalling
|
||||||
|
function simply carries on. This is the accumulation case.
|
||||||
|
|
||||||
|
The alternative — `signal` producing a value supplied by the handler — was
|
||||||
|
rejected: it forces every signal site to declare a default value and a result
|
||||||
|
type, which is a much heavier language for one convenience.
|
||||||
|
|
||||||
|
The consequence is visible in the syntax. A `restart-case` in value position
|
||||||
|
must produce its type on the *fall-through* path too:
|
||||||
|
|
||||||
|
```
|
||||||
|
(defn load-texture [path string] (Handle Texture)
|
||||||
|
(if (file-exists? path)
|
||||||
|
(rl/load-texture path)
|
||||||
|
(restart-case
|
||||||
|
(do (signal (AssetMissing {:path path}))
|
||||||
|
(abort "unhandled AssetMissing")) ; fall-through must not return
|
||||||
|
(use-placeholder [] placeholder-texture)
|
||||||
|
(retry [] (load-texture path)))))
|
||||||
|
```
|
||||||
|
|
||||||
|
`abort` has type `Never`, which unifies with anything. Any expression of type
|
||||||
|
`Never` (a `return`, a call to a diverging function) is equally acceptable there.
|
||||||
|
|
||||||
|
## 2. No handler
|
||||||
|
|
||||||
|
`signal` with no matching handler on the handler stack is a **no-op** that
|
||||||
|
returns `Unit`. It does not abort, does not print, does not enter a break loop.
|
||||||
|
`(error c)` is the diverging variant: same lookup, but with type `Never` and, if
|
||||||
|
nothing handles it, it enters the dev-build break loop or aborts in release.
|
||||||
|
|
||||||
|
The cost when unused is the intended one: `handler-bind` is a couple of stores
|
||||||
|
onto a stack-allocated linked-list frame, and `signal` with an empty stack is a
|
||||||
|
null check.
|
||||||
|
|
||||||
|
## 3. Restart signatures
|
||||||
|
|
||||||
|
```
|
||||||
|
(restart-case BODY
|
||||||
|
(name [p1 T1 p2 T2] BODY-1)
|
||||||
|
...)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Parameters are annotated inline, like any other binding form.
|
||||||
|
- **Every clause body and the `restart-case` body must have the same type**, and
|
||||||
|
that is the type of the whole form.
|
||||||
|
- `(invoke-restart 'name arg ...)` has type `Never` — it never returns to the
|
||||||
|
invoking handler. Control resumes at the `restart-case`, which yields the
|
||||||
|
clause's value to *its* continuation.
|
||||||
|
- Argument count and types are checked at **runtime** in the first
|
||||||
|
implementation, because restarts are dynamically scoped and named. A statically
|
||||||
|
tracked restart set (Zig's error-set model) remains a nice-to-have.
|
||||||
|
|
||||||
|
## 4. Name shadowing
|
||||||
|
|
||||||
|
Restart lookup walks the dynamic restart stack from innermost outward and takes
|
||||||
|
the **first** frame offering the name. An inner `restart-case` therefore shadows
|
||||||
|
an outer one with the same name for the duration of its body. This is what makes
|
||||||
|
"restarts go at the resync point" composable: an inner parser's `skip-form` is
|
||||||
|
found before an outer one's.
|
||||||
|
|
||||||
|
`(find-restart 'name)` returns `(Option Restart)` so a handler can test before
|
||||||
|
committing; `(compute-restarts)` lists the visible frames for the debugger.
|
||||||
|
|
||||||
|
## 5. Cleanup during a transfer
|
||||||
|
|
||||||
|
Invoking a restart transfers control outward past zero or more frames.
|
||||||
|
|
||||||
|
- `defer` forms in every frame between the `invoke-restart` and the target
|
||||||
|
`restart-case` **do run**, innermost first, before the clause body starts.
|
||||||
|
- `errdefer` forms **do not run**. `errdefer` is bound to the `Result` failure
|
||||||
|
path (`try` returning `Err`) only. A restart transfer is not a failure — it is
|
||||||
|
a chosen recovery, and the recovery may well want the resource.
|
||||||
|
- The condition object lives on the *signalling* frame's stack. Nothing has
|
||||||
|
unwound when a handler runs, so it is valid there; but once a transfer starts,
|
||||||
|
the signalling frame dies. Anything a handler keeps must be copied out
|
||||||
|
(conditions are value structs, so `(push errors c)` copies).
|
||||||
|
|
||||||
|
## 6. Crossing compiler-generated frames
|
||||||
|
|
||||||
|
Transfer is lowered **explicitly** — result propagation plus branch targets — not
|
||||||
|
via platform unwinding, so that native and wasm32 behave identically. That means
|
||||||
|
every function on the path between the invoke and the target must be
|
||||||
|
transfer-aware: it returns a discriminated "normal value / transferring to frame
|
||||||
|
N" result, checks it after each call, and forwards.
|
||||||
|
|
||||||
|
- The compiler marks a function transfer-transparent if it can call, directly or
|
||||||
|
indirectly, anything that may invoke a restart. Escape analysis narrows this
|
||||||
|
set; functions outside it pay nothing.
|
||||||
|
- **Foreign frames cannot be crossed.** A restart transfer whose path passes
|
||||||
|
through a C frame (a raylib callback, an `extern` function calling back into
|
||||||
|
Flan) is a runtime error, not undefined behaviour. Handlers installed across an
|
||||||
|
FFI boundary must therefore either return normally or use `handler-case`
|
||||||
|
installed inside the callback.
|
||||||
|
- With the async state-machine transform, the handler and restart stacks live in
|
||||||
|
the **task** state, not thread-local, so a handler established before an
|
||||||
|
`await` is still in scope after resumption.
|
||||||
|
|
||||||
|
## What this does not settle
|
||||||
|
|
||||||
|
Condition inheritance/predicate-matching details, the break-loop UI, restart
|
||||||
|
interaction with threads, and whether `handler-case` should be a macro over
|
||||||
|
`handler-bind` + a transfer. None of these block milestone 5.
|
||||||
154
spec-memory.md
Normal file
154
spec-memory.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# Spec 1 — Ownership, containers, and copies
|
||||||
|
|
||||||
|
Status: **frozen**. Closes plan.org open decisions #6 and #10, and resolves the
|
||||||
|
contradiction between "value structs copy on assignment" and owning containers.
|
||||||
|
Everything else in the design references this vocabulary.
|
||||||
|
|
||||||
|
## The four container types
|
||||||
|
|
||||||
|
| Notation | Layout | Assignment | Owns storage | Allocator |
|
||||||
|
|-----------|-------------------|------------|--------------|-----------|
|
||||||
|
| `[n T]` | n contiguous `T` | copies | no (inline) | — |
|
||||||
|
| `[T]` | ptr + len | copies the *view* | no | — |
|
||||||
|
| `(Vec T)` | ptr + len + cap | **moves** | yes | stored |
|
||||||
|
| `(Map K V)` | open-addressed, flat key/value arrays | **moves** | yes | stored |
|
||||||
|
|
||||||
|
- `[n T]` is a value. It lives wherever it is declared, copies on assignment and
|
||||||
|
on pass-by-value, and is what `defconst colors [4 u32] ...` and
|
||||||
|
`(defvar grid [rows [cols u32]] ...)` are.
|
||||||
|
- `[T]` is a **non-owning slice**: a borrowed window into a `[n T]`, a `(Vec T)`,
|
||||||
|
or a literal in read-only memory. Copying a slice copies ptr+len, never the
|
||||||
|
elements. A slice may be `const`-qualified; freeing through one is not possible
|
||||||
|
because a slice has no allocator and no `cap`.
|
||||||
|
- `(Vec T)` and `(Map K V)` are **move-only**. Binding, passing, or returning one
|
||||||
|
transfers ownership; the source binding is dead afterwards and using it is a
|
||||||
|
compile error. There is no shallow copy, so there is no double free.
|
||||||
|
|
||||||
|
## Copying is always explicit
|
||||||
|
|
||||||
|
`(clone x)` produces an independent deep copy of a `Vec`/`Map` using the current
|
||||||
|
allocator; `(clone x alloc)` names one. Value types (`[n T]`, structs of value
|
||||||
|
types, primitives) need no `clone` — assignment already copies them.
|
||||||
|
|
||||||
|
A struct containing a `Vec` field is itself move-only. Ownership is structural,
|
||||||
|
not declared: a type is a value type iff all of its fields are.
|
||||||
|
|
||||||
|
## Borrowing
|
||||||
|
|
||||||
|
- `(as-slice v)` / `(as-slice v lo hi)` view a `Vec` or fixed array as `[T]`.
|
||||||
|
- A slice is invalidated by any operation that may reallocate the owner (`push`,
|
||||||
|
`put`, `reserve`). This is **not checked** in the first implementation; dev
|
||||||
|
builds carry a generation word on `Vec` and trap on use of a stale slice.
|
||||||
|
- Cross-referencing long-lived objects uses `(Handle a)` into a pool, never a
|
||||||
|
raw pointer or slice. A stale handle is detectable.
|
||||||
|
|
||||||
|
## Taking an address
|
||||||
|
|
||||||
|
`(addr x)` yields `(Ptr T)` for any assignable place `x` — a local, a global, a
|
||||||
|
field, an element. The pointer is non-owning and does not extend anything's
|
||||||
|
lifetime, so `addr` of a local is only valid while that frame lives. This is the
|
||||||
|
same escape question as case 3 below and is checked by the same analysis; until
|
||||||
|
that analysis exists, `addr` of a local may not be stored or returned.
|
||||||
|
|
||||||
|
`addr` is how a value struct is shared mutably without an allocator — recursive
|
||||||
|
descent over a cursor, an entity passed down a call chain — and it is why
|
||||||
|
milestone 2 needs no heap at all.
|
||||||
|
|
||||||
|
## Places — what `set` accepts
|
||||||
|
|
||||||
|
A fixed set of assignable forms, not a `setf`-style extensible place mechanism:
|
||||||
|
|
||||||
|
```
|
||||||
|
(set x v) ; a local or a defvar
|
||||||
|
(set (.field x) v) ; struct field; x may be a struct, (Ptr S) or (Handle S)
|
||||||
|
(set (at a i ...) v) ; fixed array, slice, or Vec element
|
||||||
|
(set (get m k) v) ; map entry
|
||||||
|
(set (deref p) v) ; whole-object store through a pointer
|
||||||
|
```
|
||||||
|
|
||||||
|
`.field` and `at` auto-deref exactly one pointer or handle level, which is what
|
||||||
|
makes `(set (.hp e) ...)` legal when `e : (Ptr Enemy)` and illegal when
|
||||||
|
`e : Enemy` bound by value.
|
||||||
|
|
||||||
|
**Mutating something you matched.** Pattern bindings bind *values*, so a matched
|
||||||
|
struct is a copy. To mutate in place, obtain a pointer first — the pointer is
|
||||||
|
visible in the type:
|
||||||
|
|
||||||
|
```
|
||||||
|
(match (resolve w h) ; (Option (Ptr Enemy))
|
||||||
|
(Some e) (set (.hp e) ...) ; e : (Ptr Enemy), field access derefs
|
||||||
|
None ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`deref` yields a value; `resolve` yields a pointer. Both are overloaded on
|
||||||
|
`(Ptr a)` and `(Handle a)` and resolve at compile time.
|
||||||
|
|
||||||
|
## Generics
|
||||||
|
|
||||||
|
Parametric polymorphism is monomorphisation, with **no type classes and no
|
||||||
|
constraints**. The consequence is a hard rule:
|
||||||
|
|
||||||
|
> A type variable `a` supports only what every type supports: move, `clone`,
|
||||||
|
> field-free storage. It does **not** support `=`, `<`, `+`, `hash`, or `print`.
|
||||||
|
|
||||||
|
Anything else is passed in explicitly as a function value:
|
||||||
|
|
||||||
|
```
|
||||||
|
(defn largest [xs [a] gt (Fn [a a] bool)] (Option a) ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordered/arithmetic operators over `a` are therefore rejected, not silently
|
||||||
|
instantiated. The alternatives — compile-time interfaces, or intrinsics
|
||||||
|
restricted to primitives — are deliberately deferred until the base checker is
|
||||||
|
stable (build sequence milestone 4).
|
||||||
|
|
||||||
|
Type arguments are **inferred at call sites** from the argument types; there is
|
||||||
|
no explicit instantiation syntax in the first implementation. A type variable
|
||||||
|
that appears only in the return type is therefore an error.
|
||||||
|
|
||||||
|
## Function values
|
||||||
|
|
||||||
|
Three cases, split by whether the value escapes the frame that made it.
|
||||||
|
|
||||||
|
**1. `(Fn [T1 T2] R)` — a plain function pointer.** No captured environment, no
|
||||||
|
allocation, C calling convention plus the implicit allocator argument. This is
|
||||||
|
what raylib callbacks, hot-reload indirection cells, and function *parameters*
|
||||||
|
use. A top-level `defn` is one, so `(largest hps >)` passes `>` at `i32`
|
||||||
|
directly. This is the only function type that may cross an FFI boundary or sit
|
||||||
|
in a reload cell.
|
||||||
|
|
||||||
|
**2. Non-escaping `fn` — captures by value into a stack environment.** A `fn`
|
||||||
|
whose value provably does not outlive the frame that created it gets an
|
||||||
|
environment allocated in that frame and captures the named locals **by value**
|
||||||
|
at the point of creation. No heap, no allocator, no lifetime question. This
|
||||||
|
covers essentially every lambda in practice:
|
||||||
|
|
||||||
|
- callbacks to `reduce` / `filter` / `each` / `map`, which consume them and return
|
||||||
|
- comparators passed to a function that does not store them
|
||||||
|
- `handler-bind` handler bodies
|
||||||
|
|
||||||
|
That last one is not a convenience. A handler must be able to see the enclosing
|
||||||
|
locals — `(fn [c] (push errors c) (invoke-restart 'skip-form))` capturing a local
|
||||||
|
`(Vec ParseError)` *is* the accumulation pattern, and conditions are not worth
|
||||||
|
building without it. Handlers are strictly non-escaping: the `handler-bind` frame
|
||||||
|
outlives every call to them.
|
||||||
|
|
||||||
|
Captured `Vec`/`Map` are captured **by pointer**, not moved, since the capture
|
||||||
|
does not outlive the owner. A non-escaping `fn` is therefore not itself an owner.
|
||||||
|
|
||||||
|
**3. Escaping closures — still open.** A `fn` stored in a struct, pushed into a
|
||||||
|
container, or returned needs a heap environment and an answer to "which allocator
|
||||||
|
owns it, and what happens when the frame arena resets". Not settled; see
|
||||||
|
plan.org open decisions. Escape analysis (open decision #4) is the same analysis
|
||||||
|
that classifies cases 2 and 3, so they are decided together.
|
||||||
|
|
||||||
|
**Early exit inside a `fn`.** `try`, `some`, and `return` in a `fn` body exit the
|
||||||
|
`fn`, not the enclosing function — a `fn` is a function. Code that wants to
|
||||||
|
propagate out of a loop uses an imperative loop form, not a callback.
|
||||||
|
|
||||||
|
## Allocators
|
||||||
|
|
||||||
|
The allocator is part of the calling convention (`context/allocator`,
|
||||||
|
`context/temp`). `Vec` and `Map` record the allocator they were created with, so
|
||||||
|
`free` and `clone` never need it named again. No core operation allocates
|
||||||
|
implicitly.
|
||||||
198
syntax-sketch.flan
Normal file
198
syntax-sketch.flan
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
;; Syntax sketch. Not final — illustrates the decisions in plan.org.
|
||||||
|
;;
|
||||||
|
;; Rules held here:
|
||||||
|
;; - every type notation reads as exactly ONE data item
|
||||||
|
;; - types are inline name/type pairs, as in `let` and `defstruct`
|
||||||
|
;; - an omitted return type means Unit (a real zero-sized type, not C's void)
|
||||||
|
;; - lowercase type names are variables, Capitalized are concrete
|
||||||
|
;; - no `!` convention (nothing is immutable), no `->`, no sigils
|
||||||
|
;;
|
||||||
|
;; Normative references: spec-memory.md (ownership, containers, places,
|
||||||
|
;; generics, function values) and spec-conditions.md (restart semantics).
|
||||||
|
|
||||||
|
(import rl "vendor:raylib") ; directory = package, declaration optional;
|
||||||
|
; imports are always qualified rl/foo
|
||||||
|
|
||||||
|
;; ── Type notation ─────────────────────────────────────────────────────
|
||||||
|
;; [4 f32] fixed array — a value, copies on assignment
|
||||||
|
;; [f32] slice, ptr+len — a NON-OWNING view, copies the view only
|
||||||
|
;; (Vec f32) owning growable, ptr+len+cap — MOVE-ONLY, carries allocator
|
||||||
|
;; {string i32} owning hashmap — move-only, shorthand for (Map string i32)
|
||||||
|
;;
|
||||||
|
;; Braces are read by position: in a TYPE position {K V} is a map type; in a
|
||||||
|
;; VALUE position {:field v ...} is a struct or condition literal. There is no
|
||||||
|
;; map literal — a map is built with make-map and an allocator.
|
||||||
|
;; (Ptr World) pointer
|
||||||
|
;; (Fn [f32] bool) function pointer, no captured environment
|
||||||
|
;; (Option a) union from the stdlib
|
||||||
|
;; (Handle a) generational handle into a pool
|
||||||
|
;;
|
||||||
|
;; A struct is a value type iff all its fields are. One Vec field makes it
|
||||||
|
;; move-only. Copying an owning container is always explicit: (clone v).
|
||||||
|
|
||||||
|
(defalias Vec2 [2 f32])
|
||||||
|
(defalias Vec4 [4 f32])
|
||||||
|
|
||||||
|
;; ── Structs are value types with C layout, no header word ─────────────
|
||||||
|
(defstruct Enemy
|
||||||
|
[pos Vec2
|
||||||
|
vel Vec2
|
||||||
|
hp i32
|
||||||
|
spr (Handle Texture)])
|
||||||
|
|
||||||
|
(defunion Shape
|
||||||
|
[(Circle [r f32])
|
||||||
|
(Rect [w f32 h f32])])
|
||||||
|
|
||||||
|
;; ── Locals inferred; only signatures are annotated ───────────────────
|
||||||
|
(defn area [s Shape] f32
|
||||||
|
(match s
|
||||||
|
(Circle r) (* PI r r)
|
||||||
|
(Rect w h) (* w h)))
|
||||||
|
|
||||||
|
;; ── Lowercase = type variable. Monomorphised at each call site ────────
|
||||||
|
;; There are no type classes, so `a` supports only what EVERY type supports.
|
||||||
|
;; Ordering is not that — it is passed in as a function value. Type arguments
|
||||||
|
;; are inferred from the argument types; there is no explicit instantiation.
|
||||||
|
;; The inner `fn` captures `gt`, a parameter: legal because it does not outlive
|
||||||
|
;; this frame (spec-memory.md, non-escaping fn).
|
||||||
|
(defn largest [xs [a] gt (Fn [a a] bool)] (Option a)
|
||||||
|
(if (> (len xs) 0)
|
||||||
|
(Some (reduce (fn [x y] (if (gt x y) x y)) (nth xs 0) xs))
|
||||||
|
None))
|
||||||
|
|
||||||
|
;; (largest hps >) — `>` at i32 is an ordinary function value
|
||||||
|
;; (largest es (fn [x y] (> (.hp x) (.hp y))))
|
||||||
|
|
||||||
|
;; Parameters are immutable values; pass a pointer to mutate. `[Enemy]` is a
|
||||||
|
;; borrowed slice — centroid neither owns nor frees the storage.
|
||||||
|
(defn centroid [es [Enemy]] Vec2
|
||||||
|
(/ (reduce (fn [acc e] (+ acc (.pos e))) [0 0] es)
|
||||||
|
(f32 (len es))))
|
||||||
|
|
||||||
|
;; ── Handles, not pointers, for anything cross-referenced ──────────────
|
||||||
|
;; Pattern bindings bind VALUES, so matching a struct out of a pool would give
|
||||||
|
;; a copy and `set` would mutate the copy. `resolve` yields (Option (Ptr a))
|
||||||
|
;; instead, and the pointer is visible in the binding's type. `deref` is the
|
||||||
|
;; by-value counterpart. Both are overloaded on (Ptr a)/(Handle a).
|
||||||
|
(defn damage [w (Ptr World) h (Handle Enemy) amount i32]
|
||||||
|
(match (resolve w h)
|
||||||
|
(Some e) (set (.hp e) (- (.hp e) amount)) ; e : (Ptr Enemy), field derefs
|
||||||
|
None (log "stale enemy handle")))
|
||||||
|
|
||||||
|
;; ── Error handling is layered ─────────────────────────────────────────
|
||||||
|
;; Option expected absence: lookup miss, empty collection, end of stream
|
||||||
|
;; Result failure that belongs in the signature; error set inferred
|
||||||
|
;; Condition failure where the CALLER owns the recovery policy
|
||||||
|
;;
|
||||||
|
;; Rule: if you can name the one correct recovery at the point of failure,
|
||||||
|
;; return a Result. If the answer is "depends who's calling", signal.
|
||||||
|
|
||||||
|
;; `some` unwraps Some, else early-returns None.
|
||||||
|
(defn player-weapon [w (Ptr World)] (Option Weapon)
|
||||||
|
(let [p (some (find-player w))
|
||||||
|
s (some (slot (.inventory p) 3))]
|
||||||
|
(Some (.weapon s))))
|
||||||
|
|
||||||
|
;; `try` unwraps Ok, else early-returns Err, widening this function's error
|
||||||
|
;; set. Option→Result conversion is explicit — no implicit From, no anyhow.
|
||||||
|
(defn load-config [path string] (Result Config)
|
||||||
|
(let [text (try (read-file path))
|
||||||
|
table (try (parse-toml text))
|
||||||
|
port (try (ok-or (get table "port")
|
||||||
|
(MissingKey {:key "port"})))]
|
||||||
|
(Ok (Config {:port port}))))
|
||||||
|
|
||||||
|
;; errdefer runs only on the Result failure path — NOT on a restart transfer
|
||||||
|
;; (spec-conditions.md §5). Pairs with explicit allocation.
|
||||||
|
(defn load-atlas [path string] (Result Atlas)
|
||||||
|
(let [buf (alloc-image context/allocator)]
|
||||||
|
(errdefer (free buf))
|
||||||
|
(try (decode-png path buf))
|
||||||
|
(Ok (Atlas {:image buf}))))
|
||||||
|
|
||||||
|
;; ── Conditions: handlers run on the signalling frame, nothing unwinds ─
|
||||||
|
;; load-texture cannot know the right recovery — an editor wants a placeholder,
|
||||||
|
;; a release build wants to abort, a hot-reload session wants to retry after the
|
||||||
|
;; file is fixed on disk. So it offers a menu and the caller chooses.
|
||||||
|
(defcondition AssetMissing [path string])
|
||||||
|
|
||||||
|
;; `signal` has type Unit and RETURNS if every handler returns normally, so the
|
||||||
|
;; fall-through path of a restart-case in value position must still produce the
|
||||||
|
;; type. `abort` has type Never, which unifies with (Handle Texture).
|
||||||
|
;; Every clause body and the restart-case body share one type.
|
||||||
|
(defn load-texture [path string] (Handle Texture)
|
||||||
|
(if (file-exists? path)
|
||||||
|
(rl/load-texture path)
|
||||||
|
(restart-case
|
||||||
|
(do (signal (AssetMissing {:path path}))
|
||||||
|
(abort "unhandled AssetMissing"))
|
||||||
|
(use-placeholder [] placeholder-texture)
|
||||||
|
(retry [] (load-texture path)))))
|
||||||
|
|
||||||
|
;; Intermediate frames say nothing about AssetMissing. Nothing to thread.
|
||||||
|
;; invoke-restart has type Never: it does not return to the handler.
|
||||||
|
(defn load-level [path string] Level
|
||||||
|
(handler-bind [AssetMissing (fn [c]
|
||||||
|
(log "missing asset:" (.path c))
|
||||||
|
(invoke-restart 'use-placeholder))]
|
||||||
|
(parse-level (slurp path))))
|
||||||
|
|
||||||
|
;; A handler that returns normally does not unwind, so the signaller carries on.
|
||||||
|
;; That is error accumulation with no monad or applicative. Restarts go at the
|
||||||
|
;; resync point — once — not in every function below it.
|
||||||
|
;; The result is an owning (Vec Form): it is pushed to, and it is returned by
|
||||||
|
;; move, so the caller owns it.
|
||||||
|
(defn parse-all [p (Ptr Parser)] (Vec Form)
|
||||||
|
(let [forms (make-vec Form)]
|
||||||
|
(until (at-end? p)
|
||||||
|
(restart-case
|
||||||
|
(push forms (parse-form p))
|
||||||
|
(skip-form [] (skip-to-next-delimiter p))))
|
||||||
|
forms))
|
||||||
|
|
||||||
|
(defn collect-parse-errors [src string] (Result Ast)
|
||||||
|
(let [errors (make-vec ParseError)]
|
||||||
|
(handler-bind [ParseError (fn [c]
|
||||||
|
(push errors c) ; value struct: copies out of
|
||||||
|
; the signalling frame
|
||||||
|
(invoke-restart 'skip-form))]
|
||||||
|
(let [ast (parse-all (parser src))]
|
||||||
|
(if (zero? (len errors))
|
||||||
|
(Ok ast)
|
||||||
|
(Err (Errors errors))))))) ; errors moves into the Err
|
||||||
|
|
||||||
|
;; ── Allocators. context/temp resets each frame; nothing freed by hand ─
|
||||||
|
;; `filter` allocates a (Vec Enemy) from the current allocator, which is why
|
||||||
|
;; this is wrapped: the frame arena is bulk-reset, so the Vec is never freed
|
||||||
|
;; individually. `each` borrows it as a slice.
|
||||||
|
(defn draw-frame [w (Ptr World) dt f32]
|
||||||
|
(with-allocator context/temp
|
||||||
|
(->> (as-slice (.enemies w))
|
||||||
|
(filter (fn [e] (on-screen? (.pos e))))
|
||||||
|
(each (fn [e] (rl/draw-texture (.spr e) (.pos e))))))
|
||||||
|
(free-all context/temp))
|
||||||
|
|
||||||
|
;; ── defer for explicit resources ──────────────────────────────────────
|
||||||
|
;; defer DOES run when a restart transfer passes through this frame.
|
||||||
|
(defn save-world [w (Ptr World) path string]
|
||||||
|
(let [f (open path :write)]
|
||||||
|
(defer (close f))
|
||||||
|
(write-bytes f (serialize w))))
|
||||||
|
|
||||||
|
;; ── Fixed arrays: component-wise ops and swizzles, no library ─────────
|
||||||
|
(defn reflect [v Vec4 n Vec4] Vec4
|
||||||
|
(- v (* 2.0 (dot v n) n)))
|
||||||
|
|
||||||
|
(defn to-2d [v Vec4] Vec2 (.xy v))
|
||||||
|
|
||||||
|
;; ── Later: async as a state-machine transform, not fibers ─────────────
|
||||||
|
;; The handler and restart stacks live in the task state, not thread-local.
|
||||||
|
;;
|
||||||
|
;; An imperative loop, not (each (fn [p] (try ...))): `try` and `return` inside a
|
||||||
|
;; `fn` exit the FN, so a callback would swallow the Err instead of propagating
|
||||||
|
;; it out of preload.
|
||||||
|
(defn ^:async preload [paths [string]] (Result Unit)
|
||||||
|
(for [p paths]
|
||||||
|
(try (await (load-texture-async p))))
|
||||||
|
(Ok unit))
|
||||||
@ -1,2 +1,3 @@
|
|||||||
(test
|
(test
|
||||||
(name test_flan))
|
(name test_flan)
|
||||||
|
(libraries flan))
|
||||||
|
|||||||
@ -0,0 +1,121 @@
|
|||||||
|
(* Reader tests. Plain assertions, no test framework — another dependency that
|
||||||
|
would have to be reimplemented if the compiler is ever self-hosted. *)
|
||||||
|
|
||||||
|
open Flan
|
||||||
|
|
||||||
|
let failures = ref 0
|
||||||
|
|
||||||
|
let check name cond =
|
||||||
|
if not cond then begin
|
||||||
|
incr failures;
|
||||||
|
Printf.printf "FAIL %s\n" name
|
||||||
|
end
|
||||||
|
|
||||||
|
let reads name src expected =
|
||||||
|
match Reader.read_all ~file:"<test>" src with
|
||||||
|
| forms ->
|
||||||
|
let got = String.concat " " (List.map Form.to_string forms) in
|
||||||
|
if got <> expected then begin
|
||||||
|
incr failures;
|
||||||
|
Printf.printf "FAIL %s\n src: %s\n got: %s\n wanted: %s\n"
|
||||||
|
name src got expected
|
||||||
|
end
|
||||||
|
| exception Loc.Error (loc, msg) ->
|
||||||
|
incr failures;
|
||||||
|
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
|
||||||
|
name src (Loc.to_string loc) msg
|
||||||
|
|
||||||
|
let rejects name src =
|
||||||
|
match Reader.read_all ~file:"<test>" src with
|
||||||
|
| _ -> incr failures; Printf.printf "FAIL %s: expected a read error\n" name
|
||||||
|
| exception Loc.Error _ -> ()
|
||||||
|
|
||||||
|
let () =
|
||||||
|
(* ── Atoms ─────────────────────────────────────────────────────── *)
|
||||||
|
reads "integer" "42" "42";
|
||||||
|
reads "negative" "-1" "-1";
|
||||||
|
reads "float" "0.05" "0.05";
|
||||||
|
reads "hex" "0xE6B800FF" "3870818559";
|
||||||
|
reads "string" "\"SAND\"" "\"SAND\"";
|
||||||
|
reads "symbol" "empty-at?" "empty-at?";
|
||||||
|
reads "qualified" "rl/draw-fps" "rl/draw-fps";
|
||||||
|
reads "field access" ".pos" ".pos";
|
||||||
|
reads "operator" "->>" "->>";
|
||||||
|
reads "bare minus" "-" "-";
|
||||||
|
reads "keyword" ":space" ":space";
|
||||||
|
reads "else keyword" ":else" ":else";
|
||||||
|
|
||||||
|
(* Byte literals, as used by calc-me's tokenizer. *)
|
||||||
|
reads "byte named" "\\space" "\\space";
|
||||||
|
reads "byte digit" "\\0" "\\0";
|
||||||
|
reads "byte paren" "\\(" "\\(";
|
||||||
|
reads "byte dot" "\\." "\\.";
|
||||||
|
|
||||||
|
(* ── Sequences ─────────────────────────────────────────────────── *)
|
||||||
|
reads "list" "(+ 1 2)" "(+ 1 2)";
|
||||||
|
reads "vector" "[1 2 3]" "[1 2 3]";
|
||||||
|
reads "map literal" "{:src src :pos 0}" "{:src src :pos 0}";
|
||||||
|
reads "type notation" "[4 f32]" "[4 f32]";
|
||||||
|
reads "nested type" "[rows [cols u32]]" "[rows [cols u32]]";
|
||||||
|
reads "commas as space" "[1, 2, 3]" "[1 2 3]";
|
||||||
|
reads "nested" "(a (b [c {:d e}]))" "(a (b [c {:d e}]))";
|
||||||
|
|
||||||
|
(* ── Trivia ────────────────────────────────────────────────────── *)
|
||||||
|
reads "line comment" "; nope\n42" "42";
|
||||||
|
reads "trailing comment" "42 ; nope" "42";
|
||||||
|
reads "banner comment" ";;;; header\n(f)" "(f)";
|
||||||
|
reads "multiple forms" "(a) (b)" "(a) (b)";
|
||||||
|
reads "empty source" "" "";
|
||||||
|
reads "only comments" "; nothing here" "";
|
||||||
|
|
||||||
|
(* ── Quote ─────────────────────────────────────────────────────── *)
|
||||||
|
(* Restart names are quoted symbols. Before this existed, 'skip-form read as
|
||||||
|
a symbol *named* "'skip-form", which is silently a different symbol from
|
||||||
|
skip-form and nothing would ever have reported it. *)
|
||||||
|
reads "quote symbol" "'skip-form" "(quote skip-form)";
|
||||||
|
reads "quote in call" "(invoke-restart 'use-placeholder)"
|
||||||
|
"(invoke-restart (quote use-placeholder))";
|
||||||
|
reads "quote list" "'(a b)" "(quote (a b))";
|
||||||
|
|
||||||
|
(* The whole class: no reader-significant character may end up inside a name. *)
|
||||||
|
let rec bad_names f =
|
||||||
|
let open Form in
|
||||||
|
match f.v with
|
||||||
|
| Sym s | Kw s ->
|
||||||
|
if String.exists (fun c -> c = '\'' || c = '^') s then [ s ] else []
|
||||||
|
| List l | Vec l | Map l -> List.concat_map bad_names l
|
||||||
|
| _ -> []
|
||||||
|
in
|
||||||
|
let corpus = "(invoke-restart 'skip-form) (a 'b [c 'd] {:e 'f}) '(g 'h)" in
|
||||||
|
check "no sigils leak into names"
|
||||||
|
(bad_names (Form.make (Form.List (Reader.read_all ~file:"<test>" corpus))
|
||||||
|
Loc.unknown) = []);
|
||||||
|
|
||||||
|
(* ── Errors ────────────────────────────────────────────────────── *)
|
||||||
|
rejects "unclosed list" "(f x";
|
||||||
|
rejects "unbalanced close" ")";
|
||||||
|
rejects "mismatched" "(f x]";
|
||||||
|
rejects "unterminated str" "\"abc";
|
||||||
|
rejects "empty keyword" ":";
|
||||||
|
rejects "unknown char" "\\bogus";
|
||||||
|
rejects "metadata" "^:async";
|
||||||
|
rejects "dangling quote" "'";
|
||||||
|
|
||||||
|
(* ── Locations ─────────────────────────────────────────────────── *)
|
||||||
|
(match Reader.read_all ~file:"f.flan" "(a)\n (b)" with
|
||||||
|
| [ a; b ] ->
|
||||||
|
check "loc line 1" (a.loc.line = 1 && a.loc.col = 1);
|
||||||
|
check "loc line 2" (b.loc.line = 2 && b.loc.col = 3);
|
||||||
|
check "loc file" (a.loc.file = "f.flan")
|
||||||
|
| _ -> check "loc: two forms" false);
|
||||||
|
|
||||||
|
(match Reader.read_all ~file:"f.flan" "(f\n bad" with
|
||||||
|
| _ -> check "unclosed reports opening loc" false
|
||||||
|
| exception Loc.Error (loc, _) ->
|
||||||
|
check "unclosed reports opening loc" (loc.line = 1 && loc.col = 1));
|
||||||
|
|
||||||
|
if !failures = 0 then print_endline "reader: all tests passed"
|
||||||
|
else begin
|
||||||
|
Printf.printf "\n%d failure(s)\n" !failures;
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
Loading…
x
Reference in New Issue
Block a user