- 1 Getting started
- 1.1 Your first bjolang program
- 1.2 Declaring types
- 1.3 Values, types and signatures
- 1.4 Functions
- 1.5 Bindings and control flow
- 1.6 Your own types
- 1.7 Pattern matching
- 1.8 Options, results and failure
- 1.9 Collections
- 1.10 Loops and sequences
- 1.10.1 loop
- 1.10.2 Accumulators
- 1.10.3 Several collections: lockstep and nesting
- 1.10.4 State that carries over: :with
- 1.10.5 Stopping early
- 1.10.6 Looping while something holds: :while and :until
- 1.10.7 Counting and walking part of a collection
- 1.10.8 Named loops
- 1.10.9 Comprehensions
- 1.10.10 Lazy sequences: seq and yield
- 1.10.11 map, filter and friends
- 1.10.12 Example: dealing and counting
- 1.11 Strings and text
- 1.12 Traits
- 1.12.1 Declaring and implementing a trait
- 1.12.2 Default methods
- 1.12.3 Constraints
- 1.12.4 Associated types
- 1.12.5 Implementations for particular type arguments
- 1.12.6 Conditional and blanket implementations
- 1.12.7 Where an implementation is written
- 1.12.8 Traits that require other traits
- 1.12.9 Giving a newtype the traits of the type it wraps
- 1.12.10 Eq, Ord, Written and Displayed
- 1.12.11 Values of different types in one collection: dyn
- 1.12.12 Traits that are .NET interfaces
- 1.12.13 Example: cards that sort
- 1.13 Numbers
- 2 Writing programs
- 2.1 Modules
- 2.2 Projects and packages
- 2.3 Files and I/O
- 2.4 Formatting text
- 2.5 Concurrency
- 2.6 Effects
- 2.7 .NET interop
- 2.8 Macros
- 2.9 Testing and the REPL
- 2.10 Benchmarking
- 2.10.1 time-it
- 2.10.2 A benchmark file
- 2.10.3 How a benchmark is measured
- 2.10.4 Setup, parameters, and the rest
- 2.10.5 Choosing what runs
- 2.10.6 More numbers
- 2.10.7 Which of two ways is faster
- 2.10.8 Saving a run and comparing with it
- 2.10.9 Two versions against each other:
--against - 2.10.10 The flags
- 2.10.11 Driven by another program
- 2.11 Performance
- 3 The standard library
- 3.1 std/prelude
- 3.2 std/maths
- 3.3 std/array and std/simd
- 3.4 std/fmt
- 3.5 std/rx
- 3.6 std/scan
- 3.7 std/http
- 3.8 std/url
- 3.9 std/run
- 3.10 std/random
- 3.11 std/uuid
- 3.12 std/crypto/hash
- 3.13 std/atomic
- 3.14 std/stm
- 3.15 std/datetime
- 3.16 std/eq and std/clr-ord
- 3.17 std/monad
- 3.18 std/effect
- 3.19 std/hashset, std/orderedset and std/orderedmap
- 3.20 std/priority-queue
- 3.21 std/mutable
- 3.22 std/inbox
- 3.23 std/ports and std/net
- 3.24 std/test and std/simpletest
- 3.25 std/bench
- 3.26 std/stopwatch
- 3.27 std/syntax-match
- 3.28 text/bjodat
- 3.29 text/json and text/json-codec
- 3.30 text/xml
- 3.31 text/encodings
- 4 Appendices
- 4.1 Coming from Scheme or ML
- 4.2 Reading compiler errors
- 4.2.1 1. Missing top-level type signature
- 4.2.2 2. Calling a bjoroutine from a synchronous function
- 4.2.3 3. Mismatch between signature and argument count
- 4.2.4 4. Non-exhaustive pattern matching
- 4.2.5 5. Exporting a binding that references a private type
- 4.2.6 6. Accessing fields of an opaque type
- 4.2.7 7. Using a macro where it is defined
- 4.3 How it compiles
- 4.4 Editor support
1 Getting started↑
Bjolang is only installed from source. There are no releases or packages: you clone the repository at github.com/bjoli/Bjolang and build it where it is.
It needs only two things: dotnet 10 and an f# compiler. The .NET 10 SDK comes with both. Once that is installed, clone the repository and build the runtime, the compiler and the standard library:
git clone https://github.com/bjoli/Bjolang
cd Bjolang
dotnet build BjolangRuntime/BjolangRuntime.csproj -c Release
dotnet build -c Release
./build_std.sh
If you want to make sure everything works, run ./run_tests.py and ./run_bjo_tests.py in the same directory. Then you can use the bjo executable in the bjo directory: ./bjo/bjo run hello.bjo. Personally I alias a shell command to the path of bjo, since there currently is no way to install it properly. Compiled programs find the standard library in the directory you built it in, so leave the checkout where it is.
To update, pull and build again:
git pull
./bjo/bjo rebjo
./build_std.sh
1.1 Your first bjolang program↑
Bjolang compiles to c#. Many of the design decisions behind bjolang stems from this limitation. The first one you will encounter is that a program needs a main function, like the one in c#. An int it returns is the program’s exit code, 0 when it ran correctly; a main that returns anything else exits with 0.
(: print-hello (-> string void))
(defun (print-hello name)
(println (str "Hello, " name "!")))
(defun (main args)
(print-hello "User"))
This can then be run using bjo run filename, and it should print Hello, User!.
What you can see here is that defun is the form for defining a function. main is special, it needs no type declaration. print-hello is a regular function, and thus has to declare its type. Everything at toplevel needs to do that. The form is (: name type). A function type is declared as (-> ArgType … Return-Type). print-hello takes a string argument and returns void. void is the name for nothing: the unit type, whose one value is what println gives back.
1.2 Declaring types↑
For most of your bjolang code, you will only declare types in the top level. Types within functions are inferred, so declaring types there is almost never necessary. A value type is declared just like a function, but without the function type declaration:
(: usermap (Map Keyword string))
(def usermap #map((:admin "Linus") (:moderator "Sunil") (:janitor "Björnstam")))
You can also declare your own types. Either aliases of other types, records of your own, or union types.
(type
(: Suit (Union Hearts Diamonds Clubs Spades))
(: Card (Struct (: rank byte) (: suit Suit)))
(: User (Record (: name string)
(: age byte)
(: favourite-playing-card Card)))))
This can then be destructed using pattern matching.
(: print-user (-> User void))
(defun (print-user u)
(match u
;; Subpatterns: here we match name, age, the Card rank
;; but only succeed if the Suit is Hearts.
((User name age (Card rank (Suit Hearts)))
(println "Oh my god, we got hearts!"))
((User (age 100))
(println "I have no idea which card, but the user is old"))
((User name) (println "The user is called ${name}."))
;; pattern matching does it's best to do exhaustiveness checks.
;; and while it is usually pretty good at figuring out whether
;; patterns are exhaustive, sometimes it cannot. Then you will have to add
;; a match-all
(_ (panic! "This should not happen."))))
1.3 Values, types and signatures↑
These are the bjolang literals:
| Type | Form |
| int,byte,long, ulong uint | 1 0 -4 200000 |
| long | -5000000000L |
| ulong | 50000000000UL |
| double | 1.234 |
| decimal | 1.0m |
| bigint | 1234567890123456789012345I |
| string | “hej” |
| symbol | ‘hej |
| keyword | #:keyword :keyword (these are equal) |
| character | #\space #\a #\newline |
| bool | #t #f |
| unit/void | unit |
Then there are some special cases, like string interpolation:
(let ((an-int 5)
(a-string "hej"))
(println $"This is interpolated: ${an-int} ${a-string}")
;; Which is literally the same as:
(println (str "This is interpolated: " (displayed an-int) " " (displayed a-string))))
Each hole goes through displayed, so a string hole gives its text without quotes. See Converting to and from strings below.
1.3.1 Function types↑
A function takes any number of arguments and produces a value. The signature of a function in bjolang is denoted using the arrow ->:
(: double (-> int int))
(defun (double x)
;; the last expression in the body is the return value
(* x 2))
(: print-n-times (-> string int void))
(defun (print-n-times what n)
(loop (:for i (up-from 0 #:to n))
(:do (println what))))
;; call twice takes a function take takes an argument
;; of type %a and returns void.
(: call-twice (-> (-> %a void) %a void))
(defun (call-twice f val)
(f val)
(f val))
Bjolang also supports keyword arguments. The caller do not have to supply them, and if they are not supplied they are bound to a default expression that is evaluated in the function body.
(: n-times (-> int (#:what string) string))
(defun (n-times n #:what (string-append "place" "holder"))
(def sb (stringbuilder-empty))
(loop (:for i (up-from 0 #:to n))
(stringbuilder-add-string! sb placeholder))
(stringbuilder->string sb))
;; Calling it now returns:
(n-times 2) ;; -> "placeholderplaceholder"
(n-times 500 #:what "hej!\n") ;; -> "hej!\nhej!\nhej!\n..."
The default expression of the keyword argument is evaluated in the body of the function every time the function is called without a supplied argument. Keywords must also be in the same order in the signature and definition. A function with keywords yields two c# bodies, one with optional arguments and one with the default expressions, UNLESS the default arguments are c# literals. Then the function is a regular c# function with named arguments.
1.3.2 Inference↑
Everything at the toplevel needs a signature, even values. In a function body, nothing strictly needs a signature. Function bodies are inferred using Hindley Milner type inference. This is of course comfortable, but also has some limits. They type of an undeclared type is inferred at first usage.
;; pretend we are in a function body
(def a (transientmap-empty))
(add! a (:key . 5)) ;; a is pinned to (Transientmap Keyword int).
(add! a (:key2 . "hej")) ;; fails
The example above is of course facile, but it matters more for generic functions with inlined traits. They are pinned to the first use, and any subsequent usage will raise an error. More on this in the trait section.
1.3.3 Typed holes↑
When you don’t know what to write somewhere, write _ there. That is a typed hole (not to be confused with the holes of string interpolation). A program with a typed hole does not compile. Instead the compiler tells you what type is wanted at that spot, and which bindings in scope have that type:
(: lengths (-> (Vec string) (Vec int)))
(defun (lengths names)
(vec-map _ names))
Hole at lengths.bjo:3:12: '_' stands for a value of type (-?-> string int).
Bindings in scope that fit: string-count, unhashable, count, eq-hash, length, structural-hash, and 2 more.
-?-> is a function that may or may not suspend, which is what vec-map accepts. More on that under Concurrency.
The bindings closest to the hole are listed first: locals, then your own module’s, then imported ones. A binding with a where clause is only listed if the clause holds. A name that starts with an underscore, like _total, is a hole too as long as nothing binds it, which is handy when you have several. If nothing around the hole decides its type, you get a ?a and what it has to be able to do, like (Displayed ?a). In the REPL, (: _ string) is a quick way to ask what in scope is a string.
While a hole’s type is still open, the other errors in the same definition are held back, since they are often caused by the hole. Once you fill it in, they show up.
1.3.4 Unused definitions↑
The compiler warns about definitions nothing uses: a top-level def or defun that the module doesn’t export and nothing in it reads, and a local binding (a let, a def in a body, a loop variable) that nothing in its scope reads. Dead code piles up quietly in a large program, and this keeps it from doing so.
If you keep something on purpose, start its name with an underscore: (def _registered (register!)), or (:for _ (range 0 n)) for a loop that just repeats. Parameters and pattern variables are not checked.
1.3.5 Naming conventions↑
Bjolang follows scheme’s naming conventiens. Predicates end with ?. Mutating functions end with !. Conversions are written using ->.
While the base types are lowercase (which is a design mishap), types should be capitalized. Record and struct fields are lower case.
1.4 Functions↑
As you have seen already, functions are declared using defun. Bjolang also has anonymous functions defined using fun:
(list-map (fun (x) (* x x)) (list 1 2 3 4)) ;; -> '(1 4 9 16).
;; there is also a lambda shorthand where argument are numbered
;; using &. & = &1.
(println (#(+ & &2) 2 3)) ;; -> prints 5.
(println (#(+ &1 &3) 1 2 3)) ;; -> prints 4.
Functions can also be generic, to handle any kind of argument. This is useful if you have a function that does not need to inspect the values.
(: make-tuple (-> (List %a) %b (Tuple %a %b)))
(defun (make-tuple ls val)
(match ls
((Cons car _) (Tuple car val))
(_ (panic! "this did not work as expected."))))
Generics can also be constrained to work on Traits. For example this signature, for a function that can compare the first element of a tuple with the elements of a Vec.
(: (-> (Tuple %a %b) (Vec %a) bool) (where (Eq %a)))
1.4.1 Local functions and inline signatures↑
A local function defined inside another function looks like a top-level defun, but does not need a separate (: ...) type signature because local types are inferred automatically with Hindley-Milner type inference. In fact, local functions do not support separate (: ...) signatures at all.
When you want to document types explicitly or constrain inference, you can write the signature inline directly on the defun by annotating each parameter with (: param Type) and following the parameter list with : ReturnType:
(defun (process-numbers ls)
(defun (clamp (: n int) (: max int)) : int
(if (> n max) max n))
(list-map #(clamp & 100) ls))
Parameters can use any type, including type variables for generic local functions:
(defun (pair-up val ls)
(defun (wrap (: x %a)) : (Tuple %a %b)
(Tuple x val))
(list-map wrap ls))
Note that if you supply an inline signature, all parameters and the return type must be annotated together; partial inline annotations are not permitted. Trait constraints (where clauses) and keyword argument defaults cannot be written inline and require top-level signatures.
1.5 Bindings and control flow↑
1.5.1 binding forms↑
Variables are bound using, primarily, four forms def, def* let, let*. let and let* are the same as in scheme. let binds values in parallel. let* binds sequentially.
(def a 1)
(def b 2)
(let ((a 4) (b (+ a 1)))
(println a) ;; -> 4
(println b)) :: -> 2
(let* ((a 4) (b (+ a 1)))
(println a) ;; 4
(println b)) ;; 5
def* is a form that mostly just saves on key presses:
(def* (a 5)
(b 6)
(c (+ a b)))
1.5.2 conditional executions - if, cond, when, unless, and case↑
The base form for executing code conditionally is (if test-expr true-expr false-expr). The prelude defines the cond macro that allows for chaining tests:
(def weather (get-weather-symbol))
(if (= weather 'rain)
(get-umbrella)
(if (= weather 'windy)
(get-jacket)
(get-shorts)))
;; can be written as
(cond
((= weather 'rain) (get-umbrella))
((= weather 'windy) (get-jacket))
(else (get-shorts)))
;; which can be written as
(case weather
('rain (get-umbrella))
('windy (get-jacket))
(else (get-shorts)))
For the cases where code starts drifting rightward, there are two special clauses in the cond macro, :def and :do. This is taken from actual bjolang code.
(cond
((> depth json-max-depth) (Err (too-deep rd "array")))
;; this executes two things and binds the variable `mark` before continuing
(:do
(step! rd)
(skip-space! rd))
(:def mark (record-ref rd top))
((at-end? rd) (Err (at rd "the document ended inside an array")))
((char=? (peek rd) #\]) (step! rd) (Ok (JsonArr (harvest! rd mark))))
(else ...))
The above is usually a code smell, but can be useful in managing the kind code drift that sometimes plagues lisp code.
1.5.3 with-return and bailing from failed bindings↑
Bjolang has 2 solutions to the problem that is sometimes solved by do-notation in functional languages. Thi (with-return ret body ... form binds an espace function to ret, which means code within it can return from the body (but not within local functions). This is often used with the destructuring of the def and def* forms.
Within a with-return you can use the bound label to directly return a value from the with-return block
(with-return ret
(def a (read-line))
(when (= "" a)
(ret 0))
(println "hej hopp hej hej")
(string-count a))
The def forms have a particular thing that they do when they are usid with refutable patterns (patterns that can fail). Then they take one of four extra clauses:
(: do-things (-> (List int) (Result string string)))
(defun (do-things ls)
;; Try to match a list with 3 elements and return an Err if it fails
(def (List a b c) ls :leave-with (Err "The string did not have 3 elements"))
;; Try get the key "a" from a map and leave with err
(def (Some elem) (try-ref my-map a) :leave-with (Err "The map lacked a key"))
;; open the filename in `elem` and propagate any error.
(def (Ok port) (open-input-file elem) :propagate)
(println "Here we know port is bound. ")
(Ok "the file exists"))
The other clauses are :default expr that binds the variable to expr if the match fails. This only works with patterns that bind one variable. Then there is :leave which take the other pattern arms:
(def (Ok b) expr :leave ((Err "ojdå") (println "special swedish error encountered") expr)
((Err b) expr))
1.5.4 Mutable bindings↑
Mutable bindings are defined with def/mutable. They can be mutated with the set! form. Generic local mutable variables generalize at the first set!, and top-level generic bindings are not allowed.
(defun (main args)
(def/mutable b 0)
(loop (:for a args)
(:when (my-special-predicate? a))
(set! b (+ b a)))
b)
1.6 Your own types↑
A program without your own types are boring.
1.6.1 Aliases↑
A type alias is a different name for a type that already exists. It is declared with the type form.
(type (: Stringalias string))
1.6.2 Newtypes↑
A newtype is the opposite of an alias: a type of its own, which the compiler will not mix up with the type it wraps, but which is that type when the program runs, so it costs nothing.
(type (: UserId (Newtype int)))
(type (: OrderId (Newtype int)))
(def u (UserId 7)) ;; built by its name
(def (UserId n) u) ;; taken apart by a pattern: n is 7
(: (-> UserId string))
(defun (lookup-user id) ...)
(lookup-user (OrderId 7)) ;; a compile error: an OrderId is not a UserId
A newtype does not get the traits of the type it wraps: (+ u u) is an error, though (+ 7 7) is not. It gets those you give it, as the section on traits shows.
A newtype can have a type parameter that its value does not use. Such a parameter is called a phantom: it is only there to tell values apart. Instead of one newtype for each kind of id, there can be one Id that says what it is an id of. The tags are types with no values, written as unions with no cases:
(type (: User (Union))) ;; tags: types with no values
(type (: Order (Union)))
(type (: (Id %a) (Newtype int))) ;; %a is in no field
(: lookup-user (-> (Id User) string))
(defun (lookup-user id) ...)
(: id-number (-> (Id %a) int)) ;; for an id of any kind
(defun (id-number id)
(def (Id n) id)
n)
(lookup-user (: (Id 7) (Id Order))) ;; a compile error: an (Id Order) is not an (Id User)
An (Id User) is still an int when the program runs. A function can be written once for every kind of id, as id-number is, and a trait can have an implementation for each tag, such as one for (Id User) and another for (Id Order).
A tag can also record what has been checked about a value. Here only validate can make an (Email Valid), so send never gets an address nobody checked:
(type (: Raw (Union)))
(type (: Valid (Union)))
(type (: (Email %s) #:opaque (Newtype string)))
(: validate (-> (Email Raw) (Option (Email Valid))))
(defun (validate e)
(def (Email s) e)
(if (string-contains? s "@") (Some (Email s)) None))
(: send (-> (Email Valid) void))
(defun (send e) ...)
#:opaque, described under exporting types in “Writing programs”, is what keeps another module from building an (Email Valid) itself, with (Email s).
1.6.3 Records and structs↑
Records and structs are direct mappings to c# records and immutable structs. The constructor is the type name, together with parenthesised fields. As follows.
(type
(: Ripeness (Union Ripe Megaripe Rotten))
(: Fruit (Record (: name string)
(: amount int)
(: ripeness Ripeness))))
(def banana (Fruit (name "banana") (amount 5) (ripeness Rotten)))
A field can be marked #:mutable, to make the record (not struct) mutable. This however makes using the record in places that requires hashing fail at runtime. The mutable fields can be updated with record-set!.
(type (: Reader (: pos integer #:mutable)
(: port TextInputPort)))
...
(record-set! my-reader pos (+ 1 (record-get my-reader pos)))
1.6.4 Unions↑
A union is a type whose values are one of several cases. A case either carries nothing, and is then written as a bare name, or it carries a payload, and is then written like a record field: (: CaseName type ...).
(type
;; Four cases that carry nothing.
(: Suit (Union Clubs Diamonds Hearts Spades))
;; A card's rank is either a number card, carrying its number, or a court card.
(: Rank (Union (: Pip int) Jack Queen King Ace))
;; A case may carry more than one value.
(: Shape (Union (: Circle double) (: Rect double double) Empty)))
A case is also its constructor. A case without a payload is a value, and a case with a payload is a function that builds one:
(def trump Hearts) ;; Suit
(def seven (Pip 7)) ;; Rank
(def square (Rect 2.0 2.0)) ;; Shape
;; Since (Pip ...) is a function, it can be passed around like one:
(list-map Pip (list 2 3 4)) ;; a (List Rank) of three number cards
You take a union apart with match, which gets its own section below. The short version is that every case gets a clause, and the payload is bound to names:
(: area (-> Shape double))
(defun (area s)
(match s
((Circle r) (* 3.14159 (* r r)))
((Rect w h) (* w h))
(Empty 0.0)))
(area (Rect 2.0 3.0)) ;; 6.0
The compiler checks that a match covers every case. If you add a Triangle to Shape, every match on a shape that does not handle it becomes a compile error, which is most of the reason to use a union in the first place.
Unions can be generic. The type variables go after the type name, just like for records:
(type (: (Maybe %a) (Union Nothing (: Just %a))))
(type (: (Either %l %r) (Union (: Left %l) (: Right %r))))
(: maybe-first (-> (List %a) (Maybe %a)))
(defun (maybe-first xs)
(match xs
(() Nothing)
((Cons x _) (Just x))))
You never have to write these two, since the prelude already has them under the names (Option %a), with the cases None and Some, and (Result %e %a), with the cases Err and Ok. They are ordinary unions, and they get a section of their own further down.
Two unions can each have a case of the same name, in one module or in two. Which one a use of the name means is decided by its type: the type wanted where it is written, the type of the value a match takes apart, or the number of values it is given, when only one of the cases takes that many.
(type (: Light (Union Red Amber Green)))
(type (: Colour (Union Red Blue (: Grey int))))
(: stop (-> Light))
(defun (stop) Red) ;; a Light, because stop returns one
(: colour-name (-> Colour string))
(defun (colour-name c)
(match c
(Red "red") ;; Colour's, because c is a Colour
(Blue "blue")
((Grey n) "grey")))
(list Red Amber) ;; Lights, because Amber is
(println (: Red Colour)) ;; written out
When nothing in the definition says which union is meant, a case your module declares wins over an imported one, and an imported one over the standard library’s. If that does not decide it either, as for two Reds of your own in (println Red), the compiler asks you to write the type: (: Red Light).
A few things to know about unions:
- A case cannot have the name of a record or a newtype of the same module, because those are built by their names too.
- A union case cannot have a
#:mutablefield. printlnon a value of your own type prints it as you would build it, for example(Card (rank Queen) (suit Hearts))or(Pip 7). The compiler writes this for each type you declare. To print a type another way, implementDisplayed(see the traits section).
1.6.5 Types that refer to themselves and each other↑
A type may mention itself. This is how you write trees, expression languages and anything else recursive:
(type (: (Tree %a) (Union Leaf (: Node (Tree %a) %a (Tree %a)))))
(: tree-insert (-> int (Tree int) (Tree int)))
(defun (tree-insert x t)
(match t
(Leaf (Node Leaf x Leaf))
((Node l v r)
(cond ((< x v) (Node (tree-insert x l) v r))
((> x v) (Node l v (tree-insert x r)))
(else t)))))
(: tree->list (-> (Tree %a) (List %a)))
(defun (tree->list t)
(match t
(Leaf '())
((Node l v r) (list-append (tree->list l) (Cons v (tree->list r))))))
(tree->list (list-foldl tree-insert Leaf (list 5 2 8 1 9 2))) ;; '(1 2 5 8 9)
Types may also refer to each other. All the types of a module are known before any of them is checked, so the order they are declared in does not matter, and they do not even have to be in the same type form:
(type (: Expr (Union (: Num int)
(: Add Expr Expr)
(: Let Binding Expr)
(: Var string))))
(type (: Binding (Record (: name string) (: value Expr))))
1.6.6 Deriving equality and ordering↑
Every type can be compared with =. By default a record, struct or union is compared field by field. To use your type as a key in a hash map, or to sort it, it is better to say so explicitly with type/derive. It is written like type, with a list of traits in front:
(type/derive (Eq Ord)
(: Suit (Union Clubs Diamonds Hearts Spades))
(: Rank (Union (: Pip int) Jack Queen King Ace))
(: Card (Struct (: rank Rank) (: suit Suit))))
Eq gives you = and a matching hash. Ord gives you an ordering, and with it compare, less?, list-sort, list-max and everything else that orders things. The derived order is the obvious one:
- A union orders by case first, in the order the cases are declared, so above
Clubsis less thanSpadesand everyPipis less thanJack. Two values of the same case are ordered by their payload. - A record or struct compares its fields in declaration order and stops at the first one that differs. Above, cards are ordered by rank first and by suit when the ranks are equal.
(list-sort (list (Card (rank Ace) (suit Spades))
(Card (rank (Pip 7)) (suit Hearts))
(Card (rank (Pip 10)) (suit Clubs))
(Card (rank (Pip 7)) (suit Clubs))))
;; 7♣ 7♥ 10♣ A♠
(less? (Card (rank (Pip 2)) (suit Spades))
(Card (rank Jack) (suit Clubs))) ;; #t: rank decides first
Note that < and > are not in that list. They are for numbers and characters only. For anything with an Ord, use less?, greater?, at-most? and at-least?, or compare, which returns a negative number, zero or a positive number.
When the derived order is not the one you want (for example if aces should sometimes be low), you write the implementation by hand. That is covered in the traits section.
1.6.7 The card game↑
The rest of this chapter uses a card game as a running example, so here are its types in one place. Put this at the top of a file and the examples in the following sections can be pasted below it.
(type/derive (Eq Ord)
(: Suit (Union Clubs Diamonds Hearts Spades))
(: Rank (Union (: Pip int) Jack Queen King Ace))
(: Card (Struct (: rank Rank) (: suit Suit))))
(type (: Player (Record (: name string)
(: hand (Vec Card))
(: tricks int))))
;; How a card prints. Traits are explained later; for now it is enough
;; to know that Displayed is what println and #"...${...}" use, and that
;; display-into adds the text to the string builder b.
(impl (Displayed Suit)
(defun (display-into s b)
(stringbuilder-add-string! b
(match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠")))))
(impl (Displayed Rank)
(defun (display-into r b)
(stringbuilder-add-string! b
(match r
((Pip n) (int->string n))
(Jack "J") (Queen "Q") (King "K") (Ace "A")))))
(impl (Displayed Card)
(defun (display-into c b)
(display-into (record-ref c rank) b)
(display-into (record-ref c suit) b)))
(println (Card (rank Queen) (suit Hearts))) ;; Q♥
(written (Card (rank Queen) (suit Hearts))) ;; "(Card (rank Queen) (suit Hearts))"
Without the three implementations, println prints the card as it is written: (Card (rank Queen) (suit Hearts)).
Card is a struct because it is two small values and is copied around a lot. Player is a record because it holds a whole hand. That is the usual rule of thumb: a struct for something small that behaves like a value, a record for everything else. A struct also cannot contain itself and cannot have mutable fields.
Records are updated by copying, with record-set:
(: won-trick (-> Player Player))
(defun (won-trick p)
(record-set p (tricks (+ 1 (record-ref p tricks)))))
(def ada (Player (name "ada") (hand []) (tricks 0)))
(record-ref (won-trick (won-trick ada)) tricks) ;; 2
(record-ref ada tricks) ;; still 0
A field can also be read with (.field value), which is the same as record-ref. On its own, .field is a function, so it can be passed around:
(.tricks ada) ;; 0
(list-map .tricks (list ada (won-trick ada))) ;; '(0 1)
1.6.8 Reading and changing nested data↑
Data is often a record holding a map holding a vec of records. Reading something deep inside it, or making a copy with one thing changed deep inside it, is a lot of record-ref and record-set by hand. The prelude has four macros for it instead. Each takes a value and a path: a list of steps, where a step written .field is a field, and any other step is a key into a map or an index into a vec.
(type
(: Player (Record (: name string) (: score int)))
(: Team (Record (: players (Vec Player)) (: tags (Map string int)))))
(def team (Team (players [(Player (name "Ada") (score 3))
(Player (name "Bo") (score 5))])
(tags #map(("fruit" 1)))))
(get team .players 1 .name) ;; "Bo"
(try-get team .tags "veg") ;; None
(set team .players 0 .name "Bea") ;; a new Team; team is unchanged
(update team .players 1 .score (fun (s) (+ s 10))) ;; Bo has 15 in the new one
| Form | Does |
|---|---|
(get value step ...) | reads along the path, and throws if a key is missing |
(try-get value step ...) | the same as an Option: None at the first key that is missing |
(set value step ... new) | a copy of value with new at the end of the path |
(update value step ... f) | a copy of value with (f old) at the end of the path |
A key is read with ref and written with ref-set, so the path can go through anything that implements the traits behind them: Refable to read, and Settable to write, which Vec, Map and OrderedMap do. A field step can read a .NET property too, as in (get team .players 0 .name .-Length), but only a Bjolang record or struct can be copied with a field changed, so set and update refuse one.
Some details worth knowing:
setdoes not read the last key, so setting a key a map does not have adds it. A key that is missing earlier in the path throws, asrefdoes.try-getgivesNonefor a missing map key or a vec index out of range. The keys after a missing one are not evaluated.setandupdateevaluate the value, then each key from left to right, then the new value or the function, each of them exactly once.- These are macros, so a step is a field or a key by how it is written, not by its type:
.nameis always a field, and anything else is always a key.
1.7 Pattern matching↑
Pattern matching is how you take values apart in bjolang. There is no car/cdr style of programming on unions: you say what shape you expect and the compiler binds the parts for you, and checks that you did not forget a shape.
1.7.1 match↑
(match value clause ...) tries each clause in turn. A clause is a pattern followed by a body, and the first clause whose pattern fits is the one that runs. Its last expression is the value of the whole match.
The basic patterns are:
| Pattern | Matches |
|---|---|
42 "hi" #\a #t | exactly that literal |
:red 'sym | exactly that keyword or symbol |
_ | anything, and binds nothing |
name | anything, and binds it to name |
Hearts | the case Hearts (capitalised: a constructor) |
(Pip n) | the case Pip, binding its payload to n |
The difference between a binder and a constructor is the first letter. A lowercase name binds, a capitalised name is a case. This is also the source of one classic mistake: if you misspell a case, or forget to import it, it becomes a binder that matches everything. The compiler will usually tell you, because the clauses below it can then never run.
(: light (-> Keyword string))
(defun (light k)
(match k
(:red "stop")
(#:green "go") ;; :green and #:green are the same keyword
(_ "wait")))
(: rank-points (-> Rank int))
(defun (rank-points r)
(match r
((Pip n) n)
(Ace 11)
(_ 10)))
Patterns nest. A payload can itself be matched against a pattern:
(match (Some (Pip 10))
((Some (Pip 10)) "a ten")
((Some (Pip n)) #"the number n")
((Some court) "a court card or an ace")
(None "no card"))
1.7.2 Records and structs↑
A record or struct is matched by its type name followed by the fields you care about, each written (field pattern). Fields you leave out match anything, and the order does not matter. A field name on its own binds the field to a variable of the same name, which is by far the most common way to use it:
(: card-name (-> Card string))
(defun (card-name c)
(match c
((Card (rank Ace) (suit Spades)) "the ace of spades")
((Card (rank (Pip 10))) "a ten")
;; `suit` alone binds the suit field to the name suit
((Card (rank (Pip n)) suit) #:when (< n 5) #"a low card (nsuit)")
((Card (rank (or Jack Queen King))) "a court card")
((Card rank) #"something else: rank")))
(: player-line (-> Player string))
(defun (player-line p)
(match p
((Player name (tricks 0)) #"name has not won anything")
((Player name tricks) #"name has won tricks tricks")))
A bare type name, like Card, is a pattern that matches every card.
1.7.3 Lists, vecs, arrays and tuples↑
Collections have patterns too. ... after the last name binds the rest of the collection:
| Pattern | Matches |
|---|---|
() or (List) | the empty list |
(List a b) | a list of exactly two elements |
(List first rest ...) | a list of at least one element |
(Cons head tail) | a non-empty list, split in head and tail |
[] [a b] [a rest ...] | the same for a Vec |
#[] #[a rest ...] | the same for an Array |
(a . b) or (Tuple a b) | a pair |
(: describe (-> (List int) string))
(defun (describe xs)
(match xs
(() "empty")
((List x) #"one: x")
((List 0 rest ...) "starts with zero")
((List x y rest ...) #"x, y and (list-length rest) more")))
(describe (list 1 2 3 4)) ;; "1, 2 and 2 more"
(: header (-> (Vec string) string))
(defun (header v)
(match v
([] "no columns")
([only] only)
([first rest ...] #"first (+(vec-length rest))")))
(: pair (-> (Tuple int string) string))
(defun (pair p)
(match p
((0 . s) #"zero and s")
((_ . "x") "an x")
((n . s) #"n/s")))
The rest of a vec pattern shares its structure with the original vec, so it is cheap. The rest of an array pattern is a copy.
1.7.4 Guards, or and and↑
A clause may have a guard, written #:when expr after the pattern. The clause only matches if the pattern fits and the guard is true, and the guard can use everything the pattern bound:
(match hand-size
(0 "no cards")
(n #:when (> n 10) "too many cards")
(n #:when (even? n) "an even hand")
(_ "an odd hand"))
(or p ...) matches if any of the alternatives match. The alternatives may not bind names, since there would be no way to know which of them did. (and p ...) matches if all of them match, which is mostly useful to bind a name to the whole value while also taking it apart:
(: red? (-> Suit bool))
(defun (red? s)
(match s
((or Hearts Diamonds) #t)
(_ #f)))
(match (Some 3)
((and (Some n) whole) #"n inside whole") ;; "3 inside (Some 3)"
(None "none"))
1.7.5 Comparisons↑
Five patterns compare a number or a character with bounds:
| Pattern | Matches a value that is |
|---|---|
(:at-least lo) | lo or more |
(:at-most hi) | hi or less |
(:between lo hi) | from lo to hi |
(:greater-than lo) | more than lo |
(:less-than hi) | less than hi |
:between includes both of its ends: it is :at-least and :at-most together, as version-between is in a manifest. Use :greater-than and :less-than when an end must not be included, which a double needs, because it has no next value below or above a bound.
A comparison is a pattern like any other, so you can put it inside other patterns: in a constructor, in a field of a record, in a list, vec or array pattern, and in or and and:
(: grade (-> int string))
(defun (grade points)
(match points
((:less-than 0) "invalid")
((:between 0 59) "fail")
((:at-least 60) "pass")))
(: letter (-> Char string))
(defun (letter c)
(match c
((:between #\a #\z) "lower case")
((:between #\A #\Z) "upper case")
(_ "something else")))
(match card
((Card (rank (Pip (:at-least 7)))) "a high pip card")
(_ "something else"))
(match reading
((Some (and n (:greater-than 100))) #"too high: n")
((Some (or (:less-than 0) (:greater-than 50))) "odd")
((Some n) #"ok: n")
(None "no reading"))
The bounds must be number or character literals, because the comparison is compiled to a constant pattern. To compare with a value that is computed at run time, use a #:when guard.
The checker adds up the ranges of an integer or a character, so grade above needs no catch-all: its three ranges include every int. A clause whose range is already matched by the clauses above it is an error, as any clause that can never run is. The checker does not add up the ranges of a double or a decimal. A double can be NaN, which no comparison matches, so a match on one must end in a catch-all.
1.7.6 Views, type tests and Has↑
(:view f pattern) calls f on the value and matches the result against pattern. That lets a match look at something that is not directly in the value, like whether a string parses as a number:
(: parse-int (-> string (Option int)))
(defun (parse-int s)
(match (try (string->int s) #:catch (System.FormatException))
((Ok n) (Some n))
((Err _) None)))
(: classify (-> string string))
(defun (classify s)
(match s
((:view parse-int (Some n)) #:when (even? n) "an even number")
((:view parse-int (Some n)) "an odd number")
((:view string-length 0) "nothing")
(_ "text")))
(:is Type name) tests the .NET type of a value, and binds it with that type. It is mostly used to tell exceptions apart, and there is an example of that in the section on failure.
(Has key pattern) matches any collection you can look things up in (maps, vecs) that has key, and matches the value under it against pattern. Has* takes several key and pattern pairs:
(: port-of (-> (Map string int) int))
(defun (port-of settings)
(match settings
((Has "port" p) p)
(_ 8080)))
(: user-line (-> (Map string string) string))
(defun (user-line m)
(match m
((Has* ("name" n) ("city" c)) #"n from c")
((Has "name" n) n)
(_ "anonymous")))
1.7.7 Exhaustiveness↑
Every match is checked, and a match that does not cover every value is a compile error. The error names a value that falls through:
(: red? (-> Suit bool))
(defun (red? s)
(match s
(Hearts #t)
(Diamonds #t)
(Clubs #f)))
Pattern Error at cards.bjo:8: this match does not cover every value. Spades reaches no clause.
The counterexample is a real value, and for nested patterns it tells you exactly which combination is missing:
(match c
((Card (rank (Pip n))) n)
((Card (rank (or Jack Queen King))) 10)
((Card (rank Ace) (suit Spades)) 50))
Pattern Error at cards.bjo:8: this match does not cover every value. (Card (rank Ace) (suit Clubs)) reaches no clause.
A clause that can never run, because the clauses above it already match everything it would, is also an error:
Pattern Error at cards.bjo:5: this clause can never run — the clauses above it already match everything it does.
The checker does not run your code, so a clause with a #:when guard, a :view or an :is covers nothing as far as it is concerned. A match that only has guarded clauses needs a final catch-all:
(match n
(x #:when (< x 10) "small")
(_ "large")) ;; without this line:"_ reaches no clause"
Comparisons of integers and characters are different: the checker knows which values they match. The counterexample is then a value in a gap between the ranges:
(match n
((:less-than 0) "negative")
((:at-least 1) "positive"))
Pattern Error at example.bjo:2: this match does not cover every value. 0 reaches no clause.
1.7.8 case↑
case is a short form for the common match against literals. Each clause is a list of values, and else is the catch-all:
(: nth-name (-> int string))
(defun (nth-name n)
(case n
((1) "first")
((2) "second")
((3) "third")
(else #"nth")))
(case command
(("quit" "exit") (stop))
(("help") (show-help))
(else (run command)))
The data in a clause must be literals: numbers, strings, characters, booleans, keywords or quoted symbols. A union case like Hearts is not allowed, since case could not tell it from a binder. For those, use match with or.
1.7.9 Patterns in def↑
Any pattern can also be used in def. You have already seen that in the section on with-return, where a pattern that may fail gets a failure part. A pattern that cannot fail needs none, and is simply a way to take something apart without indenting the rest of the function:
(def (Card rank suit) c) ;; binds rank and suit
(def (who . points) (Tuple "ada" 3)) ;; binds who and points
A pattern that may fail needs one of the failure parts: :leave-with, :leave, :propagate or :default. They are all covered in the section on bailing out of failed bindings. Here is one of each of the two most common:
(: score-line (-> (Map string int) string string))
(defun (score-line scores who)
;; If the lookup fails, the function's value is the :leave-with value.
(def (Some n) (map-try-ref scores who) :leave-with #"who has not played")
#"who: n")
(: total (-> (Map string int) string string (Option int)))
(defun (total scores a b)
;; If either lookup fails, the None is passed on as the function's value.
(def* ((Some x) (map-try-ref scores a) :propagate)
((Some y) (map-try-ref scores b) :propagate))
(Some (+ x y)))
1.7.10 Example: who wins the trick↑
In many card games, everyone plays one card, and the highest card in the suit that was led (the suit of the first card) wins. Here a trick is a list of who played what, in order:
(: trick-winner (-> (List (Tuple string Card)) (Option string)))
(defun (trick-winner trick)
(match trick
(() None)
((Cons (_ . lead) _)
(def led-suit (record-ref lead suit))
(def best
(loop (:for (who . card) trick)
(:when (= (record-ref card suit) led-suit))
(:acc top (folding (Tuple "" lead)
(match top
((_ . top-card)
(if (at-least? card top-card)
(Tuple who card)
top)))))
=> top))
(def (winner . _) best)
(Some winner))))
(trick-winner (list (Tuple "ada" (Card (rank (Pip 9)) (suit Hearts)))
(Tuple "bo" (Card (rank Ace) (suit Spades)))
(Tuple "cy" (Card (rank Queen) (suit Hearts)))
(Tuple "dee" (Card (rank (Pip 10)) (suit Hearts)))))
;; (Some "cy"): bo's ace is higher, but it is not a heart.
The loop is explained in its own section. What matters here is the patterns: (Cons (_ . lead) _) picks out the first card of a non-empty list in one go, (:for (who . card) trick) takes each pair apart as it walks the list, and at-least? works on cards because Card derives Ord.
1.8 Options, results and failure↑
Bjolang has no null. A value that may be missing is an Option, and a computation that may fail returns a Result. Both are ordinary unions from the prelude:
;; (Option %a) is either None or (Some value)
;; (Result %e %a) is either (Err error) or (Ok value)
Exceptions exist too, since everything in .NET throws them, but they are something you turn into a Result at the edge of your program rather than something you use for ordinary control flow.
1.8.1 Option↑
(: halve (-> int (Option int)))
(defun (halve n)
(if (even? n) (Some (/ n 2)) None))
(match (halve 7)
((Some h) #"half is h")
(None "7 is odd"))
Matching is always possible, but the prelude has shorter ways to say the common things:
| Form | Does |
|---|---|
(some? o) (none? o) | test which case it is |
(option-value o fallback) | the value, or fallback for None |
(option-ref-or o fallback) | the same |
(option-ref o) | the value, and throws for None |
(if-let (name o) then else) | then with the value bound to name, or else |
(when-let (name o) body ...) | the body with the value bound, and nothing for None |
(option-value (halve 7) 0) ;; 0
(if-let (h (halve 12))
(println #"half is h") ;; prints "half is 6"
(println "odd"))
(when-let (h (halve 3))
(println "never printed"))
1.8.2 Result↑
A Result is an Option that says why it is empty. The error can be any type: a string for a quick script, your own union when the caller needs to act on what went wrong, or a .NET exception.
(: parse-int (-> string (Result string int)))
(defun (parse-int s)
(match (try (string->int s) #:catch (System.FormatException))
((Ok n) (Ok n))
((Err e) (Err #"'s' is not a number"))))
(: positive (-> int (Result string int)))
(defun (positive n)
(if (> n 0) (Ok n) (Err #"n is not positive")))
(ok? (parse-int "12")) ;; #t
(err? (parse-int "twelve")) ;; #t
(result-map #(* & 2) (parse-int "21")) ;; (Ok 42)
A Result must be used. In fact every value must be: a form in the middle of a body whose value is dropped is a compile error, unless it is void. For a Result that rule is what keeps a failure from disappearing silently. If you really do not care about a value, say so with (ignore expr):
Type Error at game.bjo:4: this value has type int and is discarded.
It is a form in the middle of a body, so its value is dropped.
Every value that is computed and then dropped has to say so: write `(ignore ...)` around it.
1.8.3 Chaining things that may fail↑
Most real code does several things in a row, each of which may fail, and stops at the first failure. Nesting matches for that drifts to the right very quickly. There are three ways to write it flat.
The first is def with :propagate. When the pattern does not match, the case that did not match is rebuilt and becomes the return value of the function. With an Option that is None, and with a Result it is the same Err:
(: parse-positive (-> string (Result string int)))
(defun (parse-positive s)
(def (Ok n) (parse-int s) :propagate)
(def (Ok p) (positive n) :propagate)
(Ok (* p 10)))
(parse-positive "12") ;; (Ok 120)
(parse-positive "-4") ;; (Err "-4 is not positive")
(parse-positive "x") ;; (Err "'x' is not a number")
The second is try->, a threading form that stops at the first Err. Each step gets what the previous one returned, unwrapped, in the & position. some-> is the same for Option:
(: parse-positive (-> string (Result string int)))
(defun (parse-positive s)
(try-> s (parse-int &) (positive &)))
(: quarter (-> int (Option int)))
(defun (quarter n)
(some-> n (halve &) (halve &)))
(quarter 12) ;; (Some 3)
(quarter 6) ;; None: 3 is odd
The third is with-return together with :leave-with, which was described earlier. Use it when the failures should turn into something that is not the same type, for example a default value or a log line.
1.8.4 Exceptions: try, :is and raise↑
.NET code reports failure by throwing. try turns exceptions of the types you list into an Err, and the value of a body that did not throw into an Ok. Anything you did not list keeps propagating as an exception:
(def divisor (string->int "0"))
(try (/ 10 divisor) #:catch (System.DivideByZeroException))
;; (Err <the exception>), of type (Result System.Exception int)
Put the try around the whole region that can fail rather than around each call, and you get a single Result out.
#:finally expr runs expr however the body ends. Without a #:catch nothing is caught, and the value is just the body’s value:
(try (render page)
#:finally (println "done rendering"))
The Err of a try holds a System.Exception. To tell different exceptions apart, use the :is pattern, which tests the .NET type and binds the value with the more specific type so that you can read its properties. raise throws an exception again, for the cases that are not yours to handle:
(: slurp (-> string string))
(defun (slurp path)
(match (try (file-read-text path)
#:catch (System.IO.FileNotFoundException
System.IO.DirectoryNotFoundException))
((Ok text) text)
((Err (:is System.IO.FileNotFoundException e)) #"no such file: (.-FileName e)")
((Err (:is System.IO.DirectoryNotFoundException)) "no such directory")
((Err e) (raise e))))
1.8.5 with-open↑
with-open binds something disposable, like a port, a stream or a database connection, runs the body, and disposes it however the body ends:
(: first-line (-> string (Result Exception string)))
(defun (first-line text)
(try (with-open ((p (open-input-string text)))
(read-line p))
#:catch (System.IO.IOException)))
(first-line "first\nsecond") ;; (Ok "first")
1.8.6 panic!↑
When something happens that should be impossible, (panic! "message") prints the message and exits the program. It can stand where any type is expected, which makes it useful as the last arm of a match that you know cannot be reached:
(match (map-try-ref deck-index card)
((Some i) i)
(None (panic! "a card that is not in the deck")))
(panic! "config is broken" #:exit-code 2)
1.9 Collections↑
Bjolang’s collections are immutable by default. Adding to a vec or setting a key in a map gives you a new collection and leaves the old one untouched. That sounds expensive, but the new collection shares almost all of its structure with the old one, so an update costs about as much as it would in a mutable collection, and nobody ever has to wonder who else is holding on to the old version.
| Type | Literal | What it is |
|---|---|---|
(List %a) | '(1 2 3) (list 1 2 3) | a linked list, cheap at the front |
(Vec %a) | [1 2 3] | an indexable vector (an RRB tree), cheap everywhere |
(Array %a) | #[1 2 3] | a mutable .NET array of fixed length |
(Map %k %v) | #map(("a" 1) ("b" 2)) | a hash map (a CHAMP trie) |
(HashSet %a) | a hash set, in (std hashset) | |
(OrderedMap %k %v) | a sorted map (a B-tree), in (std orderedmap) | |
(OrderedSet %a) | a sorted set, in (std orderedset) |
If you do not know which to use, use a Vec.
1.9.1 Lists↑
The list is the scheme list: a chain of Cons cells ending in Nil. It is cheap to add to and take from the front, and slow for everything else. Use it for things you build front to back and walk once.
(def xs (list 1 2 3))
(def ys (Cons 0 xs)) ;; '(0 1 2 3), and xs is still '(1 2 3)
(list-head ys) ;; 0
(list-tail ys) ;; '(1 2 3)
(list-length ys) ;; 4, but it walks the list to find out
(list-map #(* & 10) xs) ;; '(10 20 30)
(list-filter even? ys) ;; '(0 2)
(list-foldl + 0 xs) ;; 6
(list-reverse xs) ;; '(3 2 1)
(list-append xs xs) ;; '(1 2 3 1 2 3)
A quoted list is data: '(a b c) is a list of three symbols, not a call. Inside one, ,expr splices in the value of an expression:
(def n 5)
'(1 2 ,n) ;; '(1 2 5)
1.9.2 Vecs↑
Vec is the general-purpose collection. Indexing, updating at an index and adding at the end are all cheap, and it knows its own length.
(def v [10 20 30])
(def v2 (vec-add v 40)) ;; [10 20 30 40]
(def v3 (vec-set v2 0 99)) ;; [99 20 30 40]
v ;; still [10 20 30]
(vec-ref v3 0) ;; 99
(vec-length v3) ;; 4
(vec-filter #(> & 15) v3) ;; [99 20 30 40]
(vec-slice v3 1 2) ;; [20 30]: start at 1, take 2
(vec-map #(+ & 1) v) ;; [11 21 31]
(vec-fold + 0 v) ;; 60
(vec-merge v v) ;; [10 20 30 10 20 30]
(list->vec xs) and (vec->list v) convert between the two.
1.9.3 Arrays↑
An Array is a plain .NET array: mutable, fixed in length, and exactly what C# code expects. Use them for performance-sensitive code, or when talking to .NET.
(def a #[1 2 3])
(array-set! a 0 42) ;; changes a in place
(array-ref a 0) ;; 42
(array-length a) ;; 3
(def zeroes (make-array 10))
Every evaluation of an array literal makes a fresh array, so a function that returns #[0 0] returns a new one each time.
1.9.4 Maps↑
Map is an immutable hash map. The #map(...) literal takes entries written as (key value), [key value] or (key . value).
(def scores #map(("ada" 3) ("bo" 5)))
(def scores2 (map-set scores "cy" 1)) ;; scores still has two entries
(map-ref scores2 "cy") ;; 1, and throws if the key is missing
(map-try-ref scores "cy") ;; None
(map-ref-or scores "cy" 0) ;; 0
(map-contains? scores2 "bo") ;; #t
(map-length scores2) ;; 3
(map-remove scores2 "ada") ;; a map without ada
;; The callbacks of the map- functions get the key and the value as
;; two separate arguments.
(map-fold (fun (k v acc) (+ v acc)) 0 scores2) ;; 9
(map-map-values #(* & 100) scores) ;; ada 300 and bo 500
(map-filter (fun (k v) (> v 2)) scores2) ;; ada and bo, not cy
Any type with an Eq can be a key. That includes your own types, which is one reason to derive Eq.
When a map is walked as a collection, for example in a loop or with fold, each element is a (Tuple key value) pair:
(loop (:for (name . score) scores)
(:do (println #"name: score")))
1.9.5 Sets and ordered collections↑
Sets and the sorted collections are libraries, so they have to be imported. Their functions are named like the map functions, with their own prefix:
(import (std hashset))
(import (std orderedmap))
(def s (list->hashset (list 3 1 4 1 5 9 2 6 5)))
(hashset-length s) ;; 7
(hashset-contains? s 4) ;; #t
(hashset-contains? (hashset-add s 7) 7) ;; #t
(def prices (orderedmap-set
(orderedmap-set
(orderedmap-set (orderedmap-empty) "pear" 3)
"apple" 5)
"fig" 1))
(seq->list (orderedmap-keys prices)) ;; '("apple" "fig" "pear"): always sorted
(orderedmap-min prices) ;; (Some ("apple" . 5))
An ordered collection sorts its keys with their Ord, so a type that derives Ord can be a key in one. Strings are sorted by code point, not by the rules of any language, so the order is the same on every machine.
1.9.6 Building collections quickly↑
Updating an immutable collection one element at a time is cheap, but not free. When you build a collection from scratch in a loop, use a builder, which is a mutable collection you fill and then freeze. There are builders for lists, vecs, strings, maps and sets:
(def vb (vecbuilder-empty))
(vecbuilder-add! vb "a")
(vecbuilder-add! vb "b")
(vecbuilder->vec vb) ;; ["a" "b"]
(def lb (listbuilder-empty))
(listbuilder-add! lb 1)
(listbuilder-add! lb 2)
(listbuilder->list lb) ;; '(1 2): builds front to back
A transient is the same thing for changing an existing map or set many times: it takes the collection, lets you mutate it, and gives you a new immutable collection back.
(def t (map->transientmap scores))
(transientmap-set! t "eve" 7)
(transientmap-remove! t "ada")
(def new-scores (transientmap->map t)) ;; bo and eve; scores is untouched
In practice you rarely write either of these directly, since the accumulators of loop (next section) use builders for you.
1.9.7 Generic operations↑
Many operations are traits, so they work on every collection that implements them:
| Function | Works on | Does |
|---|---|---|
(length c) (empty? c) | lists, vecs, arrays, maps, strings | size, and whether it is empty |
(ref c key) | vecs, arrays, maps, strings | the element at an index or key; throws |
(try-ref c key) | the same | the element as an Option |
(ref-set c key x) | vecs, maps, ordered maps | a copy with x at the key |
(add c x) (add-all c xs) | vecs, maps, sets | a new collection with more in it |
(add! b x) (add-all! b xs) | builders and transients | put things in, in place |
(fold f init c) | anything you can loop over | a left fold |
(map f c) (filter f c) | the same | a lazy Seq, see below |
(any? f c) (all? f c) (find f c) | the same | search |
(length [1 2 3]) ;; 3
(ref [10 20 30] 1) ;; 20
(try-ref #map((:a 1)) :b) ;; None
(add #map(("a" 1)) ("b" . 2)) ;; for a map, the element is a pair
(add-all (hashset-empty) [1 1 2 2 3]) ;; a set of three
(fold + 0 [1 2 3 4]) ;; 10
(find #(> & 3) (list 1 5 7)) ;; (Some 5)
(def b (vecbuilder-empty))
(add-all! b (range 0 5))
(vecbuilder->vec b) ;; [0 1 2 3 4]
1.9.8 Mutable collections↑
For the rare cases where you really want a collection that is changed in place and shared, there are mutable versions under (std mutable ...): vec, map, set, deque and heap. The mutable vec is a .NET List<T>, and the mutable map is a .NET Dictionary.
(import (std mutable vec))
(def mv (mutablevec))
(mutablevec-add! mv 3)
(mutablevec-add! mv 1)
(mutablevec-add! mv 2)
(mutablevec-sort! mv)
(mutablevec-ref mv 0) ;; 1
Reach for these only when you have measured that you need them, or when a .NET API wants one. A mutable collection cannot be a map key, and sharing one between fibers is your own problem.
1.9.9 Equality↑
Equality is structural. Two collections are equal when they hold equal elements, however they were built:
(= [1 2 3] (vec-add [1 2] 3)) ;; #t
(= (list 1 2) (list 1 2)) ;; #t
(= #map(("a" 1)) (map-set (map-empty) "a" 1)) ;; #t
eq? asks whether two things are the very same object. You almost never want it.
1.9.10 Example: a deck and a score table↑
A deck is a (Vec Card). Building it is a nested loop over suits and ranks (loops are the next section):
(: all-suits (Vec Suit))
(def all-suits [Clubs Diamonds Hearts Spades])
(: all-ranks (Vec Rank))
(def all-ranks [(Pip 2) (Pip 3) (Pip 4) (Pip 5) (Pip 6) (Pip 7) (Pip 8)
(Pip 9) (Pip 10) Jack Queen King Ace])
(: full-deck (-> (Vec Card)))
(defun (full-deck)
(loop (:for s all-suits)
(:subloop)
(:for r all-ranks)
(:acc deck (vecing (Card (rank r) (suit s))))
=> deck))
(vec-length (full-deck)) ;; 52
The scores of a game are a map from player name to points. Since a map is immutable, a round of scoring is a function from the old table to a new one:
(: add-points (-> (Map string int) string int (Map string int)))
(defun (add-points table who points)
(map-set table who (+ points (map-ref-or table who 0))))
(def start #map(("ada" 0) ("bo" 0)))
(def after (add-points (add-points start "ada" 5) "cy" 2))
(map-ref after "ada") ;; 5
(map-ref after "cy") ;; 2: a missing player starts at 0
(map-length start) ;; still 2
1.10 Loops and sequences↑
There are three ways to repeat something in bjolang. A named let (or any tail-recursive function) is the scheme way, and was covered earlier. loop is the way you will use most of the time. seq and seql make lazy sequences, which produce their elements only when someone asks for them.
1.10.1 loop↑
A loop is a list of clauses that run in order, once per element. (:for x coll) walks anything that can be walked: lists, vecs, arrays, maps, strings, ranges and sequences. An accumulator collects a result, and => expr says what the loop returns:
(loop (:for x (list 1 2 3 4 5 6))
(:when (even? x))
(:acc out (listing (* x x)))
=> out)
;; '(4 16 36)
The clauses are:
| Clause | Does |
|---|---|
(:for pat coll) | binds pat to each element of coll in turn |
(:with pat start update) | a variable that starts at start and is updated every round |
(:while test) | keeps going while test holds, checked before every round |
(:until test) | keeps going until test holds, checked before every round |
(:let pat expr) | binds something for the rest of this round |
(:when test) | skips the rest of this round unless test holds |
(:when-let pat expr) | binds, and skips the round when the pattern does not match |
(:finish test [value]) | ends the loop right away when test holds |
(:finish-let pat expr) | binds, and ends the loop when the pattern does not match |
(:final test) | ends the loop after this round when test holds |
(:abandon test) | ends a nested loop, and goes on with the next round of the outer one |
(:do expr ...) | runs something for its effect |
(:acc name (collector x)) | collects x |
(:subloop) | between two :fors: nests them |
(:subloop clause ...) | a loop inside the round, whose accumulators the round can use |
=> expr | the value of the loop (last, optional) |
Since :for takes a pattern, you can take elements apart as you go, like the tuples of a map or the pairs enumerate makes:
(loop (:for (i . s) (enumerate ["a" "b"]))
(:acc out (listing #"i:s"))
=> out)
;; '("0:a" "1:b")
1.10.2 Accumulators↑
An accumulator names a collector and what to feed it:
| Collector | Collects |
|---|---|
(listing x) | a List, in order |
(vecing x) | a Vec |
(mapping (k . v)) | a Map, from pairs |
(hash-setting x) | a HashSet, from (std hashset) |
(stringing c) | a string, from characters |
(summing x) | the sum of x |
(counting x) | how many rounds reached it |
(folding init expr) | starts at init; each round, expr is the new value |
folding is the general one: inside expr the accumulator’s own name is the value so far.
(def xs [3 9 2 7])
(loop (:for x xs) (:acc best (folding 0 (max best x))) => best) ;; 9
(loop (:for c "hello") (:acc s (stringing (char-upcase c))) => s) ;; "HELLO"
(loop (:for k (list "a" "b"))
(:for v (list 1 2))
(:acc m (mapping (k . v)))
=> m) ;; a map from "a" to 1 and "b" to 2
An accumulator does not need a name. Without a =>, a loop returns its accumulators, and with several of them it returns a tuple of their values in order:
(loop (:for x xs) (:acc (summing x)) (:acc (counting x))) ;; (21 . 4)
1.10.3 Several collections: lockstep and nesting↑
Two :for clauses in a row walk their collections side by side, and the loop ends when the shortest one runs out. To get every combination instead, put a (:subloop) between them. Everything after it runs once for every element of the inner collection, for every element of the outer one:
;; side by side
(loop (:for k (list "a" "b" "c"))
(:for v (list 1 2 3))
(:acc out (listing (k . v)))
=> out) ;; '(("a" . 1) ("b" . 2) ("c" . 3))
;; nested
(loop (:for x (list 1 2))
(:subloop)
(:for y (list 10 20))
(:acc out (listing (+ x y)))
=> out) ;; '(11 21 12 22)
(:abandon test) in the inner loop ends it early, and the outer loop goes on with its next round. In the outer loop there is nothing to go on with, so there it is an error:
(loop (:for x (list 1 2 3))
(:subloop)
(:for y (list 1 2 3))
(:abandon (> y x))
(:acc out (listing (x . y)))
=> out) ;; '((1 . 1) (2 . 1) (2 . 2) (3 . 1) (3 . 2) (3 . 3))
A nested loop like this runs to the end of the round: the clauses after the inner :for belong to the inner loop. When the round should go on after the inner loop, with what it computed, write the inner loop as a :subloop form instead. It is a loop of its own inside the round, and its accumulators are there for the clauses after it, with their final values:
(loop (:for row (list (list 1 2) (list 3 4 5)))
(:subloop (:for x row)
(:acc sum (summing x))
(:acc n (counting x)))
(:acc out (listing (sum . n)))
=> out) ;; '((3 . 2) (12 . 3))
With => (name expr) at its end, only name is bound after the form, to expr:
(loop (:for row rows)
(:subloop (:for x row)
(:acc sum (summing x))
(:acc n (counting x))
=> (avg (if (= n 0) 0 (/ sum n))))
(:acc out (listing avg))
=> out)
The form’s accumulators start over every time it runs, and a form that runs zero times leaves them at their start values. Its own variables are never visible after it. Inside it, two clauses are about the form:
(:finish-subloop test)ends the form here, and the round goes on after it with the accumulators as they are.(:abandon-subloop test)throws the form and the rest of the round away, and the outer loop goes on with its next round.
:when, :abandon, :while and :until inside the form are about the form’s own rounds. :finish still ends the whole loop.
A variable inside a loop cannot reuse the name of an accumulator, of a :with, or of a variable of an outer loop: (:for x xs) (:subloop) (:for x x) is an error. Pick another name.
1.10.4 State that carries over: :with↑
(:with name start update) is a variable that starts at start and is set to update after every round. All the :with updates of a round happen at once, so each of them sees the old values of the others:
(loop (:with a 0 b)
(:with b 1 (+ a b))
(:for i (range 0 10))
(:acc out (listing a))
=> out)
;; '(0 1 1 2 3 5 8 13 21 34)
1.10.5 Stopping early↑
:finish leaves the loop before the rest of the round runs, and :final leaves it after. :when-let and :finish-let bind a pattern that may fail, and skip the round or leave the loop when it does. The names follow one rule: finish stops and goes on after the loop, abandon stops and goes up to the next round of the outer loop.
(loop (:for x (list 1 2 3 4 5)) (:finish (> x 3)) (:acc out (listing x)) => out)
;; '(1 2 3)
(loop (:for x (list 1 2 3 4 5)) (:final (> x 3)) (:acc out (listing x)) => out)
;; '(1 2 3 4)
;; Sum the strings that are numbers, and skip the rest.
(loop (:for s (list "1" "x" "3"))
(:when-let (Ok n) (try (string->int s) #:catch (System.FormatException)))
(:acc total (summing n))
=> total)
;; 4
;; Number the lines of a port until it is empty. up-from without a #:to
;; never stops on its own, so the :finish-let is what ends the loop.
(loop (:for line-number (up-from 1))
(:finish-let (Some line) (read-line/opt port))
(:do (println #"line-number: line")))
(:finish test value) leaves the loop with value as its result, and the => expression is not run. The value has to have the same type as the => expression:
;; The sum, or -1 as soon as a negative number shows up.
(loop (:for x (list 1 2 -3 4))
(:finish (< x 0) -1)
(:acc total (summing x))
=> total)
;; -1
:break and :break-let are the old names of :finish and :finish-let. They still work, with a warning.
1.10.6 Looping while something holds: :while and :until↑
(:while test) keeps the loop going while test holds, and (:until test) until it does. Neither binds anything, so a loop that has no collection to walk can start with one:
(loop (:until (port-eof? port))
(:let line (read-line port))
(:do (println line)))
The test is checked before every round, the first one too, so the loop may run zero times. Next to a :for, the loop ends at whichever stops first, and the test can use the :for’s variable:
(loop (:for x (list 1 2 3 4 5))
(:until (> x 3))
(:acc out (listing x))
=> out)
;; '(1 2 3)
Since the test runs before the round, it cannot see what the round computes. To stop on that, put a :finish after it: (:let line (read-line/opt port)) (:finish (none? line)).
A loop has to start with a :for, a :with, a :while or an :until, since the other clauses belong to the round that it opens.
1.10.7 Counting and walking part of a collection↑
(:for x coll) walks all of coll from the front. These walk something else:
| Iterator | Walks |
|---|---|
(range lo hi) | the ints from lo up to, but not including, hi |
(range-by lo hi step) | the same, in steps of step |
(up-from n #:to t #:by b) | counts up from n, stopping before t; without #:to it never stops |
(down-from n #:to t #:by b) | counts down from n, stopping at t |
(in-vec v #:from f #:to t) | part of a vec |
(in-reverse-vec v) | a vec, last element first |
(in-array a #:from f #:to t) | part of an array, and in-reverse-array backwards |
(over-list xs) | the tails of a list: '(1 2 3), '(2 3), '(3) |
(in-port p read) | what read returns from a port, until it returns None |
(loop (:for i (up-from 0 #:to 10 #:by 3)) (:acc out (listing i)) => out) ;; '(0 3 6 9)
(loop (:for i (down-from 5 #:to 0)) (:acc out (listing i)) => out) ;; '(5 4 3 2 1 0)
(loop (:for x (in-reverse-vec [1 2 3 4])) (:acc out (listing x)) => out) ;; '(4 3 2 1)
(loop (:for x (in-vec [1 2 3 4] #:from 1 #:to 3)) (:acc out (listing x)) => out) ;; '(2 3)
None of these allocate anything. The loop compiles to the same code you would have written by hand.
1.10.8 Named loops↑
A loop can be given a name, which is then bound to a function that continues with the next round. Calling it with keyword arguments overrides the value of an accumulator or a :with for the next round. It must be called in tail position:
(loop lp (:for x [1 2 3 4])
(:acc total (summing x))
(:do (if (= x 2)
(lp #:total 100) ;; reset the sum to 100 after the 2
(lp)))
=> total)
;; 107
1.10.9 Comprehensions↑
A comprehension is a short way to write a loop with one accumulator. It is written in braces, with the collector first, then the expression to collect, then the clauses:
{listing (* a a) (:for a (range 0 5))} ;; '(0 1 4 9 16)
{listing a :when (even? a) (:for a (range 0 10))} ;; '(0 2 4 6 8)
{summing x (:for x [1 2 3])} ;; 6
{vecing (k . v) (:for k (list "a" "b")) (:for v (list 1 2))}
;; [("a" . 1) ("b" . 2)]
A loose :when right after the expression filters what is collected. Every loop clause works inside a comprehension, except :acc and =>.
1.10.10 Lazy sequences: seq and yield↑
A (Seq %a) is a sequence that computes its elements when they are asked for. You write one with seq, whose body calls yield for each element:
(: countdown (-> int (Seq int)))
(defun (countdown n)
(seq
(let loop ((i n))
(when (> i 0)
(yield i)
(loop (- i 1))))
(yield 0)))
(seq->list (countdown 3)) ;; '(3 2 1 0)
;; yield-from yields everything in another sequence.
(seq->list (seq (yield 100) (yield-from (countdown 2)) (yield 200)))
;; '(100 2 1 0 200)
Since nothing runs until it is asked for, a sequence can be endless, as long as you only ever take part of it:
(: naturals (-> (Seq int)))
(defun (naturals)
(seq (let loop ((i 0)) (yield i) (loop (+ i 1)))))
(seq->list (seq-take (naturals) 5)) ;; '(0 1 2 3 4)
(seq->list (seq-take (seq-filter even? (seq-map #(* & 3) (naturals))) 4))
;; '(0 6 12 18)
A sequence is re-run from the start every time it is walked. If computing the elements is expensive, turn it into a vec or list once with seq->vec or seq->list.
seql is loop made lazy. It takes the same clauses, with (:yield expr) instead of an accumulator:
(: fibs (-> (Seq int)))
(defun (fibs)
(seql (:with a 0 b)
(:with b 1 (+ a b))
(:yield a)))
(seq->list (seq-take (fibs) 10)) ;; '(0 1 1 2 3 5 8 13 21 34)
The same thing as a comprehension is {seqing expr clause ...}.
1.10.11 map, filter and friends↑
The generic map, filter, take, drop, take-while, drop-while, enumerate and flat-map work on anything you can loop over, and all of them return a lazy Seq. Chains of them are fused by the compiler into a single pass. To get a concrete collection at the end, convert it:
(seq->list (map #(* & 2) (list 1 2 3))) ;; '(2 4 6)
(seq->vec (filter even? [1 2 3 4])) ;; [2 4]
(seq->list (take 3 (naturals))) ;; '(0 1 2)
When you want a list or a vec out directly, the collection-specific functions (list-map, vec-filter and so on) are eager and return the same kind of collection they were given.
1.10.12 Example: dealing and counting↑
Dealing a deck to four players means giving player 0 the cards 0, 4, 8 and so on. That is a loop over players with a loop over the hand inside it:
(: deal (-> (Vec Card) int int (Vec (Vec Card))))
(defun (deal deck players per-hand)
(loop (:for p (range 0 players))
(:acc (vecing
(loop (:for i (range 0 per-hand))
(:acc (vecing (vec-ref deck (+ p (* i players))))))))
=> hands))
(: points (-> Card int))
(defun (points c)
(match c
((Card (rank (Pip n))) n)
((Card (rank Ace)) 11)
(_ 10)))
(: hand-points (-> (Vec Card) int))
(defun (hand-points hand)
{summing (points c) (:for c hand)})
(def hands (deal (full-deck) 4 5))
(vec-map hand-points hands) ;; the points of each hand
1.11 Strings and text↑
1.11.1 Strings and characters↑
A string is stored as UTF-8, and is always valid UTF-8. A char is not a byte, though: it is a whole Unicode code point. An emoji is one char, even though it takes four bytes in the string. That leads to two different lengths:
(def s "Björn 😀 åt")
(string-length s) ;; 15: storage units, which are bytes, O(1). Use it for sizing buffers.
(string-count s) ;; 10: characters, O(n). This is "how long is the text".
For the same reason there is no string-ref that takes an index. Finding the n:th character of a UTF-8 string means walking it, so walking is what you do: with a loop, a fold, or a cursor.
(loop (:for c s) (:acc n (counting c)) => n) ;; 10: loops go by character
(string->list "abc") ;; '(#\a #\b #\c)
(string-reverse "abc😀") ;; "😀cba", and the emoji survives
Characters are written #\a, #\space, #\newline. They can be compared with < and friends or char=?, converted with char->int and int->char, and classified with char-alphabetic?, char-numeric?, char-whitespace?, char-upper-case? and char-lower-case?.
1.11.2 Common operations↑
(string-append "ab" "cd") ;; "abcd"
(str "a" "b" "c") ;; "abc": any number of strings
(string-upcase "björn") ;; "BJÖRN"
(string-trim " hi ") ;; "hi"
(string-trim-left " hi ") ;; "hi "
(string-trim-right " hi ") ;; " hi"
(string-pad-left "7" 3 #:with #\0) ;; "007"
(string-split "a,b,,c" ",") ;; ["a" "b" "" "c"]: a Vec
(string-join ["a" "b" "c"] ", ") ;; "a, b, c"
(string-replace "banana" "an" "AN") ;; "bANANa"
(string-contains? "banana" "nan") ;; #t
(string-starts-with? "banana" "ban") ;; #t
(string-empty? "") ;; #t
All of these compare strings ordinally, byte by byte, which for UTF-8 is the order of the code points, and never according to the language settings of the machine. Upcasing and downcasing are the same everywhere too. To compare without caring about case, downcase both sides first.
1.11.3 Cursors↑
A cursor is a position in a string. It is as cheap as an int, but it always sits on a character boundary, and it can only be moved one character at a time, which is what makes it safe. Every cursor function takes the string as well as the cursor. Positions are always cursors: nothing takes a byte index, and the searches answer cursors too.
(: capitalise (-> string string))
(defun (capitalise s)
(if (string-empty? s)
s
(let* ((start (string-cursor-start s))
(rest (string-cursor-next s start)))
(string-append (char->string (char-upcase (string-cursor-ref s start)))
(substring s rest (string-cursor-end s))))))
(capitalise "ölstuga") ;; "Ölstuga"
;; string-index finds the first character matching a predicate, as a cursor.
(: first-word (-> string string))
(defun (first-word s)
(match (string-index char-whitespace? s)
((Some c) (substring s (string-cursor-start s) c))
(None s)))
(first-word "hello there world") ;; "hello"
;; string-search finds where a string starts; string-search-backward, the last one.
(: extension (-> string string))
(defun (extension path)
(match (string-search-backward path ".")
((Some dot) (substring path (string-cursor-next path dot) (string-cursor-end path)))
(None "")))
(extension "notes.backup.txt") ;; "txt"
The folds walk a string for you, and are usually simpler than a cursor:
(: count-vowels (-> string int))
(defun (count-vowels s)
(string-fold (fun (c n)
(if (string-contains? "aeiouy" (char->string (char-downcase c)))
(+ n 1)
n))
0 s))
(count-vowels "Hello World") ;; 3
1.11.4 Slices↑
substring, string-trim and string-split copy the text they answer. A slice is the same text without the copy: a string that shares the bytes of the string it was taken from. Only the forms ending in /slice make one:
(substring/slice s start end)
(string-trim/slice s)
(string-trim-left/slice s)
(string-trim-right/slice s)
(string-split/slice s sep)
A slice is a string like any other. It equals, hashes and orders by its text, so it finds a map key with that text, and it can be passed anywhere a string can. That makes slices the thing to reach for when a scanner cuts many short pieces out of one text and only looks at them:
;; "name = Björn; age = 42" into a map, without copying a key or a value
;; before it is needed.
(: settings (-> string (Map string string)))
(defun (settings line)
(loop (:for field (string-split/slice line ";"))
(:let parts (string-split/slice field "="))
(:when (= (vec-length parts) 2))
(:acc m (mapping ((string-copy (string-trim/slice (vec-ref parts 0)))
. (string-copy (string-trim/slice (vec-ref parts 1))))))
=> m))
(map-ref (settings "name = Björn; age = 42") "age") ;; "42"
Two things are different about a slice:
- Its cursors are its source’s. A cursor found in a slice, by
string-search,string-indexor a regex, is a position in the string the slice was taken from, and(string-cursor-start sl)is where the slice begins in it. - It keeps all of its source alive. A slice of ten bytes out of a file of a megabyte holds on to the megabyte. That is why the plain forms copy, and why the example copies what it keeps:
(string-copy s)gives a string bytes of its own, and issitself when it already has them.
1.11.5 Building strings↑
Appending strings in a loop copies the whole string every time. Use a string builder instead, or the stringing collector, which uses one for you:
(: join-words (-> (List string) string))
(defun (join-words words)
(def sb (stringbuilder-empty))
(loop (:for w words)
(:do (when (> (stringbuilder-length sb) 0)
(stringbuilder-add! sb #\space))
(stringbuilder-add-string! sb w)))
(stringbuilder->string sb))
Do not build strings by writing to a string port. Writing to a port is I/O as far as the compiler is concerned, and it will make every function that calls yours potentially asynchronous.
1.11.6 Converting to and from strings↑
Two functions turn any value into a string:
writtengives the value as the reader reads it, as Scheme’swritedoes. A string is in quotes, a char is#\a, a number keeps the suffix of its type (21L), and a double always has a point (3.0). The REPL shows a result this way.displayedgives the value for a person to read, as Scheme’sdisplaydoes. A string is its text, and a number has no suffix.println,printand interpolation use it.
(written "hej") ;; "\"hej\""
(displayed "hej") ;; "hej"
(written 21L) ;; "21L"
(displayed 21L) ;; "21"
(written (list "a" "b")) ;; "(\"a\" \"b\")"
(displayed (list "a" "b")) ;; "(a b)"
(written (Some (Tuple 1 "x"))) ;; "(Some (Tuple 1 \"x\"))"
(println (Fruit (name "banana") (amount 5) (ripeness Rotten)))
;; prints (Fruit (name "banana") (amount 5) (ripeness Rotten))
Every type you declare is written as you would build it. A type that prints differently for a person implements Displayed, as the card game did for Card; one that is written differently implements Written. The traits section has more. ->str is deprecated: use displayed.
For the basic types there are also named conversions:
(int->string 42) ;; "42"
(double->string 2.5) ;; "2.5"
(displayed 3.0) ;; "3"
(written 3.0) ;; "3.0"
(string->int "42") ;; 42, and throws on anything that is not a number
(string->double "1.5") ;; 1.5
(char->string #\a) ;; "a"
(string->symbol "hi") ;; 'hi
(keyword->string :hi) ;; "hi"
These all use the invariant culture: a number written on one machine reads back on any other, and 1.5 is never written as 1,5.
1.11.7 Formatting with (std fmt)↑
For output where the layout matters, (std fmt) has two layers. The simple one prints a vec of mixed values:
(import (std fmt))
(println* ["Hello, " 42 ", ok? " #t]) ;; Hello, 42, ok? #t
The other one is a small layout language, made of blocks. A string, an int or a character is a block, so is a vec of blocks, and so is what each of the combinators gives back. show prints a block:
(show ["[" (padded/left 6 "foo") "]" nl]) ;; [ foo]
(show ["[" (padded/right 6 "foo") "]" nl]) ;; [foo ]
(show [(numeric 1234567.891 #:comma 3) nl]) ;; 1,234,567.891
(show [(numeric 3.14159 #:precision 2) nl]) ;; 3.14
(render->string (trimmed/right 5 "abcdefgh")) ;; "abcde"
(show [(joined/last BStr (fun (s) ["and " s])
(list "a" "b" "c") ", ")
nl]) ;; a, b, and c
;; tabular takes columns, and sizes each to its content.
(show [(tabular [["name" nl "ada" nl "bo"]
["score" nl 12 nl 7]]
#:sep " ")])
;; name score
;; ada 12
;; bo 7
The section on formatting text in “Writing programs” is a guide to the rest: paragraphs, tables, settings, and what to write when a layout does not compile. Every combinator is in the reference for (std fmt).
1.11.8 Regular expressions with (std rx)↑
Regular expressions are written as s-expressions with the #rx(...) hash macro, not as strings. There is no escaping, and a broken pattern is a compile error rather than a runtime one:
(import (std rx))
(def date-rx #rx(seq :bos
(=> :year (= 4 :ascii-digit))
"-" (=> :month (= 2 :ascii-digit))
"-" (=> :day (= 2 :ascii-digit))
:eos))
(def number-rx #rx((+ :ascii-digit)))
(rx-match? number-rx "123") ;; #t: the whole string
(rx-search? number-rx "abc 123") ;; #t: anywhere
(rx-split #rx((+ :space)) "a b c") ;; ["a" "b" "c"]
(rx-replace number-rx "a1b22c" "#") ;; "a#b#c"
(vec-map rx-text (rx-matches number-rx "a1b22c333")) ;; ["1" "22" "333"]
The most useful building blocks are (seq ...), (or ...), (* r), (+ r), (? r), (= n r), (** min max r), (=> :name r) to capture, :bos and :eos for the ends of the string, and character classes like :digit, :alpha, :space and (in "abc"). Note that :digit is every digit in Unicode; use :ascii-digit for 0 to 9.
The Rx and RxIn patterns use a regex inside a match. Rx matches the whole string and RxIn searches in it:
(: year-of (-> string string))
(defun (year-of s)
(match s
((Rx date-rx (Some m)) (option-value (rx-group m :year) "?"))
((RxIn number-rx (Some m)) #"some number: (rx-text m)")
(_ "no date")))
(year-of "2026-09-30") ;; "2026"
(year-of "room 101") ;; "some number: 101"
Define patterns at the top level, as above, rather than writing #rx(...) inside a clause. Otherwise the regex is recompiled at every call, which will tank performance.
1.12 Traits↑
A trait is a set of functions that a type can implement. It is how bjolang does what other languages do with interfaces, type classes or overloading: = is a trait method, so is displayed, and so are length, fold and compare.
1.12.1 Declaring and implementing a trait↑
def/trait declares a trait. It names a type variable, the implementor, and gives the signatures of its methods. impl implements it for one type:
(def/trait (Describe %a)
(: describe (-> %a string)))
(impl (Describe Suit)
(defun (describe s)
(match s
(Clubs "clubs") (Diamonds "diamonds") (Hearts "hearts") (Spades "spades"))))
(impl (Describe Rank)
(defun (describe r)
(match r
((Pip n) (int->string n))
(Jack "jack") (Queen "queen") (King "king") (Ace "ace"))))
(impl (Describe Card)
(defun (describe c)
;; These two calls go to the Rank and the Suit implementations.
#"the (describe (record-ref c rank)) of (describe (record-ref c suit))"))
(describe (Card (rank Queen) (suit Hearts))) ;; "the queen of hearts"
A method is called like any other function. Which implementation runs is decided by the type of the argument, at compile time when the type is known there.
1.12.2 Default methods↑
A trait may give a method a body. An implementation then gets that method for free, but may still write its own:
(def/trait (Describe %a)
(: describe (-> %a string))
(: shout (-> %a string))
(defun (shout x) (string-upcase (describe x))))
(impl (Describe Suit) ;; shout comes from the trait
(defun (describe s)
(match s
(Clubs "clubs") (Diamonds "diamonds") (Hearts "hearts") (Spades "spades"))))
(impl (Describe Card) ;; this one writes its own shout
(defun (describe c)
#"the (describe (record-ref c rank)) of (describe (record-ref c suit))")
(defun (shout c) "A CARD!"))
(shout Spades) ;; "SPADES"
(shout (Card (rank Queen) (suit Hearts))) ;; "A CARD!"
1.12.3 Constraints↑
A generic function that calls a trait method has to say that its type variable implements the trait. That is written as a where clause at the end of the signature:
(: describe-all (-> (Vec %a) (Vec string)) (where (Describe %a)))
(defun (describe-all xs) (vec-map describe xs))
(describe-all [Ace (Pip 3)]) ;; ["ace" "3"]
(: highest (-> (List %a) (Option %a)) (where (Ord %a)))
(defun (highest xs) (list-max xs))
describe-all can now be called with a vec of anything that has a Describe, and calling it with anything else is a compile error at the call.
For Eq, Written and Displayed you may leave the where out: the compiler adds them by itself when a function uses =, written or displayed on a type variable.
1.12.4 Associated types↑
A trait has exactly one implementor type. When a method needs another type that depends on the implementor, such as the element type of a collection, the trait declares an associated type with (type %name), and each implementation says what it is:
(def/trait (Container %c)
(type %item)
(: first-item (-> %c (Option %item))))
(impl (Container (List %a))
(type %item %a)
(defun (first-item xs)
(match xs
(() None)
((Cons x _) (Some x)))))
(impl (Container string)
(type %item char)
(defun (first-item s)
(if (string-empty? s)
None
(Some (string-cursor-ref s (string-cursor-start s))))))
(first-item (list 1 2)) ;; (Some 1)
(first-item "xyz") ;; (Some #\x)
This is how the prelude’s own Iterable, Collection and Refable work, and why length and fold work on so many types.
1.12.5 Implementations for particular type arguments↑
An implementation may be written for a generic type at particular type arguments, and one type may have several, one for each:
(impl (Describe (List int))
(defun (describe xs) "some numbers"))
(impl (Describe (List string))
(defun (describe xs) "some words"))
(describe (list 1 2)) ;; "some numbers"
(describe (list "a" "b")) ;; "some words"
The compiler picks the one whose arguments match. Two rules keep that choice unambiguous:
- Two implementations may not overlap. With
(List int)already there,(impl (Describe (List %a)) ...)is refused, since a(List int)would then have two. - The type has to be known where the call is.
(describe (list))is an error, because an empty list could be either, and so is a call at(List %a)in a generic function: neither implementation is written for every element type. Give such a value an annotation, or write one implementation for(List %a)with awhere, as in the next section.
Where only one implementation fits, it decides the type instead. With (impl (Describe (List int)) ...) and no other implementation for a list, (describe (list)) is a call at (List int), and a function with (where (Describe %a)) called with (list) is too.
1.12.6 Conditional and blanket implementations↑
An implementation for a generic type can require something of its type variables, with a where after the head. A list can be described if its elements can:
(impl (Describe (List %a))
(where (Describe %a))
(defun (describe xs)
(string-join (list->vec (list-map describe xs)) ", ")))
(describe (list Clubs Hearts)) ;; "clubs, hearts"
A blanket implementation is one for a bare type variable. It applies to every type that has no implementation of its own, and a more specific implementation always wins over it. A type whose head has implementations only for other arguments, such as a (List bool) beside the (List int) above, is an error rather than the blanket. Only the module that declares a trait may write its blanket, since a blanket is for every type and so for types the module did not declare:
(def/trait (Weight %a)
(: weight (-> %a int)))
(impl (Weight %a) ;; everything weighs 1 ...
(defun (weight x) 1))
(impl (Weight string) ;; ... except strings
(defun (weight s) (string-count s)))
(weight 42) ;; 1
(weight "hello") ;; 5
1.12.7 Where an implementation is written↑
An implementation is written in the module that declares its trait, or in the one that declares its type. Every module that can name both the trait and the type then sees the implementation, so a call at a type means the same in every module. A module may implement its own traits for any type, and any trait for its own types:
(impl (Describe (List int)) ...) ;; fine: Describe is declared here
(impl (Displayed Card) ...) ;; fine: Card is declared here
(impl (Displayed (Tuple int string)) ;; refused: neither is declared here
...)
A tuple, a type of the runtime such as List, and a .NET type are declared by no module, so only the module of a trait may implement it for one of them. To give such a type a trait from somewhere else, wrap it in a newtype of your own, as described below, and implement the trait for that. #:via gives the newtype the implementations the wrapped type already has.
Traits, types and implementations may be used above where they are written. A function may name a type that is declared further down, and call a method of a trait that is declared below it, and an implementation may stand above its trait.
1.12.8 Traits that require other traits↑
A trait may say that every type implementing it implements another trait too, with a where in the trait itself. Something that greets must have a name:
(def/trait (Named %a)
(: name-of (-> %a string)))
(def/trait (Greet %a)
(where (Named %a))
(: greeting (-> %a string)))
(: introduce (-> %a string) (where (Greet %a)))
(defun (introduce x) (str (name-of x) " says " (greeting x)))
introduce asks only for Greet, and may still call name-of: a type that can greet is known to have a name. An implementation of Greet for a type with no Named is a compile error at the implementation.
The prelude’s Ord requires Eq this way, so a function with (where (Ord %a)) can use = as well as compare. Displayed requires Written in the same way.
1.12.9 Giving a newtype the traits of the type it wraps↑
A newtype is the type it wraps when the program runs, so it can borrow that type’s implementation of a trait as it is, with #:via:
(type (: Meters (Newtype double)))
(impl (Eq Meters) #:via double)
(impl (Ord Meters) #:via double)
(impl (Num Meters) #:via double) ;; + - * /
(impl (Ordered Meters) #:via double) ;; < > <= >=
(+ (Meters 1.5) (Meters 2.0)) ;; (Meters 3.5)
(+ (Meters 1.5) 2) ;; a number literal is a Meters too
type/derive writes these for you, for any trait, when the type is a newtype:
(type/derive (Eq Ord Num Ordered)
(: Meters (Newtype double)))
A trait that should behave differently is written by hand instead. That is the other use of a newtype: the same data with a different behaviour, such as an Eq that ignores case on a (Newtype string). Sets and maps of the newtype use that implementation, not the wrapped type’s.
1.12.10 Eq, Ord, Written and Displayed↑
Four traits from the prelude are worth knowing by heart, since the rest of the library leans on them.
| Trait | Methods | Gives you |
|---|---|---|
Eq | =, eq-hash | equality, and use as a key in maps and sets |
Ord | compare | less?, least, list-sort, list-max, ordered collections |
Written | write-into, written | the REPL’s output, the failure text of expect |
Displayed | display-into, displayed | println, print, string interpolation |
Eq and Ord can be derived with type/derive, as shown earlier. When you write them by hand, they must be written in the module that declares the type, because they are compiled into the type itself: they become its .NET Equals, GetHashCode and CompareTo, which is why a .NET dictionary or a sorted collection agrees with = and compare. = and eq-hash must agree: two values that are equal must have the same hash. compare returns a negative number, zero or a positive number.
(impl (Eq Card)
(defun (= a b)
(and (= (record-ref a rank) (record-ref b rank))
(= (record-ref a suit) (record-ref b suit))))
(defun (eq-hash c)
(hash-combine (eq-hash (record-ref c rank)) (eq-hash (record-ref c suit)))))
The compiler writes a Written for every type you declare, so a value prints as you would build it: (Card (rank Queen) (suit Hearts)), (Pip 7), Hearts. A generic type is written when its type arguments are. An #:opaque type is written as #<Name>, and a function as #<procedure>, wherever it is: (println (Some (fun (x) (+ x 1)))) prints (Some #<procedure>), and so does a field that holds one, or a function at the REPL’s prompt. Every function type has the head Procedure, so a trait of your own can have an implementation for functions, (impl (Describe Procedure) ...), which applies to all of them. A blanket does not reach them, which is why (= f g) on two functions is still refused. Displayed has a blanket implementation that displays a value as it is written.
To print a type your own way, implement Displayed for the text a person reads, as the card game did, or Written to change the written form too. The method to give is display-into (or write-into), which adds the text to a string builder; displayed and written then come with the trait:
(type (: Money (Union (: Kr int))))
(impl (Displayed Money)
(defun (display-into m b)
(match m ((Kr n) (stringbuilder-add-string! b #"n kr")))))
(println (Kr 42)) ;; 42 kr
(written (Kr 42)) ;; "(Kr 42)"
A container that holds other values calls write-into or display-into on them with the same builder. elements-print-into and entries-print-into from the prelude lay out the elements of a collection, or the entries of a map, for you.
->str is deprecated. It still works, through displayed, but each use gives a warning.
1.12.11 Values of different types in one collection: dyn↑
A list holds values of one type. When you need a collection of different types that all implement the same trait, pack each value as a (dyn Trait):
(: render-all (-> (List (dyn Displayed)) (List string)))
(defun (render-all xs) (list-map displayed xs))
(render-all (list (dyn Displayed 1)
(dyn Displayed "two")
(dyn Displayed (Card (rank Queen) (suit Hearts)))))
;; '("1" "two" "Q♥")
A (dyn T) implements the traits that T requires as well, and the ones those require, through the value it holds. With Greet and Named from above, a (dyn Greet) can be given to introduce, which calls name-of, and to any function that asks for (where (Named %a)). So a trait cannot be packed if a trait it requires cannot be packed, or has associated types.
Packing is always explicit, and a dyn cannot be turned back into the value it came from. It is the exception rather than the rule: most code is better served by a union.
1.12.12 Traits that are .NET interfaces↑
Some traits are not implemented in bjolang at all, but stand for a .NET interface, which the type either implements or does not. The numeric traits Num, Integral and Ordered are like that, and are described in the next section. Such a trait is declared with #:clr-constraint, and each method names the interface member it stands for:
(def/trait (Float %a)
(#:clr-constraint (System.Numerics.IFloatingPointIeee754 %a))
(: nan? (-> %a bool) #:clr-member IsNaN)
(: finite? (-> %a bool) #:clr-member IsFinite))
(nan? (/ 0.0 0.0)) ;; #t
You cannot write an impl of such a trait, and no type declared in bjolang can satisfy one.
1.12.13 Example: cards that sort↑
Deriving Ord sorts cards by rank, but by the order the cases are declared in. If you want a different order, for example suits in bridge order, spades highest, and aces low, you write compare yourself. Here the cards are compared by rank first, with ace as 1, and by suit when the ranks are equal:
(type (: Card (Struct (: rank Rank) (: suit Suit)))) ;; no type/derive this time
(: rank-value (-> Rank int))
(defun (rank-value r)
(match r ((Pip n) n) (Ace 1) (Jack 11) (Queen 12) (King 13)))
(: suit-value (-> Suit int))
(defun (suit-value s)
(match s (Clubs 0) (Diamonds 1) (Hearts 2) (Spades 3)))
(impl (Eq Card)
(defun (= a b)
(and (= (rank-value (record-ref a rank)) (rank-value (record-ref b rank)))
(= (suit-value (record-ref a suit)) (suit-value (record-ref b suit)))))
(defun (eq-hash c)
(hash-combine (rank-value (record-ref c rank)) (suit-value (record-ref c suit)))))
(impl (Ord Card)
(defun (compare a b)
(def by-rank (compare (rank-value (record-ref a rank))
(rank-value (record-ref b rank))))
(if (= by-rank 0)
(compare (suit-value (record-ref a suit)) (suit-value (record-ref b suit)))
by-rank)))
(list-sort (list (Card (rank Ace) (suit Clubs))
(Card (rank (Pip 7)) (suit Spades))
(Card (rank (Pip 7)) (suit Hearts))
(Card (rank King) (suit Diamonds))))
;; A♣ 7♥ 7♠ K♦
Nothing about list-sort knows about cards. It asks for (Ord %a), and now Card has one, so sorting, least, list-max, less? and ordered maps keyed by cards all work.
1.13 Numbers↑
1.13.1 The types↑
The numeric types are the .NET ones:
| Type | Range | Type | Range |
|---|---|---|---|
byte | 0 … 255 | uint | 0 … 2^32−1 |
short | −32 768 … 32 767 | long | −2^63 … 2^63−1 |
ushort | 0 … 65 535 | ulong | 0 … 2^64−1 |
int | about ±2.1 billion | double | 64-bit floating point |
bigint | any integer | float | 32-bit floating point |
int128 | −2^127 … 2^127−1 | decimal | 28 digits, exact decimals |
uint128 | 0 … 2^128−1 |
A char is not a number: it can be compared and converted, but not added.
1.13.2 Writing numbers↑
A number with a suffix is of that type. A decimal point or an exponent makes it a real number, which is a double, a float or a decimal, and never an integer:
| Written | Type |
|---|---|
21uy | byte |
21s | short |
21us | ushort |
21u | uint |
21L | long |
21UL | ulong |
21d 2.5d | double |
21M 2.5M | decimal |
21I | bigint |
2.5 1e3 | a real type, as the context says |
0x2A 0b101010 | 42, of whatever type the context says |
A number without a suffix has no type of its own. It takes the type of wherever it is used, and is an int, or a double if it is real, only when nothing says otherwise:
(: doubled (-> ushort ushort))
(defun (doubled x) (* x 2)) ;; the 2 is a ushort
(doubled 21) ;; 42, and 21 is a ushort too
(let ((start 5))
(+ start 3L)) ;; 8L: start was a long all along
(: tenth decimal)
(def tenth 0.1) ;; a decimal, so (+ tenth 0.2) is exactly 0.3
A literal that does not fit the type it ends up with is a compile error. (doubled 99999) says that 99999 does not fit in a ushort, and (doubled 2.5) that 2.5 has a fraction.
1.13.3 Arithmetic↑
+ - * / % are the C# operators and do what they do in C#. With more than two arguments they fold, so (+ 1 2 3) is 6. (- x) negates.
(/ 7 2) ;; 3: integer division truncates
(/ 7.0 2.0) ;; 3.5
(% -7 2) ;; -1: the remainder takes the sign of the dividend
(% 7 -2) ;; 1
Integer arithmetic wraps around on overflow, silently, as it does in C#:
(: bump (-> int int))
(defun (bump n) (+ n 1))
(bump 2147483647) ;; -2147483648
Integer division by zero throws System.DivideByZeroException. Dividing a double by zero gives infinity, and (/ 0.0 0.0) is NaN. The bit operations are bitwise-and, bitwise-or, bitwise-xor, shift-left, shift-right and shift-right-logical.
1.13.4 Converting↑
There is no automatic conversion between numeric types. Multiplying an int variable by 1.5 is a type error:
(: scale (-> int double))
(defun (scale n) (* n 1.5))
;; Type error: these types do not match.
;; int
;; double
cast converts, with the target written as a .NET type. It behaves like a C# cast: converting a double to an integer rounds toward zero, and narrowing an integer wraps.
(: scale (-> int double))
(defun (scale n) (* (cast System.Double n) 1.5))
(: average (-> (Vec int) double))
(defun (average xs)
(/ (cast System.Double (fold + 0 xs))
(cast System.Double (vec-length xs))))
(cast System.Int32 2.7) ;; 2
(cast System.Int32 -2.7) ;; -2
(cast System.Int64 5) ;; 5L
Converting to and from strings was covered in the section on text. Remember that string->int throws on bad input, so input that is not yours should go through try:
(: parse (-> string (Result System.Exception int)))
(defun (parse s) (try (string->int s) #:catch (System.FormatException)))
1.13.5 Comparing↑
There are two ways to compare, and they are for different things:
<,>,<=,>=are the C# operators. They work on numbers and characters, and nothing else.compare, and everything built on it (less?,greater?,at-most?,at-least?,least,greatest,list-sort,list-min,list-max), works on every type with anOrd: numbers, strings, booleans and your own types.
(compare 2.5 1.5) ;; 1
(least "pear" "apple") ;; "apple"
(list-sort (list 5 3 9)) ;; '(3 5 9)
(min 3 7) ;; 3, but only for numbers
(least 3 7) ;; 3, for anything with an Ord
1.13.6 Generic numeric code↑
A function that does arithmetic on a type variable needs a constraint that says the type is a number:
| Constraint | Allows | Holds for |
|---|---|---|
Num | + - * / %, numeric literals | every numeric type |
Ordered | < > <= >= | numbers and char |
Integral | the bit operations | the integer types |
(: sum-all (-> (Vec %a) %a) (where (Num %a)))
(defun (sum-all xs) (fold + 0 xs))
(sum-all [1 2 3]) ;; 6
(sum-all [1.5 2.5]) ;; 4.0
(: low-bit (-> %a %a) (where (Integral %a) (Num %a)))
(defun (low-bit x) (bitwise-and x 1))
(low-bit 7) ;; 1
(low-bit 8L) ;; 0L
Unlike Eq, these constraints are never added for you. They are .NET interfaces, and no type you declare can ever implement them, so they say what a type is rather than what it can do, and bjolang wants that written down. The compiler tells you exactly what is missing, though:
Type Error at nums.bjo:2: 'low-bit' needs (Num %a), which its signature does not declare. [...] Write:
(: low-bit ... (where (Integral %a) (Num %a)))
If a function only compares values, use (where (Ord %a)) instead. It works for strings and your own types too.
1.13.7 The maths functions↑
abs, sign, min, max, clamp, zero?, even?, odd? and sqrt are in the prelude. The rest of System.Math is in (std maths), and works on doubles:
(import (std maths))
(sqrt 9) ;; 3.0: the int 9 widens to a double at the call
(pow 2.0 10.0) ;; 1024.0
(floor -2.5) ;; -3.0
(truncate -2.5) ;; -2.0
(round 2.5) ;; 2.0: banker's rounding, to the nearest even
(round 3.5) ;; 4.0
(clamp 5 1 3) ;; 3
(abs -5) ;; 5
The logarithms are loge (natural), log2, log10 and (log-base x b). The name log is taken by the logging function of the prelude.
2 Writing programs↑
While the first part focused on the core language constructs in single files, this part covers the wider ecosystem: breaking programs into modules, managing project dependencies, handling I/O, concurrency with bjoroutines, .NET interop, macros, and testing.
Throughout this part we will continue building on the card game from Part 1, gradually turning it into a full project complete with a deck module, a persistent high-score file, concurrent players, and a custom #card(Q hearts) literal.
2.1 Modules↑
In Bjolang, every .bjo file is implicitly a module named after its file. There are no namespace declarations or enclosing module blocks; instead, a module simply exports whatever public API it wants other files to see, leaving everything else private by default.
2.1.1 Splitting the game into files↑
Here is the deck from the first part as a module of its own. Put it in deck.bjo:
(import (std random))
(export Suit Rank Card full-deck shuffle card-points)
(type/derive (Eq Ord)
(: Suit (Union Clubs Diamonds Hearts Spades))
(: Rank (Union (: Pip int) Jack Queen King Ace))
(: Card (Struct (: rank Rank) (: suit Suit))))
(impl (Displayed Suit)
(defun (display-into s b)
(stringbuilder-add-string! b
(match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠")))))
(impl (Displayed Rank)
(defun (display-into r b)
(stringbuilder-add-string! b
(match r
((Pip n) (int->string n))
(Jack "J") (Queen "Q") (King "K") (Ace "A")))))
(impl (Displayed Card)
(defun (display-into c b)
(display-into (record-ref c rank) b)
(display-into (record-ref c suit) b)))
;; Not exported: only this module needs them.
(def all-suits [Clubs Diamonds Hearts Spades])
(def all-ranks [(Pip 2) (Pip 3) (Pip 4) (Pip 5) (Pip 6) (Pip 7) (Pip 8)
(Pip 9) (Pip 10) Jack Queen King Ace])
(: full-deck (-> (Vec Card)))
(defun (full-deck)
(loop (:for s all-suits)
(:subloop)
(:for r all-ranks)
(:acc deck (vecing (Card (rank r) (suit s))))
=> deck))
(: shuffle (-> (Vec Card) (Vec Card)))
(defun (shuffle deck) (shuffle-vec deck))
(: card-points (-> Card int))
(defun (card-points c)
(match c
((Card (rank (Pip n))) n)
((Card (rank Ace)) 11)
(_ 10)))
And the game, in game.bjo next to it:
(import "deck.bjo")
(defun (main)
(def hand (vec-slice (shuffle (full-deck)) 0 5))
(println #"Your hand: hand")
(println #"Points: (vec-fold (fun (c acc) (+ acc (card-points c))) 0 hand)")
0)
Running bjo run game.bjo outputs something like Your hand: [3♥ 10♠ 8♥ 10♦ 7♣].
Notice how the imports and exports interact here:
- Relative imports:
(import "deck.bjo")specifies a file path relative to the importing file. Bjolang compilesdeck.bjointodeck.dllahead of time, automatically rebuilding it whenever it (or any of its dependencies) changes. - Type exports are complete: Exporting a type exposes all its constructors, union cases, and fields. Because
Cardis exported,game.bjocan freely construct(Card (rank Ace) (suit Spades))and pattern-match on its fields. - Trait implementations are automatic: Although
Displayedwas not explicitly listed in the export list, its implementations still work ingame.bjo. Becauseimplblocks define anonymous implementations rather than named bindings, they cannot be exported individually; they are automatically carried along whenever the module defining them is loaded. - Private definitions: Unexported bindings like
all-suitsremain private todeck.bjoand are inaccessible from outside.
Every exported definition must have an explicit top-level type signature. The compiler needs this signature to write the metadata into the compiled .dll:
Export Error: Exported item 'helper' is missing a mandatory type signature at bad1.bjo:1
2.1.2 The standard library, and the prelude↑
A list-shaped module path such as (std random) refers to a standard library module. The compiler resolves these relative to the Bjolang installation rather than the current directory, so (std random) always points to the same library regardless of where you invoke bjo. A few notable modules include:
| Module | What it has |
|---|---|
(std random) | random numbers, shuffle-vec |
(std hashset) | sets; (std orderedset), (std orderedmap) |
(std ports) | reading a whole port: port->lines and friends |
(std fmt) | text layout |
(std rx) | regular expressions |
(std run) | running other programs |
(std effect) | effect handlers |
(std simpletest) | tests |
(std syntax-match) | writing macros |
(text json) | JSON |
By default, (std prelude) is imported implicitly into every module, providing core functions like println, list-map, and the basic operations introduced in Part 1. If you need to hide or override names from the prelude, you can import it explicitly using import modifiers (described below), which replaces the default implicit import:
(import (except (std prelude) list-map))
2.1.3 Exporting types↑
Types are private unless listed in export. Furthermore, the compiler forbids exporting a function whose signature references an unexported private type, since importers would have no way to understand or satisfy that signature. The compiler catches this as an export error in the defining module.
When you want external modules to use a type without inspecting or constructing its internal representation directly, mark the type definition with #:opaque. For example, here is a card draw pile in pile.bjo that only permits drawing from the top:
(import "deck.bjo")
(export Pile new-pile draw pile-size)
;; Importers can hold a Pile, but only this module can look inside.
(type (: Pile #:opaque (Record (: cards (Vec Card)))))
(: new-pile (-> Pile))
(defun (new-pile) (Pile (cards (shuffle (full-deck)))))
(: pile-size (-> Pile int))
(defun (pile-size p) (vec-length (record-ref p cards)))
;; The top card and the rest of the pile, or None when it is empty.
(: draw (-> Pile (Option (Tuple Card Pile))))
(defun (draw p)
(match (record-ref p cards)
([] None)
([top rest ...] (Some (Tuple top (Pile (cards rest)))))))
With this setup, another module can receive Pile values, use Pile in type annotations, and call functions like new-pile or draw. However, it cannot construct a pile directly with (Pile ...), access its internal cards field, or pattern-match on its structure:
Type Error at game3.bjo:4: 'cards' cannot be read here. pile/Pile is exported #:opaque,
so its representation is visible only to the code of pile. A value of it can be held and
passed on here, and built and taken apart through the functions that module exports.
Even though the type’s representation is private, any trait implementations defined in pile.bjo remain available to consumers. For instance, an (impl (Displayed Pile) ...) inside pile.bjo allows any importing module to print a Pile. Without one, a Pile is written as #<Pile>, so printing it shows nothing of its representation.
2.1.4 Import modifiers↑
Imports can be wrapped in modifiers to filter which definitions are brought into scope or rename them to prevent naming collisions:
| Modifier | Does |
|---|---|
(only m a b ...) | only these functions and macros |
(except m a b ...) | everything but these |
(prefix m "p/") | puts p/ in front of every name |
(postfix m "/p") | the same, at the end |
(prefix-defs m "p/") | a prefix on functions and macros only |
(prefix-types m "P/") | a prefix on types, constructors and traits only |
(rename m (old new) ...) | renames functions and macros |
(import "deck.bjo"
(prefix "pile.bjo" "pile/"))
(defun (main)
(match (pile/draw (pile/new-pile))
((Some (Tuple c rest)) (println #"Drew c, (pile/pile-size rest) left"))
(None (println "empty")))
0)
Modifiers can be nested and evaluate from the inside out. For example, (prefix (except (std hashset) hashset-map) "s/") excludes hashset-map and prefixes all remaining names with s/.
Note that only and except apply exclusively to functions and macros, not types. A module’s exported types are always imported, ensuring that signatures referencing those types remain valid and resolvable:
(import (only "deck.bjo" full-deck)
(rename (std random) (shuffle-vec mix)))
(defun (main)
(println (vec-length (mix (full-deck)))) ;; 52
(println (Card (rank Ace) (suit Spades))) ;; A♠, Card came along anyway
0)
Types are strictly scoped to the module that declared them. If two imported libraries each declare their own Card type, they remain distinct types, and writing the unqualified name Card is an error until you say which one you mean. To tell them apart, use prefix-types:
(import (prefix-types "poker.bjo" "P/")
(prefix-types "bridge.bjo" "B/"))
(: convert (-> P/Card B/Card))
2.1.5 Which name wins↑
When multiple definitions share the same identifier, name resolution follows three precedence rules:
- Local definitions take precedence over imported names.
- A name the prelude offers is overridden by any other import’s, which is how a module can freely replace a prelude name. Between two other imports that offer one name as two different things, importing both is fine, but writing the name is an error until you say which one you mean, with
only,exceptor a prefix. Two cases of different unions are the exception: which one a use means is decided by its type, as described for unions in Part 1. - Explicit renames or aliases created by import modifiers or
:aliasmust be unique. If an alias collides with an existing name, the compiler raises an error to prevent ambiguity.
2.1.6 re-export and :alias↑
While export publishes definitions authored in the current module, re-export publishes definitions that were imported from elsewhere. This makes it easy to create aggregator modules that bundle several sub-modules behind a single import. The prelude uses this technique to pass through =, compare, and list-sort from (std eq). In our card game:
;; cards.bjo: one import for everything about cards
(import "deck.bjo" "pile.bjo")
(re-export Card Rank Suit full-deck shuffle Pile new-pile draw)
Re-exporting a type exposes the exact original type, not a distinct copy; a Card referenced via cards.bjo is identical to one from deck.bjo. However, trait implementations (impl) do not transfer through re-exports. They remain bound to the module where they were defined, meaning any file that wants to print cards with their Displayed implementation must still import deck.bjo.
You can provide alternative names for bindings or macros using (:alias new old). Combining :alias with export or re-export allows libraries to offer cleaner or more idiomatic public APIs:
(:alias deal full-deck)
(export deal)
2.1.7 include↑
Unlike import, which compiles a separate module, (include "file.bjo") textually splices the included file’s forms directly into the current file at compile time. No separate module boundary is created, no exports are required, and all declarations in the included file share the enclosing module’s scope:
;; helpers.bjo
(: double (-> int int))
(defun (double x) (* x 2))
(include "helpers.bjo")
(defun (main)
(println (double 21)) ;; 42
0)
As a general guideline: use include when you want to divide a single large module across multiple files, and use import when the target file represents an independent component with its own encapsulation boundaries.
2.2 Projects and packages↑
Up to this point, our programs have consisted of standalone files importing neighboring modules directly by relative path. While that approach is convenient for quick scripts and small experiments, any software that pulls in external dependencies or is meant to be consumed by other developers should be structured as a project.
A project is simply a directory containing a manifest.bjodat file. When operating inside a project directory, bjo understands the package’s identity, resolves dependencies, and manages builds automatically. Outside of a project, the CLI falls back to single-file execution just as before.
2.2.1 Making a project↑
mkdir cards && cd cards
bjo init
bjo run
Running bjo init scaffolds a new project layout:
cards/
manifest.bjodat package metadata and dependencies
src/
main.bjo executable entry point
tests/
.gitignore
The generated manifest defines the package identity:
(package
(name (cards))
(version "0.1.0"))
All source files in a package live under src/, where each module’s name mirrors its relative path within that directory. For example, moving deck.bjo into src/ exposes it as the module (cards deck), which src/main.bjo can import just like any library module:
(import (cards deck))
(defun (main args)
(println #"Your hand: (vec-slice (shuffle (full-deck)) 0 5)")
(println #"Arguments: args")
0)
Because bjo automatically detects the project root by walking upward until it finds a manifest.bjodat, you can invoke CLI commands from any sub-directory within the project:
| Command | What it does |
|---|---|
bjo init [--lib] | Initializes a new project in the current directory |
bjo fetch | Fetches external dependencies without compiling |
bjo build | Fetches dependencies and compiles the program or library |
bjo run . args ... | Builds and runs the project with the specified arguments |
bjo check | Type-checks the entire project without emitting output binaries |
bjo repl | Starts the REPL preloaded with access to the project’s modules |
In bjo run . one two, the dot specifies the project itself. Any arguments following the target are forwarded directly to the program’s main function, binding args to [one two]. You can also point bjo run at a specific test file, such as bjo run tests/deck-test.bjo; the file will be compiled and linked against the project’s declared packages.
2.2.2 Modules only the package may use↑
An export list says what a module offers, but once a module is exported, any module can import it. As a package grows you want helpers that the package’s own modules share without them becoming part of what the package offers to others. Put them in a directory whose name starts with an underscore:
cards/
src/
_lib/shuffle.bjo (cards _lib shuffle)
deck.bjo may import (cards _lib shuffle)
tests/shuffle-test.bjo may import it too
A module under src/_lib/ may be imported by the modules under src/, and by the project’s own tests/ and benches/. Any other package that tries is told the module is internal to (cards), and should use a module outside _lib/ instead. The rule works for any directory starting with _, at any depth: a module in src/game/_rules/ is for the modules under src/game/. The REPL may import anything.
2.2.3 Libraries↑
A package that lacks an executable entry point (src/main.bjo) is treated as a library. Running bjo init --lib initializes a library project, and running bjo build inside it compiles all modules found under src/.
To see this in action, we can split out our card deck into a separate library residing alongside the game:
mkdir cardlib && cd cardlib
bjo init --lib
mv ../cards/src/deck.bjo src/
We can then declare a path dependency in cards/manifest.bjodat and import the library module as (cardlib deck):
(package
(name (cards))
(version "0.1.0")
(depends
(package (name (cardlib))
(source (path (dir "../cardlib"))))))
(import (cardlib deck))
(defun (main)
(println (vec-slice (shuffle (full-deck)) 0 5))
0)
Path dependencies are resolved relative to the manifest file that specifies them. Because a path dependency points straight to source files on disk, it does not require a version number—the build simply consumes whatever code is currently in the directory, making local development and iteration seamless.
2.2.4 Dependencies from git↑
Once a library is hosted in a Git repository, you can switch from a local path to a Git dependency:
(depends
(package (name (cardlib))
(version (version-at-least "0.1.0"))
(source (git (url "https://github.com/someone/cardlib")))))
Package versions correspond to Git tags formatted strictly as vX.Y.Z. Git dependencies specify version constraints using (version-at-least "1.2"), (version-between "1.2" "2.0"), or (version-at-most "2.0").
Bjolang resolves versions using Minimal Version Selection (MVS). Rather than greedily selecting the latest release available in the wild, MVS inspects the minimum version requested by each package across the entire dependency graph, and then selects the highest of those minimum requirements. In other words, you get the oldest possible release that satisfies everyone’s stated requirements. This guarantees reproducible builds: new upstream releases won’t alter your build until someone explicitly raises their requirement in a manifest, ensuring your code compiles identically a year from now. Any upper bound constraints are treated strictly as validation checks, never as criteria to pick an older package.
Commands like bjo fetch, build, and run clone external dependencies into a local .bjo/ directory and record the exact resolved commits in bjo.lock. You should always check bjo.lock into version control, while leaving .bjo/ ignored (as configured in the default .gitignore). If an upstream repository rewires a tag to point to a different commit after it was locked, the build aborts immediately. On CI servers, pass --locked to guarantee that the build fails if any dependency deviates from the locked snapshot.
You can also override dependency sources: if your root manifest defines an explicit source for a package, that source overrides any locations requested deeper in the dependency tree. This makes it straightforward to substitute a local checkout or an emergency fork while debugging.
2.2.5 NuGet packages↑
You can pull in third-party .NET packages directly from NuGet by listing them in the packages section:
(package
(name (shop))
(version "0.1.0")
(packages (nuget (id "Npgsql") (version "9.0.3"))))
Once restored, types and methods from NuGet packages can be consumed through Bjolang’s .NET interop mechanisms (described in the .NET section below). Version strings follow standard NuGet versioning semantics: "9.0.3" matches version 9.0.3 or higher, while bracketed syntax like "[9.0.3]" locks to that exact version. Bjolang delegates package restoration to the .NET SDK via the dotnet tool, storing the resolution graph in packages.lock.json (which should also be committed). Currently, compiled binaries load dependencies directly from the local NuGet cache, so executables expect to run in an environment with the restored packages available.
2.2.6 Native executables↑
A program usually runs on .NET: bjo run starts the runtime, and the JIT compiles each method the first time it runs. With (aot #t) in the manifest, the program is also compiled ahead of time, with .NET’s Native AOT, to one native executable:
(package
(name (cards))
(version "0.1.0")
(aot #t))
The executable is beside the source, with no extension: src/main, or src/main.native.exe on Windows. It holds the program, the standard library and the .NET runtime, so it runs on a machine without .NET installed.
- It starts in a few milliseconds. A program on .NET spends about 70 ms starting the runtime, and then compiles its methods as it runs them. On 17 days of Advent of Code, the solutions together took 3.0 s on .NET and 1.2 s as native executables.
bjo runstarts the native executable, andbjo publishcopies it into the output directory. The ordinary.exeis still built, for the debugger and for-d.- The build takes longer, about 10 s more, and needs clang or gcc. The first build downloads .NET’s ILCompiler package from NuGet.
- The runtime settings are built into the executable. The GC settings, such as
(runtime (gc-server #t)), take effect. The settings of tiered compilation do nothing, because there is no JIT. - A long-running program can be slower. The JIT optimizes the code with what it sees the program do (dynamic PGO), and a native executable can not. This matters for programs where the call graph stops the monomorphization or when trait calls cannot be determined at compile time.
- Reflection that is resolved at run time may fail. Native AOT keeps only the code that it can see the program use. Bjolang does not use reflection by default, so this should be a small problem.
By default the executable runs on any x86-64 CPU, so it uses only the instructions that all of them have: no AVX, and 128-bit vectors. aot-cpu names a CPU to build for instead:
(aot #t)
(aot-cpu "native")
"native" is the CPU of the machine that builds. A level, such as "x86-64-v3", is every CPU that has its instructions: x86-64-v3 has AVX2, and x86-64-v4 has AVX-512. An executable built for a CPU stops at start on a CPU that does not have the instructions, so use "native" for a program that runs where it is built. This matters most to (std array) and (std simd); see the section on vectors under performance.
Only the project being built reads aot and aot-cpu, as with runtime: a dependency is not a program. A change to either rebuilds the program. The compiler itself takes them as --aot and --aot-cpu, for a file built without a project.
2.2.7 Publishing a package↑
Publishing a Bjolang package requires three basic steps:
- Organize your source files under
src/, and create amanifest.bjodatdefining the packagenameandversion. - Run
bjo buildto verify that all modules compile cleanly without errors. - Tag your commit with the semantic version—e.g.
git tag -a v0.1.0 -m "First release"—and push the tag to your remote repository. Dependencies require validvX.Y.Ztags to resolve.
2.3 Files and I/O↑
2.3.1 Whole files↑
For files that comfortably fit into memory, the prelude provides straightforward functions to read or write entire files in a single call:
| Function | Description |
|---|---|
(file-read-text path) | Reads the entire file into a string |
(file-write-text path s) | Writes s, overwriting any existing contents |
(file-append-text path s) | Appends s to the file, creating it if needed |
(file-read-lines path) | Reads lines as a (Vec string) |
(file-write-lines path lines) | Writes a (Vec string), one line per element |
(file-read-bytes path) | Reads the raw bytes as an (Array byte) |
(file-exists? path) | Checks whether a file exists at the given path |
(file-delete path) | Deletes a file; succeeds silently if file is absent |
(file-copy from to) | Copies a file, raising an error if target exists |
(file-move from to) | Moves or renames a file or directory |
(file-info path) | Returns size, modification time, and kind as an Option |
Because these wrap the underlying .NET I/O methods directly, any I/O failure (such as missing permissions or a missing file on read) throws a .NET exception. If you expect a failure under normal conditions, wrap the call in a try expression.
To illustrate, we can add a persistent high-score ledger to our card game. Each line records a player name and their score separated by a space. Malformed lines are safely discarded rather than crashing the program:
(: score-file string)
(def score-file "scores.txt")
(: parse-score (-> string (Option (Tuple string int))))
(defun (parse-score line)
(match (string-split line " ")
([name points]
(match (try (string->int points) #:catch (System.FormatException))
((Ok n) (Some (Tuple name n)))
((Err _) None)))
(_ None)))
(: read-scores (-> (Vec (Tuple string int))))
(defun (read-scores)
(if (file-exists? score-file)
(loop (:for line (file-read-lines score-file))
(:when-let (Some score) (parse-score line))
(:acc (vecing score)))
[]))
(: save-score (-> string int void))
(defun (save-score name points)
(file-append-text score-file #"name points\n"))
(defun (main)
(save-score "ada" 31)
(save-score "bo" 27)
(println (read-scores)) ;; [(Tuple ada 31) (Tuple bo 27)]
0)
2.3.2 Paths, directories and the environment↑
Paths in Bjolang are represented as standard strings. The path manipulation utilities perform purely lexical operations on the string representations without touching the filesystem:
(path-combine "a" "b" "c.txt") ;; "a/b/c.txt"
(path-file-extension "deck.bjo") ;; (Some ".bjo")
(path-filename "/tmp/x/") ;; None: it names a directory
(path-directory "/tmp/x/y.txt") ;; (Some "/tmp/x")
(path-absolute "deck.bjo") ;; the full path
When you do need to inspect or modify the filesystem layout, use the directory functions:
(directory-create "saves/2026") ;; creates every missing directory on the way
(directory-exists? "saves") ;; #t
(directory-files ".") ;; the files directly in ".", as a (Vec string)
(directory-subdirectories ".") ;; and the directories
(directory-delete-tree "saves") ;; everything under it, and it
;; There is no pattern argument. Filter instead:
(vec-filter #(string-ends-with? & ".bjo") (directory-files "."))
;; Every file under a directory, lazily, without going into .git:
(filter #(string-ends-with? & ".bjo")
(directory-walk "." #:into? (fun (d) (not (= (path-filename d) (Some ".git"))))))
Process environment variables and working directory state can be inspected and updated similarly:
(get-environment-variable "HOME") ;; (Some "/home/linus"), or None
(set-environment-variable! "MODE" "x") ;; for this process and the ones it starts
(current-directory)
2.3.3 Ports↑
When dealing with large files, streaming data, or incremental I/O, Bjolang uses *ports*. An input port provides a stream from which characters and lines can be read incrementally, while an output port collects written data. File ports and in-memory string ports share the same underlying interface, so functions written against ports work seamlessly with both files and string buffers.
Attempting to open a file returns a Result, treating potential I/O errors (such as missing files) as expected outcomes. The Err variant wraps the underlying .NET exception, allowing you to match on specific exception types with :is:
(match (open-input-file "nope.txt")
((Ok port) (read-all port))
((Err (:is System.IO.FileNotFoundException e)) #"missing: (.-FileName e)")
((Err e) "some other error"))
To avoid leaking resources, you will usually want to use call-with-input-file or call-with-output-file. These higher-order functions open the port, pass it to your callback, and guarantee the port is cleanly closed when the function finishes—even if an exception is thrown. For example, counting lines in an arbitrary file looks like:
(: count-lines (-> string (Result Exception int)))
(defun (count-lines path)
(call-with-input-file path
(fun (port)
(loop (:until (port-eof? port))
(:let line (read-line port))
(:acc lines (counting line))
=> lines))))
(count-lines "scores.txt") ;; (Ok 2)
| Function | Description |
|---|---|
(open-input-file path) | Returns a (Result Exception TextInputPort) |
(open-output-file path) | Opens for writing; #:mode can be Truncate, Append, or CreateNew |
(read-line/opt p) | Reads the next line, or returns None at EOF |
(read-char/opt p) | Reads the next character, or returns None at EOF |
(read-all p) | Reads the remaining contents into a single string |
(port-eof? p) | Checks if the end of the input stream has been reached |
(write-string p s) | Writes string s to the port |
(writeln p s) | Writes string s followed by a newline |
(close-input-port p) | Closes an input port |
(close-output-port p) | Closes an output port |
(open-input-string s) | Creates a port reading from an in-memory string |
(open-output-string) | Creates a string-builder port; read with get-output-string |
The variants read-line and read-char (without the /opt suffix) also exist, but throw an end-of-file exception if called at EOF.
Convenience helpers in (std ports) can ingest an entire port into collections: port->lines, port->list, port->vec, as well as lazy sequences via port->seq and file->seq:
(import (std ports))
(call-with-input-file "scores.txt" port->lines) ;; (Ok ["ada 31" "bo 27"])
In Bjolang, open file ports are also tracked by their enclosing structured concurrency scope (explained in the Concurrency section below). When a scope exits, any unclosed ports opened within it are automatically finalized. Even main acts as a root scope, ensuring leaked handles are collected when your process terminates. For long-lived processes, however, you should always explicitly close ports or rely on call-with-input-file.
2.3.4 Running other programs↑
The (std run) module lets you invoke external processes. Commands are written as S-expression forms using a concise DSL where pipelines and shell-style redirections are integrated directly into the syntax:
(import (std run))
(run/string '(echo "hello")) ;; (Ok "hello\n")
(run/strings '(pipe (cat "scores.txt") (sort -r))) ;; (Ok ["bo 27" "ada 31"])
(run/status '(into-file "sorted.txt"
(pipe (cat "scores.txt") sort))) ;; (Ok 0), the exit code
(def who "ada")
(run/strings '(grep ,who "scores.txt")) ;; (Ok ["ada 31"])
Prefixing an identifier with a comma (e.g. ,who) splices the variable’s value into the command arguments. Each list element is treated as an individual command-line argument, preserving spaces without fragile string quoting or shell injection hazards. The DSL primitives pipe, into-file, from-file, append-to-file, and errors-into-file are validated at compile time; any unrecognized head identifier is executed as an external executable.
| Function | Returns |
|---|---|
(run/string form) | Standard output as a single trimmed string |
(run/strings form) | Standard output split into a vector of lines |
(run/status form) | Process exit code as an integer |
(run/output form) | Tuple of exit code and standard output string |
(run form) | A running Proc handle with streaming I/O ports |
All of these functions return a Result and execute synchronously, blocking the calling thread until completion. When running inside a bjoroutine, non-blocking asynchronous alternatives are available; refer to the (std run) module documentation for details.
2.4 Formatting text↑
For one line of output, an interpolated string or println* is enough. println* is in (std fmt), and takes strings, ints, doubles, chars and bools in one vec:
(import (std fmt))
(println #"who has points points") ;; ada has 31 points
(println* [who " has " points " points"]) ;; ada has 31 points
When the shape of the output matters, such as columns that line up, padding, a paragraph wrapped to a width or a table, use the layout part of (std fmt). This section is a guide to it. The module’s page has the reference.
2.4.1 Blocks↑
Layout is made of blocks. A Block is a piece of output, and every function that lays something out takes its content as a block and gives back a block. These are blocks:
- A string, an int or a char. Written out, or the value of a variable or a call:
"tea",name,(string-upcase name). - A vec of blocks: the blocks one after another.
["x = " n nl]is a string, an int and a newline. - What a function in
(std fmt)gives back:(padded/left 5 "x"),nl,nothing. - An
if,cond,matchorletwhose results are blocks.
So one thing needs no brackets, and several things are a vec:
(def name "Ada")
(def n 3)
(show "Hello\n") ;; Hello
(show (padded/left 6 name)) ;; " Ada"
(show (padded/left 8 ["x = " n])) ;; " x = 3"
(show [(padded/left 5 "x") (padded/left 5 "y") nl]) ;; " x y", then a newline
(show ["Hello, " name "! You have " n " new messages" #\. nl])
;; Hello, Ada! You have 3 new messages.
Read (padded/left 6 name) as “pad name to 6 columns”, and (padded/left 8 ["x = " n]) as “pad these pieces, together, to 8 columns”. A vec in a vec is a block in a block, so extra brackets change nothing.
Nothing is written until show runs the block. A block is a value: you can bind it, pass it to a function and return it.
2.4.2 What is not a block↑
Anything else is converted first:
| You have | Write |
|---|---|
A double x | (numeric x), or (numeric x #:precision 2) |
A bool b | (if b "yes" "no") |
A long n | (long->string n), or (bytes n) for a size |
A (Vec string) xs | (vec-map BStr xs) |
A (List %a) xs | (joined f xs ", "), where f makes the block of one element |
BStr, BInt, BChar and BMany are the cases of Block. You rarely write them, but like any constructor they are functions, so (vec-map BStr names) is a vec of blocks.
A number bound by def. (def n 3) does not say which kind of number n is: it could still become a long or a double. Where a block is wanted, it is read as the number written there would be, so ["n = " n] is what ["n = " 3] is, and n is an int from then on. A number that is used as a double later, as in (+ n 0.5), needs (: n double) above its def.
Where a block is wanted. A string or a vec is read as a block where the compiler knows that a block is wanted: as an argument, as what a function declared to return a Block returns, and as the value of a binding whose type is written, such as (: parts Block). A def without a type is what its value is: (def parts ["a" "b"]) is a (Vec string), and (show parts) is refused. Write (: parts Block) above it.
2.4.3 Blocks from your data↑
Most layout code is a function from a value of your own to a block. Give it a signature: the signature is what tells the compiler that what it returns is a block.
(type (: Item (Record (: name string) (: price double) (: stock int))))
(: stock (Vec Item))
(def stock
[(Item (name "Green tea") (price 3.5) (stock 12))
(Item (name "Gaiwan") (price 1240.0) (stock 2))
(Item (name "Kettle") (price 89.9) (stock 0))])
(: item-line (-> Item Block))
(defun (item-line i)
[(padded/right 10 (record-ref i name))
(padded/left 9 (numeric (record-ref i price) #:precision 2 #:comma 3))
(if (= (record-ref i stock) 0) " sold out" nothing)
nl])
(show (vec-map item-line stock))
Green tea 3.50
Gaiwan 1,240.00
Kettle 89.90 sold out
vec-map gives a vec of blocks, which is a block. A lambda given to vec-map is different from a function with a signature: vec-map does not say what its function returns, so a vec of mixed pieces that the lambda returns, such as a string and an int, is not read as blocks and is refused. Give it to each, which says that it is one:
(show (vec-map (fun (i) (each [(record-ref i name) ": " (record-ref i stock) nl]))
stock))
;; Green tea: 12
;; Gaiwan: 2
;; Kettle: 0
A lambda given to a function in (std fmt), such as joined, needs no each: their signatures say that the lambda returns a block.
nothing is the block that writes nothing, for the branch that has nothing to say:
(: greeting (-> string (Option string) Block))
(defun (greeting name title)
["Hello, "
(match title
((Some t) [t " "])
(None nothing))
name "!"])
(: medal (-> int Block))
(defun (medal place)
(cond ((= place 1) "gold")
((= place 2) "silver")
(else nothing)))
(show [(greeting "Ada" (Some "Dr")) nl (greeting "Bob" None) nl])
;; Hello, Dr Ada!
;; Hello, Bob!
(show [(medal 1) "/" (medal 2) "/" (medal 3) nl]) ;; gold/silver/
A list is written with joined, which takes a function that makes each element’s block, and a separator:
(show [(joined BStr (list "tea" "milk" "honey") ", ") nl]) ;; tea, milk, honey
(show [(joined/last BStr (fun (s) ["and " s])
(list "tea" "milk" "honey") ", ") nl]) ;; tea, milk, and honey
(show [(joined (fun (s) ["* " s]) (list "tea" "milk") nl) nl])
;; * tea
;; * milk
2.4.4 Padding, cutting and leaders↑
padded/left, padded/right and padded/both pad content to a width: aligned right, left, or centred. Padding never cuts. trimmed/left, trimmed/right and trimmed/both cut content to a width, and keep its end, its start or its middle. fitted/left, fitted/right and fitted/both do both, so the content is exactly the width.
(show [(padded/right 8 who) (padded/left 4 points) nl]) ;; ada 31
(show [(with-pad #\0 (padded/left 4 42)) nl]) ;; 0042
(show [(with-pad #\= (padded/both 20 " Receipt ")) nl]) ;; ===== Receipt ======
(show [(trimmed/left 6 "/home/ada/scores.txt") nl]) ;; es.txt
(show [(with-fmt current-ellipsis "…"
(trimmed/left 12 "/home/ada/scores.txt")) nl]) ;; …/scores.txt
(show ["[" (fitted/both 6 "ab") "]" nl]) ;; [ ab ]
space-to pads to a column, whatever was written before it, and with-pad changes what the padding is made of. Together they make a leader:
(show [(with-pad #\. ["Tea" (space-to 12) "3.50"]) nl
(with-pad #\. ["Gaiwan" (space-to 12) "1240.00"]) nl])
;; Tea.........3.50
;; Gaiwan......1240.00
nl is a newline, and fl a newline unless the cursor is already at the start of a line.
2.4.5 Numbers↑
An int is a block. A double is not, as there is no one way to write one: numeric says how, with a precision, digits in groups, and the separators. bytes and percent are blocks for two common kinds of number.
(show [(numeric 1234567.891 #:precision 2 #:comma 3) " "
(numeric 0.5 #:precision 3) " "
(numeric 42.0 #:signed #t) " "
(percent 0.125) " "
(bytes 1500L #:si #t) nl])
;; 1,234,567.89 0.500 +42 12.5% 1.5 kB
The digits are always the invariant culture’s, so the machine’s locale never shows. The separators are settings, described below.
2.4.6 Paragraphs↑
wrapped fills content to current-width, and justified makes it flush against both margins. Every run of whitespace in the content is a break between words.
(show [(with-fmt current-width 20
(justified "Bjolang lays text out with blocks, and blocks nest."))
nl])
;; Bjolang lays text
;; out with blocks, and
;; blocks nest.
The first line starts where the cursor is, so after a label it has less room. The lines after it start at column 0.
2.4.7 Tables↑
A table is the place where the brackets matter. tabular takes a vec of columns, and tabular/rows a vec of rows. Each column or row is a (Vec Block), with a block for each cell:
(show (tabular [["tea" nl "gaiwan" nl "kettle"]
[3 nl 1240 nl 90]]
#:sep " " #:align [AlignLeft AlignRight]))
(show (tabular/rows [["tea" 3] ["gaiwan" 1240] ["kettle" 90]]
#:sep " " #:align [AlignLeft AlignRight]))
Both write the same table:
tea 3
gaiwan 1240
kettle 90
In tabular a column’s cells are separated by nl. In tabular/rows each element of a row is one cell, so a cell of several pieces is a vec in the row, and a newline in a cell gives the row another line:
(show (tabular/rows [["tea" [3 " cups"]]
["kettle" ["boils in" nl "4 minutes"]]]
#:style StyleAscii))
+--------+-----------+
| tea | 3 cups |
| kettle | boils in |
| | 4 minutes |
+--------+-----------+
Rows from data come from a function with a signature, for the reason given above: a row of a string and an int has no element type of its own. Here is the high-score ledger from “Files and I/O” as a table:
(: score-row (-> (Tuple string int) (Vec Block)))
(defun (score-row s)
(def (Tuple name points) s)
[name points])
(show (tabular/rows (vec-map score-row (read-scores))
#:titles ["Player" "Score"]
#:align [AlignLeft AlignRight]
#:style StyleAscii))
+--------+-------+
| Player | Score |
+--------+-------+
| ada | 31 |
| bo | 27 |
+--------+-------+
The tables here are drawn with StyleAscii, whose characters every font has. #:style takes a TableStyle: StyleNone, the default, StyleSimple, StyleAscii, StyleLight, StyleHeavy, StyleDouble, StyleRounded, StyleMarkdown, or StyleCustom with pieces of your own. #:align takes an Align for each column, from the first: AlignLeft, AlignRight, AlignCentre, or (AlignOn #\.), which puts every decimal point of the column in one place. #:widths and #:overflow make a table fit current-width, by cutting or wrapping its cells.
A table whose rows are not all known yet, such as results that come one at a time, is laid out a row at a time with table-layout and table-row. The widths are fixed beforehand, so each row is written when it is known:
(def layout (table-layout [8 6] #:sep " " #:align [AlignLeft AlignRight]))
(show (table-row layout ["Player" "Score"]))
(loop (:for (Tuple name points) (read-scores))
(:do (show (table-row layout [name points]))))
Player Score
ada 31
bo 27
A table can also be a quoted literal, which the compiler checks where it is written. A column can say what kind of number it holds:
(show-table '(table (style ascii) header
(column "Player")
(column "Score" (align right))
(column "Share" (percent (precision 0)))
(rows (row "ada" 31 0.46) (row "bo" 27 0.40) (row "cy" 9 0.13))))
+--------+-------+-------+
| Player | Score | Share |
+--------+-------+-------+
| ada | 31 | 46% |
| bo | 27 | 40% |
| cy | 9 | 13% |
+--------+-------+-------+
The rows can be spliced, (rows ,scores), from a (Vec (Vec TableCell)). See Table on the module’s page.
2.4.8 Settings↑
The pad character, the right margin, the ellipsis and the number formats are parameters. For a whole output, set them with parameterize around the show:
(parameterize ((current-width 20))
(show (wrapped "Bjolang lays text out with blocks, and blocks nest.")))
;; Bjolang lays text
;; out with blocks, and
;; blocks nest.
A block reads its settings when it runs, which is in show, not when it is built. So the parameterize must be around the show: around the code that builds the block, it has ended before the block runs, and does nothing. (show (parameterize ((current-width 20)) (wrapped text))) wraps at the default width, 78.
For part of an output, such as one paragraph, one cell or the numbers of one column, put the setting in the layout. with-fmt binds one setting around a block while it runs, with-fmt* binds several, written as parameterize is, and with-pad is with-fmt for current-pad:
(show (with-fmt* ((current-comma 3)
(current-comma-sep #\space)
(current-decimal-sep #\,))
(numeric 1234567.5) nl))
;; 1 234 567,5
The settings are current-pad, current-width, current-ellipsis, current-string-width, current-precision, current-comma, current-comma-sep, current-decimal-sep and current-signed. For text with wide characters or colours in it, which a terminal shows in more or fewer columns than it has characters, set current-string-width to terminal-width.
2.4.9 When it does not compile↑
The messages for the common mistakes, and what to write instead:
| The compiler says | Write |
|---|---|
| Block: … found a double | (numeric x) |
| Block: … found the number 1.5 | (numeric 1.5) |
| Block: … found a boolean, or found a bool | (if b "yes" "no") |
| Block: … found a (Vec string) | (vec-map BStr xs), or (: xs Block) above (def xs ...) |
| Block: … found a (Vec (Vec …)), at a lambda that returns a vec | (each [...]) around the vec the lambda returns |
| these types do not match, naming two of int, string and char, at a lambda that returns a vec | (each [...]) around the vec the lambda returns |
The last two are one mistake, a lambda given to a function that does not say it returns a block. Its vec is a vec of strings if all its pieces are strings, and then show gets a vec of vecs. If the pieces are of different kinds, such as a name and a count, the vec has no type at all, and the compiler says so inside the lambda.
2.4.10 A block of your own↑
captured renders content to a string and gives it to a function, which returns the block to write in its place. It is how a block that needs the whole of its content is made, and how padding and trimming are written:
(: boxed (-> Block Block))
(defun (boxed b)
(captured (fun (s) ["[" s "]"]) b))
(show [(boxed ["x = " 7]) nl]) ;; [x = 7]
A block that writes as it goes is a BRun: a function given the sink to write to and the column the cursor is at, which returns the column it ends at. run-block runs a block inside one. The module’s page describes both.
2.5 Concurrency↑
Bjolang’s concurrency model rests on three foundations: lightweight fibers called *bjoroutines*, communication channels, and structured concurrency scopes ensuring background work is cleanly contained. In addition, Bjolang adopts the Concurrent ML (CML) paradigm, where communication operations—such as sending or receiving on a channel—are first-class values that can be composed and combined before being synchronized.
2.5.1 Bjoroutines↑
Functions that may suspend execution—whether waiting on a channel, sleeping, or performing asynchronous I/O—are declared with defbjo rather than defun, and their type signatures use the -bjo-> arrow. Suspending and waiting for an event is performed using sync:
(: think (-bjo-> int int))
(defbjo (think n)
(sync (timeout (milliseconds 50))) ;; wait 50 ms, without holding up a thread
(* n n))
Bjoroutines execute as lightweight fibers scheduled across a managed thread pool. When a fiber suspends at a sync point, it yields its underlying thread back to the pool. As a result, maintaining thousands of waiting fibers incurs the memory overhead of small heap objects rather than expensive OS threads.
Because calling a suspending function means the caller may also have to wait, function coloring applies: bjoroutines can only be directly invoked from within other bjoroutines:
Type Error at colour.bjo:7: calling 'think' is a yield point, and a yield point is not allowed here.
'twice' is defined with (defun ...), which is emitted as an ordinary C# method, and an ordinary
method cannot await.
Define it with (defbjo ...), or move the suspending call out of it. Note that (defbjo ...) spreads:
whoever calls 'twice' needs to be one too.
To enter concurrent execution from the start, main can itself be defined with defbjo.
In practice, this coloring is less infectious than in many other languages. Most standard I/O functions (such as file-read-text) are automatically emitted with dual implementations: a synchronous blocking path and an asynchronous suspending path. An ordinary defun that calls standard I/O similarly gets both versions generated under the hood; when called from a bjoroutine, it transparently executes the non-blocking suspending version:
(: size-of (-> string int))
(defun (size-of path) (string-length (file-read-text path))) ;; an ordinary defun
(defbjo (main)
(println (size-of "deck.bjo")) ;; the file is read without blocking a thread
0)
2.5.2 spawn and channels↑
The (spawn (f args ...)) form creates a new concurrent fiber running the invocation (f args ...). Arguments are evaluated eagerly in the spawning fiber before transferring execution to the new fiber.
Fibers communicate over channels created with make-chan. Rather than performing immediate I/O, expressions like (chan-send ch v) and (chan-recv ch) construct first-class *events*, which are then synchronized using sync. Unbuffered channels operate as rendezvous points: sending blocks until a receiver is ready, and receiving blocks until a sender arrives.
We can model our card players as concurrent bjoroutines. Each player receives a channel representing their seat at the table and plays one card per trick (descending by value). The table (main) draws cards round-robin from each seat:
(import "deck.bjo")
(: player (-bjo-> (Vec Card) (Chan Card) void))
(defbjo (player hand seat)
(loop (:for c (list-reverse (list-sort (vec->list hand))))
(:do (sync (chan-send seat c)))))
;; One trick: a card from every seat, in turn.
(: play-trick (-bjo-> (Vec (Chan Card)) (Vec Card)))
(defbjo (play-trick seats)
(loop (:for seat seats)
(:acc (vecing (sync (chan-recv seat))))))
(defbjo (main)
(def deck (shuffle (full-deck)))
(def seats [(make-chan) (make-chan) (make-chan)])
(loop (:for seat seats)
(:for p (range 0 3))
(:do (spawn (player (vec-slice deck (* p 4) 4) seat))))
(loop (:for trick (range 1 5))
(:do (println #"Trick trick: (play-trick seats)")))
0)
Trick 1: [K♥ K♦ J♥]
Trick 2: [8♣ 7♣ 4♣]
Trick 3: [6♠ 4♥ 3♣]
Trick 4: [6♦ 3♦ 2♦]
Because channel sends and receives synchronize as a rendezvous, a player cannot get ahead of the table and play multiple cards into the same trick.
2.5.3 Events are values↑
Crucially, calling (chan-recv seat) does not perform the receive immediately; it simply constructs an event value describing the operation. The actual communication occurs only when the event is passed to sync. Because events are first-class values, they can be parameterized, composed, and transformed before being awaited:
| Function | Description |
|---|---|
(chan-recv ch) | Receives a value from channel ch |
(chan-send ch v) | Sends v on channel ch |
(timeout d) | Fires after the Duration d |
(choose e1 e2 ...) | Fires when the first of the supplied events occurs |
(wrap e f) | Fires when e occurs, applying f to its value |
A Duration is a length of time, made with milliseconds, seconds, minutes or hours: (timeout (seconds 5)). It has its unit in its type, so a timer cannot be given a bare number, and one too long for its range raises rather than wrapping round.
With these combinators, deadlines and timeouts become simple functions that compose over any event. For instance, we can enforce a response timeout on our card players:
(: within (-> Duration (Event %a) (Event (Option %a))))
(defun (within d ev)
(choose (wrap ev #(Some &))
(wrap (timeout d) (fun (u) None))))
(: slow-player (-bjo-> (Chan string) void))
(defbjo (slow-player seat)
(sync (timeout (milliseconds 200)))
(sync (chan-send seat "7♣")))
(defbjo (main)
(def seat (make-chan))
(spawn (slow-player seat))
(println (sync (within (milliseconds 50) (chan-recv seat)))) ;; None
(println (sync (within (milliseconds 500) (chan-recv seat)))) ;; (Some 7♣)
0)
Notice that within is defined with standard defun rather than defbjo. Simply constructing or composing event values does not suspend execution; suspending only occurs when an event is submitted to sync.
Also notice how the first timeout behaves: when the 50 ms deadline expires before the card arrives, the chan-recv event is cleanly cancelled without consuming the message. The card remains on the channel, available to be received by the subsequent 500 ms wait.
An operation that is synced right where it is written does not need to be an event first. (chan-put ch v) does what (sync (chan-send ch v)) does, and (chan-get ch) what (sync (chan-recv ch)) does. They wait, and are cancelled, exactly as sync is, but no event value is built, so they are cheaper. Use the event forms when you need an event: to choose between operations, to wrap one, or to hand one to a function such as within. Our players could just as well be written with chan-put:
(defbjo (player hand seat)
(loop (:for c (list-reverse (list-sort (vec->list hand))))
(:do (chan-put seat c))))
2.5.4 Collecting fiber results: bjo↑
While spawn runs a fiber for its side effects and discards its return value, bjo returns a joinable promise. You can await this promise using (promise-join p), which yields a Result wrapping either the computed value or an exception if the fiber failed:
(def a (bjo (think 3)))
(def b (bjo (think 4)))
(sync (promise-join a)) ;; (Ok 9)
(sync (promise-join b)) ;; (Ok 16)
Because both fibers execute concurrently across thread pool workers, the combined operations complete in roughly 50 ms rather than running sequentially for 100 ms.
2.5.5 Scopes↑
Every fiber runs within an enclosing structured concurrency scope. A scope cannot complete until all fibers spawned within it have terminated, ensuring background tasks never outlive their caller or leak into other components. Furthermore, unhandled exceptions inside spawned fibers propagate outward when the scope terminates rather than being silently dropped.
The top-level main function acts as an implicit root scope, which is why our earlier card game example cleanly awaited all player fibers. You can create nested scopes with fine-grained cancellation and deadlines using these forms:
| Form | Description |
|---|---|
(with-scope body ...) | Waits for child fibers and finalizes open file ports |
(with-cancel (cancel) body ...) | Binds a cancel procedure to signal child fibers |
(with-deadline d body ...) | Cancels the scope automatically after the duration d |
(with-shield body ...) | Shields cleanup operations from parent cancellation |
(: fetch (-bjo-> string void))
(defbjo (fetch name)
(sync (timeout (milliseconds 30)))
(println #"fetched name"))
(: count-up (-bjo-> (Chan int) void))
(defbjo (count-up ch)
(let go ((i 0))
(sync (chan-send ch i))
(go (+ i 1))))
(defbjo (main)
;; Both are fetched when this returns, or the deadline raises.
(with-deadline (seconds 1)
(spawn (fetch "a"))
(spawn (fetch "b")))
(println "both done")
;; count-up never ends on its own. Cancelling the scope stops it
;; the next time it waits.
(def numbers (make-chan))
(with-cancel (cancel)
(spawn (count-up numbers))
(println (sync (chan-recv numbers))) ;; 0
(println (sync (chan-recv numbers))) ;; 1
(cancel (Requested "enough")))
0)
Cancellation in Bjolang is cooperative rather than preemptive. Cancelling a scope signals its fibers, which check for cancellation whenever they next perform a suspending operation (such as waiting on a channel or timer). A fiber that exits due to cancellation is treated as a clean completion rather than a failure.
Depending on the desired lifecycle and supervision strategy, fibers can be started using four distinct forms:
| Form | Scope behavior |
|---|---|
(spawn (f x)) | Awaited by scope; propagates failure on error |
(bjo (f x)) | Awaited by scope; yields a promise holding value/failure |
(spawn/daemon (f x)) | Cancelled on scope exit without being awaited |
(spawn/detached (f x)) | Runs detached; unmonitored by the enclosing scope |
The scope forms work in both colours. In a bjoroutine, the wait at the end of the scope suspends. In ordinary code, a defun or a (fun ...), it parks the thread until the children have ended, as sync/blocking does. So an ordinary function can start fibers and know that they are done when it returns:
(: fetch-all (-> (Vec string) void))
(defun (fetch-all names)
(with-scope
(ignore (own! (fun () (println "released"))))
(loop (:for n names)
(:do (spawn (fetch n))))
(println "all started")))
(defun (main)
(fetch-all ["a" "b"])
(println "back in main")
0)
all started
fetched a
fetched b
released
back in main
A scope also owns resources. (own! thunk) registers a release on the innermost scope, and (own-disposable x) registers x’s .Dispose. When the scope ends, the releases run after the fibers have ended, newest first. (release! h) runs one early, with the handle own! returned. These are ordinary functions, so they also work in both colours. A release is a plain thunk: it cannot suspend, and a release that blocks also blocks the end of the scope.
Do not park a bjoroutine’s thread by accident. A scope in an ordinary (fun ...) that a bjoroutine calls parks the thread of that bjoroutine, and the compiler warns about it. Write the scope in the bjoroutine itself. Or, if the function the lambda is given to takes a -?-> parameter, write the lambda as a (bjoroutine ...).
2.5.6 Work that is not a fiber↑
Performing long CPU-bound computations or calling legacy blocking .NET APIs directly inside a fiber can monopolize thread pool threads, preventing other fibers from running. To avoid blocking the pool, you can offload such operations onto dedicated background threads; both helpers return synchronization events:
;; Work that waits: a call that parks its thread.
(sync (blocking #(file-read-text "deck.bjo"))) ;; (Ok "...")
;; Work that computes: gets a thread of its own.
(sync (spawn/thread #(loop (:for i (range 0 1000)) (:acc (summing i))))) ;; (Ok 499500)
Conversely, you may occasionally need to wait on a channel or event from inside synchronous code. While an ordinary defun cannot suspend with sync, it can block its caller thread using sync/blocking. Avoid calling sync/blocking inside a bjoroutine, as doing so freezes a thread pool worker that other fibers rely on:
(: wait-for-card (-> (Chan Card) Card))
(defun (wait-for-card ch)
(sync/blocking (chan-recv ch)))
2.5.7 Shared state: atomics and transactions↑
Channels move values from one fiber to another. When many fibers must read and change the same data, a channel means one fiber that owns the data and answers requests for it. That is often the clearest design, but it is not the only one. Two modules let fibers share data directly:
| Module | What it is for |
|---|---|
(std atomic) | One value that threads change without a lock: a counter, a flag, a cached result |
(std stm) | Changes to several values as one step, and waiting until the values are right |
An atomic is the simple case and is described in Part III. The rest of this section is about transactions: software transactional memory, built on the design of the OCaml library kcas.
Locations and transactions. A (Loc T) holds one value. A transaction is a function of one argument, a Tx, which is the log of the transaction. The function reads and writes locations through the log with tx-ref and tx-set!, and atomically runs it and commits it:
(import (std stm))
(def pot (make-loc 0))
(def table (make-loc (list)))
;; Put a card on the table and add its points to the pot, as one step.
(: play! (-> Card int void))
(defun (play! card points)
(atomically
(fun (tx)
(tx-set! tx table (Cons card (tx-ref tx table)))
(tx-set! tx pot (+ (tx-ref tx pot) points)))))
The writes stay in the log until the transaction commits, and the commit makes all of them visible at once. No other fiber sees the card on the table without its points in the pot. If another transaction changed a location that this one read before this one commits, this one runs again with the new values. So a transaction never acts on values that changed under it, and nothing is ever locked.
The rules for a transaction function. Because it can run more than once, a transaction function must have no side effects except through its Tx: no printing, no I/O, no changes to other state. Do those after atomically returns. A transaction function also can not sync. It is an ordinary function, and the compiler says so:
Type Error at play.bjo:7: calling 'sync' is a yield point, and a yield point is not allowed here.
If a transaction raises an exception, it writes nothing, and atomically raises the exception. Store immutable values in locations: a change inside a mutable vec is not part of any transaction.
Waiting with tx-retry. A transaction that can not go on yet calls tx-retry. That stops it, and it runs again when a location that it read has changed. There is no condition variable to signal and no wakeup to forget:
;; The top card of a pile. Waits while the pile is empty.
(: take-top (-> Tx (Loc (List Card)) Card))
(defun (take-top tx pile)
(match (tx-ref tx pile)
((Cons card rest) (tx-set! tx pile rest) card)
(Nil (tx-retry tx))))
A transaction is an event. (atomically-evt f) is the event that runs and commits f. While f retries, the event is not available. So waiting for a condition on shared data combines with everything else that choose combines: a timeout, a channel, another transaction, and the cancellation of the fiber’s scope. A transaction whose branch loses has written nothing. atomically is (sync (atomically-evt f)):
;; A card from the pile, or None after 100 ms.
(sync (choose (wrap (atomically-evt (fun (tx) (take-top tx pile))) Some)
(wrap (timeout (milliseconds 100)) (fun (_) None))))
Inside a transaction, (tx-or-else tx a b) runs a, and if a retries, undoes its writes and runs b. If both retry, the whole transaction waits for a change to anything either of them read.
atomically works in both colours: in a bjoroutine it syncs, and in an ordinary function it blocks the thread. try-atomically never waits: it gives None when the transaction retries, which makes it safe in a callback that .NET calls.
Queues, maps and counters. Three modules build data structures out of locations, so that their operations are part of the transaction that calls them:
| Module | Type | What it is |
|---|---|---|
(std stm queue) | (TQueue T) | A FIFO queue, bounded or not. Producers and consumers seldom conflict |
(std stm map) | (TMap K V) | A hash map. Changes to different keys seldom conflict |
(std stm counter) | (TCounter T) | A number that fibers on different CPUs add to without conflicts |
Their operations take the Tx first. Here a dealer takes the top card of a shared deck into a player’s hand, and counts it, in one step:
(import (std stm queue))
(import (std stm map))
(import (std stm counter))
(def deck (make-queue)) ;; a (TQueue Card)
(def hands (make-tmap)) ;; a (TMap string (List Card))
(def dealt (make-tcounter 0)) ;; a (TCounter int)
;; Waits while the deck is empty: queue-pop! retries.
(: deal! (-> Tx string Card))
(defun (deal! tx player)
(def card (queue-pop! tx deck))
(tmap-update! tx hands player
(fun (hand) (Some (Cons card (match hand ((Some cards) cards) (None Nil))))))
(tcounter-add! tx dealt 1)
card)
(: dealer (-bjo-> string int void))
(defbjo (dealer player n)
(loop (:for i (range 0 n))
(:do (ignore (atomically (fun (tx) (deal! tx player)))))))
Two dealers can run at the same time. Neither card can end up in two hands, or be counted without being dealt. The queue also has the channel names for use outside a transaction: queue-send and queue-recv are events, and queue-put and queue-get sync them. What a queue adds to a channel is the transaction: take from one queue and put in another, or take from whichever of two queues has a card, as one step.
Costs. A transaction that reads and writes the same locations as many others runs again often. Many fibers that write one location cost the most. A TMap or TCounter spreads the writes over many locations for that reason. A transaction that only reads never makes another one run again. Transactions also allocate, and a program in which many fibers allocate at once is often limited by the garbage collector: set (runtime (gc-server #t)) in the manifest to give it one heap per CPU. With it, sixteen fibers that add keys to one TMap have eight times the throughput of one fiber.
2.5.8 Compared with Go and Hopac↑
Go and Hopac are the two points of comparison. Go is the obvious one: goroutines and unbuffered channels are the same model of fibers meeting at a rendezvous. Hopac is the tougher one. It is a Concurrent ML library for F#, running on the same .NET runtime with the same JIT and garbage collector, so the differences between it and Bjolang are in the design rather than in the platform. The main repository has the same benchmarks in all three (bench/bjolang/cmlbench.bjo, go run . -suite cmlbench in bench/go, and dotnet run -c Release -- cmlbench in bench/hopac).
Every Bjolang channel operation races the cancellation token of its scope. Neither Go nor Hopac has that built in, so each is measured twice: plain, and with every operation raced against what a program in it has to use to stop its work. In Go that is a select against ctx.Done(); in Hopac it is an IVar (Hopac’s write-once variable) that is filled to stop the jobs. Bjolang is measured twice as well: with events and sync, and with chan-put and chan-get, so the difference between the two Bjolang columns is what building an event and syncing it costs. The best of five runs, in nanoseconds per operation, on a Ryzen 9 5900X under Linux with .NET 10 and Go 1.25:
| Benchmark | Bjolang, sync | Bjolang, chan-put | Go | Go, ctx.Done() | Hopac | Hopac, cancel IVar |
|---|---|---|---|---|---|---|
| Ring: 1000 fibers, a million messages | 70 | 73 | 117 | 241 | 51 | 3487 |
| The same ring, one cancel IVar per job | — | — | — | — | — | 195 |
| The same ring inside a nested scope | 63 | — | — | 242 | — | 3427 |
| Spawning a million fibers and waiting | 210 | — | 190 | — | 80 | — |
| An 8-way choose where one branch fires | 181 | 205 | 403 | 451 | 127 | 136 |
In the choose row only the sender can use chan-put, since the receiving side has to use channel-receive (and thus allocates). On the ring, syncing an event costs nothing measurable any more: a send or a receive synced alone does not build an event. For some reason, this ring inside a nested scope is faster in bjolang, and I haven’t figured out why yet.
Against Go, Bjolang is ahead on the ring and the choose, and further ahead once Go has to watch ctx.Done(), which doubles its ring. Go spawns a little faster. Against Hopac it is the other way round without cancellation: Hopac’s ring and choose take about 30% less time than Bjolang’s, and its spawns about a third. Bjolang’s numbers include watching the scope’s token at every operation, which Hopac’s plain rows do not do. Once cancellation is added, the ring turns. A thousand jobs racing one shared IVar all take that IVar’s lock at every operation, and the ring slows to three and a half microseconds a message. Giving each job an IVar of its own brings it back to 195 ns, at the price of keeping track of a thousand IVars and filling all of them to stop the ring. The choose does not turn: with a cancel IVar Hopac’s takes 136 ns, against Bjolang’s 181.
The spawn row is about twice what it was with the scheduler Bjolang used before, which handed every fiber to the .NET thread pool, and the choose row about one and a half times. With it (BJO_SCHEDULER=pool in the environment, kept as a way back) a spawn costs 105 ns and the choose 113. The spawn row is a single fiber spawning a million fibers that do nothing, and it is slow only on many cores: on two or four cores the current scheduler spawns faster than the pool, 61 and 72 ns against 87 and 112. The scheduler that replaced it runs fibers from run queues of its own, in the shape of Go’s, because the pool let work that had waited longest wait longer still under load. That is worth more in a loaded service, as the service benchmark below shows, than these two rows on an idle machine.
These are small benchmarks on one machine, and they only measure the cost of the concurrency primitives, not of the work between rendezvous. They do not show what each operation allocates either. A Go channel operation never allocates and a goroutine costs 16 bytes. A Bjolang send or receive synced alone allocates nothing, a choose 56 bytes and a spawn 136. Hopac allocates 208 bytes a message on the ring, 424 a choose and 184 a spawn. Go is ahead in other ways too: any Go function can block without being written as a bjoroutine and the Go scheduler preempts a goroutine that computes for too long, where a fiber has to be moved off the pool with spawn/thread.
What Bjolang offers that Go does not is in the CML vocabulary rather than in the numbers. A Go select is a statement, its cases are fixed in the source, and a channel operation cannot be passed to a function, wrapped, or combined with others. In Bjolang an event is a value, which is what made within above a two-line function that works on any event, and choose, wrap, guard and with-nack build new events out of old ones. Go has no structured concurrency either. Waiting for goroutines, stopping them, and reporting their failures are a sync.WaitGroup, a context.Context passed to every function and checked at every channel operation, and an errgroup, written by hand each time. A goroutine that nothing waits for leaks without a sound. In Bjolang a scope does all of that by construction: it waits for its fibers, raises their failures when it ends, releases the files they opened, and cancels them through a token that every sync watches without being asked. Hopac can run jobs in parallel and wait for all of them, but a job cannot be stopped from the outside, so stopping jobs when one of them fails, or when their owner is done with them, is the same hand-written race against an IVar as in the table.
2.5.9 A service under load↑
The rows above time one primitive at a time on an idle machine. bench/service is closer to a program: a sharded key-value store in all three languages, with 16 shard fibers that each serve a part of the keys, and clients that send gets, puts and four-key multi-gets, each with a fresh reply channel and a one-second deadline raced against the reply. The three programs do the same work and agree on a checksum. bench/service/drive.py runs them and takes the median of 15 reps.
The two .NET programs run with the server GC, which keeps a few hundred megabytes and collects a few times a rep. Go’s collector keeps the heap small and collects about twenty times as often, so Go runs twice: with its default, and with as much memory as the .NET programs took (GOGC=off and GOMEMLIMIT at their peak).
In a closed loop, 1000 clients send 1000 requests each, each one as soon as the last is answered, so the machine is always saturated. Wall time, CPU time, p99.9 latency and peak memory:
| Program | all cores: wall | CPU | p99.9 | memory | 4 cores: wall | CPU | p99.9 |
|---|---|---|---|---|---|---|---|
| Go | 397 ms | 5552 ms | 5.7 ms | 27 MB | 475 ms | 1832 ms | 3.4 ms |
| Go, same memory | 258 | 4076 | 3.2 | 364 | 414 | 1641 | 3.5 |
| Hopac | 411 | 8903 | 9.8 | 387 | 296 | 1161 | 4.2 |
| Bjolang | 254 | 5211 | 5.5 | 369 | 407 | 1571 | 4.7 |
On all cores Bjolang and Go with the same memory finish together, and Go uses a fifth less CPU and has the shorter tail. On four cores Hopac is clearly the fastest, and Bjolang and Go are close.
In an open loop, 10000 clients send on a fixed schedule at a given rate, so the three are compared at the same load. Latency is taken from when a request was due, so a client that woke late, waited for a core, or slept through a GC pause counts that time too. p99 and p99.9 in milliseconds:
| Load | Go | Go, same memory | Hopac | Bjolang |
|---|---|---|---|---|
| 4 cores, 250k/s | 5.6 / 9.0 | 2.0 / 2.2 | 2.8 / 4.3 | 2.2 / 3.1 |
| 4 cores, 500k/s | 28.0 / 32.6 | 41.9 / 48.6 | 6.9 / 8.3 | 3.1 / 3.7 |
| all cores, 500k/s | 7.0 / 9.1 | 8.0 / 12.7 | 24.5 / 31.1 | 3.6 / 6.3 |
| all cores, 1M/s | 33.9 / 37.1 | 69.4 / 73.5 | 27.5 / 32.3 | 5.9 / 8.6 |
A client that is early sleeps until its request is due, in whole milliseconds rounded up, the same in all three programs, so every latency here has up to a millisecond of that in it, and below about 2 ms the numbers say little. At 250k/s on four cores the three are close, and Go with the same memory has the shortest tail. At every higher load Bjolang has the shortest, and at a million requests a second its tail is a quarter of Hopac’s or less, and a fifth of Go’s or less. Some rows move from run to run: over three runs at 500k/s on all cores, Go with the same memory had a p99 of 2.1 to 8.0 ms and Bjolang of 3.6 to 5.0, so that row is not settled. Go’s tail at 500k/s on four cores is long with either memory, and its time once a request has started is as long, so the delay is in running the requests, not before them. Hopac keeps all 24 cores busy on all cores at either rate, and why has not been looked into.
The tables in BjolangRuntime/Cml/design.md in the main repository have every column, including the time a request took once its client had started it.
2.6 Effects↑
Our deck is shuffled using shuffle-vec, which is non-deterministic. While randomness is ideal during gameplay, it makes automated tests brittle when verifying deterministic outcomes like trick winners. The traditional object-oriented or functional workaround is dependency injection—passing an explicit shuffle function down through every layer of the call stack.
Bjolang provides an alternative mechanism: *tail-resumptive algebraic effects*. With effects, a function simply declares and performs an abstract operation, allowing an enclosing caller higher up the call stack to dynamically decide how that operation is handled.
2.6.1 Declaring an effect↑
(import (std effect) "deck.bjo")
(defeffect Dealing
(shuffle-deck (-> (Vec Card) (Vec Card)))
#:default ((shuffle-deck shuffle)))
(: deal-hand (-> int (Vec Card)))
(defun (deal-hand n)
(vec-slice (shuffle-deck (full-deck)) 0 n))
shuffle-deck is exposed as a regular function matching the declared signature. When called without an active handler in the dynamic scope, it falls back to the default implementation—in this case, the real shuffle. In a test, you can override this behavior using with-handler:
(deal-hand 5) ;; five random cards
(with-handler ((shuffle-deck (fun (deck) deck)))
(deal-hand 5)) ;; [2♣ 3♣ 4♣ 5♣ 6♣], every time
The handler intercepts operations executed anywhere within the dynamic extent of the with-handler block, including inside deeply nested helper calls and child fibers, without requiring deal-hand to pass any parameters around.
Several key semantics govern effect handlers:
- Default implementations: If an effect omits
#:defaultand is performed without an active handler, the runtime raises an exception. Omit defaults for operations that always require explicit environmental context. - One-shot execution: Handlers in Bjolang behave like standard functions and return a single value. Full multi-shot delimited continuations are not supported: a handler cannot resume its caller multiple times or abort resumption except by throwing an exception.
- Effect coloring: Operations declared with
->can be invoked from any standard function. Operations declared with-bjo->may only be invoked from within bjoroutines, and their handlers must likewise be bjoroutines. - Handler composition: Invoking an operation from inside its own handler invokes that handler recursively. To delegate to an enclosing outer handler, retrieve it using
(handler-of op):
(with-handler ((log (let ((outer (handler-of log)))
(fun (s) (outer (str "[game] " s))))))
(log "dealing")) ;; "[game] dealing" on stderr
2.6.2 The prelude’s own effects↑
Several core runtime facilities in the prelude are implemented as effects out of the box, allowing you to intercept or virtualize them in tests without modifying application code:
| Operation | Default |
|---|---|
(log s) | Writes a line to standard error |
(warn s) | Writes a warning line to standard error |
(getenv name) | Reads an environment variable as an Option |
(monotonic-ms) | High-resolution monotonic clock for elapsed timing |
The FS operations | Physical filesystem operations |
Because all prelude file I/O routes through the built-in FS effect operations, tests can replace the entire filesystem with an in-memory mock using with-fake-fs (covered in the Testing section below).
Similarly, you can capture diagnostic output in memory instead of dumping it to the terminal by installing a custom handler for log:
(def lines (make-box (list)))
(with-handler ((log (fun (s) (box-set! lines (Cons s (box-ref lines))))))
(log "one")
(log "two"))
(box-ref lines) ;; '("two" "one")
Keep in mind that with-handler only establishes a dynamic binding; unlike a concurrency scope, it does not await background fibers. If code inside the handler spawns asynchronous fibers that emit logs, ensure the concurrency scope enclosing those fibers is placed *inside* the with-handler block so the handler remains active until all fibers finish writing.
2.7 .NET interop↑
Because Bjolang compiles directly to C#, you have full, seamless access to the entire .NET ecosystem. By declaring the CLR classes, methods, and properties you need along with their signatures, the compiler exposes them as ordinary Bjolang functions and types.
2.7.1 Methods: import/extern↑
For example, we can render suit symbols in color in the terminal using .NET’s System.Console:
(import "deck.bjo")
(import/class
(ConsoleColor (: System.ConsoleColor)))
(import/extern
(set-foreground! (: System.Console.ForegroundColor (-> ConsoleColor void) #:set))
(reset-colour! (: System.Console.ResetColor (-> void)))
(console-write (: System.Console.Write (-> string void))))
(: suit-colour (-> Suit ConsoleColor))
(defun (suit-colour s)
(match s
((or Hearts Diamonds) ConsoleColor.Red)
((or Clubs Spades) ConsoleColor.Blue)))
(: print-card (-> Card void))
(defun (print-card c)
(set-foreground! (suit-colour (record-ref c suit)))
(console-write #"c ")
(reset-colour!))
(defun (main)
(vec-for-each print-card (vec-slice (shuffle (full-deck)) 0 5))
(println "")
0)
Each entry in import/extern specifies a local Bjolang identifier, the fully qualified CLR member, and its signature. For overloaded methods (such as Console.Write), the compiler determines which overload to bind based on the provided signature. Instance methods accept the target object as their first parameter. Properties and fields can be accessed via #:get and modified with #:set:
(import/extern
(trim (: System.String.Trim (-> string string)))
(max-int (: System.Int32.MaxValue #:get)))
(trim " hi ") ;; "hi"
max-int ;; 2147483647
.NET’s own string, System.String, is UTF-16 and a different type from string. A .NET method that takes or returns one, or an array of them, is converted where it is called, so trim above takes and gives a Bjolang string; a null from .NET arrives as "". A .NET string inside another type, such as a (Seq System.String), is not converted: string->clr-string and clr-string->string do it by hand.
Avoid binding an external method to the name of an existing prelude function. Locally bound names and prelude symbols take precedence over import/extern declarations, which means an alias named println would simply be shadowed and ignored.
2.7.2 Classes: import/class↑
The import/class form maps a .NET class to a Bjolang type, exposing its constructor as a function suffixed with a dot (e.g. StringBuilder.). CLR enums map similarly, with individual members accessed using dot notation like Type.Member (seen with ConsoleColor.Red above).
(import/class
(StringBuilder (: System.Text.StringBuilder (-> StringBuilder))))
(def sb (StringBuilder.)) ;; new StringBuilder()
(ignore (.Append sb "hello, "))
(ignore (.Append sb "world"))
(.ToString sb) ;; "hello, world"
(.-Length sb) ;; 12
(.ToUpper "shout") ;; "SHOUT"
A method that string does not have, like ToUpper, is called on the string converted to .NET’s, so its indices and lengths are .NET’s UTF-16 ones and not string-length’s bytes.
As shown in the example above, you do not need to declare every method up front with import/extern. Once an object’s type is known to the type checker, you can invoke any public method using (.Method obj args ...) and inspect properties using (.-Property obj). Because Append returns the builder itself and Bjolang enforces explicit handling of return values, discard unused returns intentionally using ignore.
2.7.3 When .NET fails↑
Idiomatic .NET code reports errors by throwing exceptions. Bjolang provides three distinct ways to translate these exceptions into safe value types:
- Generic
tryblocks: Wrap arbitrary code intry, as introduced in Part 1. - Exception-mapped constructors: Annotating a constructor with
#:exceptionsinimport/classcauses the constructor to return aResultinstead of throwing on specified exceptions. - Output parameter conversion: Common .NET methods adhering to the
bool TryParse(..., out T result)pattern automatically transform into Bjolang functions that return an(Option T).
(import/class
(Uri (: System.Uri (-> string Uri)
#:exceptions (System.UriFormatException))))
(import/extern
(parse-int (: System.Int32.TryParse (-> string (out int) (Option int)))))
(match (Uri. "https://example.com/cards")
((Ok u) (.-Host u)) ;; "example.com"
((Err e) (.-Message e)))
(parse-int "42") ;; (Some 42)
(parse-int "forty-two") ;; None
2.7.4 Async methods, and blocking calls↑
A .NET method returning a Task or Task<T> can be imported using the #:async modifier. Calling an async method from within a bjoroutine suspends the fiber until the task completes without blocking an OS thread, with no explicit await required:
(import/extern
(read-text-async (: System.IO.File.ReadAllTextAsync (-> string string) #:async)))
(defbjo (main)
(println (string-length (read-text-async "deck.bjo")))
0)
Conversely, CLR methods known to perform synchronous, thread-blocking I/O should be tagged with #:blocking. Calling a blocking method from within a bjoroutine triggers a compiler warning; you can resolve this warning by offloading the call to a dedicated thread pool thread via (sync (blocking (fun () ...))).
2.7.5 Generics↑
Generic .NET types are imported by parameterizing them with type variables, and generic methods specify those type variables in their signature:
(import/class
((Dict %k %v) (: System.Collections.Generic.Dictionary)))
(import/extern
(dict-try-get (: System.Collections.Generic.Dictionary.TryGetValue
(-> (Dict %k %v) %k (out %v) (Option %v)))))
At the boundary between systems, Bjolang types map cleanly to standard .NET equivalents: a Func<A, B> corresponds to (-> %a %b), IEnumerable<T> maps to (Seq %a), C# tuples map to Tuple, and native arrays T[] correspond to (Array %a). Note that constructing generic .NET classes dynamically and pattern matching on .NET objects via constructor patterns are not currently supported.
2.7.6 Culture and formatting↑
Bjolang programs execute using the invariant culture by default. For example, floating-point numbers format with a decimal point (2.5), avoiding regional discrepancies on machines where local culture settings would otherwise produce 2,5. If your application requires localized formatting, you can configure CultureInfo.CurrentCulture explicitly.
2.7.7 Calling Bjolang from C#↑
Every Bjolang module compiles to a public static class named <module>_Module, exposing top-level functions defined with defun as static methods and def values as static fields. Identifiers with characters invalid in C# are automatically mangled (for example, hyphenated names like full-deck become fullsubdeck), while collections remain Bjolang’s persistent data structures. You can inspect the emitted C# source code directly by passing the debug flag: bjo build -d, which generates out.cs.
2.8 Macros↑
Macros are functions executed by the compiler during the expansion phase. A macro receives an unevaluated syntax tree as input data and returns a new syntax tree that replaces the invocation form. In fact, many constructs in Bjolang that appear to be built-in language keywords—such as cond, when, type/derive, and with-test—are actually implemented as standard library macros.
2.8.1 def/macro↑
Macros are defined using def/macro. A macro transformer accepts three parameters: form, inject, and compare. Most macros are written using pattern matching via syntax-match from the (std syntax-match) library:
(import (std syntax-match))
;; (with-each-card (c deck) body ...) runs the body once for every card.
(def/macro (with-each-card form inject compare)
(syntax-match form
((_ (name deck) body ...)
#'(vec-for-each (fun (,name) ,@body) ,deck))
(bad (syntax-error bad "(with-each-card (name deck) body ...)"))))
The syntax-match form decomposes syntax by structural shape. A wild-card pattern _ matches the macro name itself, variable names bind matched syntax fragments, and ellipsis ... matches repeated sequences. Templates are constructed using syntax quotation #'(...), where comma ,x unquotes an individual syntax value and ,@xs splices a list of syntax objects into the form. Malformed invocations can be rejected at compile time using syntax-error, which highlights the offending source location.
Pattern fragments like ((name value) ...) match lists of pairs and bind name and value to lists of syntax elements. Quoted symbols such as 'else match that literal identifier.
Because macro transformers are compiled code executed directly by the compiler while reading subsequent source files, a macro cannot be used in the same module where it is defined:
'twice' is a macro defined in this module, and a macro cannot be used where it is defined,
at bad2.bjo:5. Its transformer runs inside the compiler, so it has to be compiled before
whatever uses it is read — which cannot be true of the file it is written in. Move it to a
module of its own and import that. An (include ...) will not do: an included file becomes
part of this one.
For our card game, we place macro definitions in a dedicated module, cardmacros.bjo. Importing that module with (import "cardmacros.bjo") automatically makes its macros available. Unlike functions, macros do not require an explicit export list—every macro defined in a module is exported automatically.
The input passed to a macro is a value of type Syntax, a union type with variants like (SSym s) for symbols, (SInt text) for numbers, and (SList items) for parenthesized forms. Because macros are ordinary Bjolang functions, you can invoke helper functions, inspect Syntax records directly, and use recursion.
2.8.2 Hygiene and inject↑
Bjolang macros are hygienic by default. Identifiers introduced within a syntax template are automatically renamed to ensure they never collide with local bindings in the calling scope:
;; (or-else a b) is a, unless a is 0.
(def/macro (or-else form inject compare)
(syntax-match form
((_ a b) #'(let ((tmp ,a)) (if (= tmp 0) ,b tmp)))))
;; in another module
(def tmp 9)
(or-else 0 tmp) ;; 9, not 0
Here, the template’s temporary variable tmp and the caller’s outer variable tmp are treated as two distinct symbols, allowing the expression to evaluate correctly to 9. Furthermore, free identifiers referenced in the template (such as let, if, and =) always resolve to their prelude definitions, even if the calling scope has shadowed those names locally.
If a macro intentionally needs to introduce an unhygienic binding into the caller’s environment, it can explicitly opt out of renaming using (inject 'name):
;; (aif test then else): in `then`, `it` is what was inside the Some.
(def/macro (aif form inject compare)
(syntax-match form
((_ test then else)
#'(match ,test
((Some ,(inject 'it)) ,then)
(None ,else)))))
(aif (map-try-ref #map(("a" 1)) "a")
(println #"found it")
(println "nothing"))
2.8.3 Splicing multiple definitions: begin↑
A macro returns a single syntax form. If you need a macro to expand into multiple definitions or expressions at the top level, return a (begin ...) form, whose child elements are spliced directly into the enclosing scope:
;; (def/counter name) defines a function that counts how often it is called.
(def/macro (def/counter form inject compare)
(syntax-match form
((_ name)
#'(begin
(def/mutable count 0)
(: ,name (-> int))
(defun (,name)
(set! count (+ count 1))
count)))))
;; in another module
(def/counter next-id)
(next-id) ;; 1
(next-id) ;; 2
In this expansion, the internal counter variable count is generated hygienically and cannot collide with any count variable in the importing module, while next-id takes on the identifier provided by the caller.
2.8.4 Pattern macros: def/pattern↑
The def/pattern form defines macros intended for use within pattern-matching positions rather than ordinary expressions. Nullary pattern macros can be referenced simply by their bare capitalized name:
;; Face matches a jack, a queen or a king.
(def/pattern (Face form inject compare)
(syntax-match form
(_ #'(or Jack Queen King))))
;; in another module
(: court? (-> Card bool))
(defun (court? c)
(match (record-ref c rank)
(Face #t)
(_ #f)))
Pattern macros expand before the compiler performs exhaustiveness checking, meaning the compiler inspects the expanded or pattern just as if it had been written by hand.
2.8.5 Hash macros: def/hash-extend↑
Literal prefixes like #map(...) are reader-level hash macros. You can register custom hash literals using def/hash-extend. For example, we can implement a custom literal syntax for playing cards, converting ranks and suits into their typed constructors:
(import (std syntax-match) "deck.bjo")
(re-export Card Rank Suit)
;; A number is a Pip, a letter a court card.
(: rank-syntax (-> Syntax Syntax))
(defun (rank-syntax r)
(syntax-match r
('J #'Jack) ('Q #'Queen) ('K #'King) ('A #'Ace)
(n (match n
((SInt _) #'(Pip ,n))
(_ (syntax-error n "a rank is 2 to 10, J, Q, K or A"))))))
(: suit-syntax (-> Syntax Syntax))
(defun (suit-syntax s)
(syntax-match s
('clubs #'Clubs) ('diamonds #'Diamonds) ('hearts #'Hearts) ('spades #'Spades)
(bad (syntax-error bad "a suit is clubs, diamonds, hearts or spades"))))
;; #card(Q hearts) is (Card (rank Queen) (suit Hearts)).
(def/hash-extend (card form inject compare)
(syntax-match form
((_ r s) #'(Card (rank ,(rank-syntax r)) (suit ,(suit-syntax s))))
(bad (syntax-error bad "#card takes a rank and a suit: #card(Q hearts)"))))
(import "deck.bjo" "cardmacros.bjo")
(defun (main)
(println #card(Q hearts)) ;; Q♥
(println #card[10 spades]) ;; 10♠, either bracket works
(println (court? #card(K clubs))) ;; #t
(with-each-card (c [#card(A spades) #card(2 hearts)])
(println #"a card: c"))
0)
Invalid literal syntax is flagged with a compile-time error at the point of use:
The hash macro 'card' failed at bad.bjo:3: a suit is clubs, diamonds, hearts or spades — in hurts
Notice the re-export in cardmacros.bjo: because the macro expansion emits constructor symbols like Card and Queen, those types must be resolvable in the module invoking the macro. Re-exporting them ensures that importing cardmacros.bjo alone brings the required types into scope. Importing deck.bjo alongside it remains recommended so that the corresponding Displayed implementations are also present.
2.8.6 Literals read by a grammar↑
Many embedded domain-specific languages do not need macros. A grammar says how a quoted literal of a type is written, and declares the type. Where the type checker expects the type, a quoted literal becomes its constructors:
(def/grammar Action
(:or (: Play (play int))
(: Say (say string))
(: Pass 'pass)))
(: script (List Action))
(def script '((play 3) (say "your turn") pass (play 1)))
;; = (list (Play 3) (Say "your turn") Pass (Play 1))
(def n 7)
(def (: more (List Action)) '((play ,n) pass))
Action is an ordinary union, and match takes its cases. The type checker reads the literal, so a misspelled keyword or a wrong number of items is a compile error that says what can come at that place:
Type Error at elab2.bjo:5: Action: expected (play ...), (say ...) or `pass`; found (sya ...).
Type Error at elab2.bjo:5: (play ...): expected the end of the list; found the number 2.
A grammar reads a literal only where the type checker already expects one of its types, which is why script and more have signatures. A grammar can also have several productions, items in any order with :each, optional items, repeats with ... and ...+, parameters, and atoms that a function checks when the literal is compiled. (std run), (std fmt) and (std url) use grammars for pipelines, layouts and URLs. The language reference describes every form: see Docs/Syntax.org and Docs/Grammar-design.org in the Bjolang repository.
2.8.7 Computing a value once↑
A macro often builds a value out of constants, and that value is then built again every time the expansion is evaluated. When that is expensive, wrap it in please-hoist-i-promise-i-am-not-naughty: the form is evaluated the first time it is reached, and every later evaluation gets that same value. This is what #rx(...) does, so a regex is compiled once rather than once per match.
;; (tag-set "a" "b" ...) is a HashSet of the strings, built once.
(def/macro (tag-set form inject compare)
(syntax-match form
((_ tag ...)
#'(please-hoist-i-promise-i-am-not-naughty
(list->hashset (list ,@tag))))))
If computing the value throws, nothing is kept, and the next evaluation tries again. The same form written in two places is one value.
The long name is a promise about what the compiler cannot check: that the form has no side effects worth repeating, and that it does not depend on things that change from call to call, such as a parameter or an effect handler. Everything it can check, it does, and a form that breaks a rule is a compile error that lists every reason. The form cannot:
- refer to a local variable or a function parameter;
- read a
defof the module it is in, or anydef/mutable; - use a type variable of the function around it;
- call a bjoroutine,
yieldto a sequence, or leave awith-return; - be void, or produce something that can be changed in place, such as an array, a box or a mutable collection.
It may use literals, functions, and the defs of other modules. A macro that puts its caller’s code inside the form passes these rules on to its caller, so keep the form to parts the macro built itself.
2.8.8 One expansion for both colours: by-colour↑
A macro does not know if its expansion will land in a bjoroutine or in ordinary code. When the two need different code, the expansion writes (by-colour (#:sync body ...) (#:bjo body ...)), and the compiler keeps the body for the colour of the code the expansion is in. This is how with-scope works in both colours: it ends the scope with a suspending wait in a bjoroutine and with a parking wait in ordinary code.
;; (await ev) syncs on ev, in code of either colour.
(def/macro (await form inject compare)
(syntax-match form
((_ ev) #'(by-colour (#:sync (sync/blocking ,ev))
(#:bjo (sync ,ev))))))
The colour is the colour of the innermost of these:
| Where | Body |
|---|---|
defun, (fun ...), (seq ...), top-level def | #:sync |
defbjo, (bjoroutine ...), (bjo ...) | #:bjo |
defbjouble | the body it is in |
a defun inside a body | the body around it |
A defun with a by-colour in it gets a suspending copy, as a defun that calls file-read-text does, and a bjoroutine calls that copy. If the #:bjo body never suspends, the copy is dropped, and the #:sync body runs everywhere. So write a #:bjo body that suspends and a #:sync body that does the same work without suspending.
A lambda applied where it is written, ((fun () body)), is replaced by its body before colours are found. A by-colour in it has the colour of the code around it.
2.8.9 Macro limitations↑
When designing macros, keep the following constraints in mind:
- Macros cannot be invoked within the module that defines them; they must be imported from a separate module.
- Macros can only appear in expression or definition positions (or in pattern positions when defined with
def/pattern). They cannot be used as loop clauses, let bindings, or type annotations. - Macros cannot override built-in special forms; a macro named
ifwill never shadow the compiler primitive. - Macros cannot inspect types, as macro expansion occurs prior to type checking.
- Macros are not first-class values and cannot be passed as function arguments.
- Macros cannot inject imports into the calling module; any imported dependencies must be declared explicitly in the consuming file.
2.9 Testing and the REPL↑
2.9.1 Tests with (std simpletest)↑
Bjolang includes a lightweight test harness in (std simpletest). Its core assertions are straightforward: (expect label actual wanted) checks equality between values, (expect-true label ok) asserts a boolean condition, and (fail label) explicitly flags an unreachable branch. Passing checks output ok: label, while test failures emit descriptive FAILURE: diagnostics:
;; tests/deck-test.bjo
(import (std simpletest) (cardlib deck))
(defun (main)
(expect "a deck has 52 cards" (vec-length (full-deck)) 52)
(expect "an ace is worth 11" (card-points (Card (rank Ace) (suit Spades))) 11)
(expect-true "shuffling keeps the cards" (= (vec-length (shuffle (full-deck))) 52))
0)
$ bjo run tests/deck-test.bjo
ok: a deck has 52 cards
ok: an ace is worth 11
ok: shuffling keeps the cards
Whenever an assertion fails, the output reports both the computed value and the expected value, each as written gives it: FAILURE: deliberately wrong gave 2 but wanted 3, or gave "4" but wanted "5" for strings. So expect needs a Written for the type, which every type has.
For asynchronous or resource-intensive testing, (with-test "name" body ...) executes the test body within an isolated structured concurrency scope configured with a default deadline. Any background fibers spawned during the test are automatically awaited, and the test fails if unclosed file ports remain open when the scope exits. Because with-test forms a concurrency scope, it must be invoked from within a bjoroutine.
You can also mock filesystem interactions using (with-fake-fs ((path contents) ...) body ...), which redirects file operations to an in-memory map for the duration of the enclosed block. Here is how we can verify our earlier high-score tracking functions without touching real files on disk:
(import (std simpletest) (std effect) "scores.bjo")
(defbjo (main)
(with-test "no file means no scores"
(with-fake-fs ()
(expect "empty" (read-scores) [])))
(with-test "a bad line is skipped"
(with-fake-fs (("scores.txt" "ada 31\nnonsense\nbo 27\n"))
(expect "two scores" (read-scores) [(Tuple "ada" 31) (Tuple "bo" 27)])))
(with-test "a saved score reads back"
(with-fake-fs (("scores.txt" "ada 31\n"))
(save-score "cy" 12)
(expect "appended" (vec-length (read-scores)) 2)))
0)
Because with-fake-fs is implemented via algebraic effects, test modules using it must also import (std effect). In standard project layouts, test files live in tests/ and can be executed individually using bjo run tests/<test-name>.bjo.
2.9.2 The REPL↑
You can launch an interactive Read-Eval-Print Loop by running bjo repl. When invoked inside a project directory, the REPL automatically discovers and allows importing any of the project’s local modules. At a terminal the REPL edits its own lines and keeps a history across sessions. Pressing Enter while a bracket is still open starts a new line, so a long form can be written and edited over several lines. Submitting an expression evaluates it and prints the result, while entering definitions compiles them and prints the bound names:
bjo> (+ 1 2)
3
bjo> (import "deck.bjo")
bjo> (vec-slice (full-deck) 0 2)
[(Card (rank (Pip 2)) (suit Clubs)) (Card (rank (Pip 3)) (suit Clubs))]
bjo> (displayed (vec-slice (full-deck) 0 3))
"[2♣ 3♣ 4♣]"
bjo> (defun (double (: x int)) : int (* x 2))
double
bjo> (double 21)
42
The REPL shows a result with written, as the reader reads it: a string in quotes, a card as it is built. println and displayed give the text a person reads.
Unlike many Lisp REPLs, Bjolang’s REPL does not interpret forms. Instead, each entered interaction is compiled on the fly into an in-memory assembly using the exact same compiler pipeline applied to files on disk, ensuring identical semantics between interactive sessions and compiled production builds.
Top-level functions declared in the REPL require explicit type signatures, mirroring the rules for modules. You can supply parameter and return types inline directly on the defun, or submit a standalone signature on its own line immediately preceding the definition:
bjo> (: triple (-> int int))
bjo> (defun (triple x) (* x 3))
triple
Redefining a symbol shadows the previous definition for subsequent interactions; however, any existing code already compiled against the earlier version continues to invoke the original binding:
bjo> (defun (f (: x int)) : int (+ x 1))
f
bjo> (defun (g (: x int)) : int (f (f x)))
g
bjo> (defun (f (: x int)) : int (* x 100))
note: f shadows the one from entry 1. Anything already compiled against that one still calls it.
f
bjo> (g 10)
12
The same incremental compilation model applies to trait implementations (impl): an implementation entered at the prompt is visible to subsequent entries, but cannot retroactively modify calls compiled earlier.
:show name prints the documentation of a name you can use at the prompt: what it is, where it comes from, how it is called, and the text its author wrote in a (:doc ...) form. It then lists the other usable names containing the text, in any case, with their modules and one-line summaries. Only names that an entry typed now could use are considered, that is builtins, names your imports bring in, and names defined at the prompt. So :show random-bool prints nothing until (std random) is imported:
bjo> :show random-bool
bjo> (import (std random))
bjo> :show random-bool
random-bool: function from (std random)
(random-bool #:rng (parameter-ref current-random))
: (-> (#:rng RandomSource) bool)
#t or #f, equally likely.
#:rng The source to draw from.
returns One random bit. They are drawn 64 at a time.
See also: random-chance?
bjo> :show random-u
random-u32 (std random) 32 random bits.
random-u64 (std random) 64 random bits.
Import modifiers apply as they would in an entry: after (import (prefix (std random) "r/")) the name to ask about is r/random-bool, and :show says that it is random-bool from (std random). A name with no documentation is shown with where it comes from and “with no doc”.
Tab completes the name at the cursor from the same names, together with the special forms and the names the entry you are typing has already bound. When only one name fits it is inserted, and when several do, the part they share is inserted and pressing Tab again opens a menu showing each name’s signature and summary. Inside an import, Tab completes module paths instead: (import (std ra becomes (import (std random.
The same completions are available as a command, :complete text, which prints the names that could be written at the end of text, one per line followed by where each comes from. It is meant for editors rather than people: an editor can send the entry up to the cursor and offer what comes back.
bjo> :complete (random-in
random-int (std random)
Type :help to view available REPL commands, or press Ctrl-D (or type :quit) to exit.
2.10 Benchmarking↑
Sooner or later a program is fast enough everywhere except one place, and the question becomes how fast that place is, and whether a change made it better. Timing it with a stopwatch gives a number, but not one you can trust: the first calls run code the JIT has not optimized yet, a garbage collection can land in the middle of a measurement, and two runs of the same code on a busy laptop differ by more than most improvements do.
(std bench) measures in a way that holds up against all of that, and says how sure it is. There are three ways in:
time-it, for a quick answer at the REPL: one line, measured properly.- Benchmark files, with
def/benchand(run-benches), for the code you care about: a table, a menu, saved runs to compare with later. bjo bench FILE --against REF, for the question “did my change make this slower”: the file run against itself at another commit, the two versions measured in turns.
All three are measured the same way, which the section “How a benchmark is measured” below describes.
2.10.1 time-it↑
time-it measures a body where you write it and prints one line. It is in (std bench), and (std stopwatch) has it too:
bjo> (import (cardlib deck))
bjo> (import (std bench))
bjo> (time-it (shuffle (full-deck)))
(shuffle (full-deck)): 1.06 µs ± 0.7% (min 1.05 µs), 3.64 KB/call, 67 samples
The line gives the median time per call, its spread (the median absolute deviation, as a percentage of the median), the fastest sample, how much the body allocated per call, and how many samples it took. The body is warmed up, calibrated and sampled just as a benchmark is, with shorter time targets, so it is done in a second or two.
A body that takes more than a second the first time is not run again. That first run is the measurement, and the line says so. Here the body is a slow recursive Fibonacci:
(fib 44): 1.46 s (one cold run: a body over a second is run once)
#:times n and #:warm-up-rounds w ask for an exact number of runs instead: w untimed runs, then n timed ones, with no calibration. Use them when the body has an effect that has to happen a known number of times:
bjo> (time-it #:times 1000 (shuffle (full-deck)))
(shuffle (full-deck)): 3.82 µs (min 3.02 µs), 3.64 KB/call, 1000 runs
The median here is more than three times the first one: without a warm-up, the early runs are the JIT’s unoptimized code. That is the reason to let time-it decide how often to run a body.
2.10.2 A benchmark file↑
For code you want to keep measuring, write a benchmark file. Benchmarks belong in benches/ in a project, next to src/ and tests/. Here is one for the deck from cardlib, in benches/deck.bjo:
(import (cardlib deck))
(import (std bench))
(def/bench "full-deck" (full-deck))
(def/bench "shuffle"
#:setup ((deck (full-deck)))
(shuffle deck))
(def/bench "hand points"
#:params ((size [5 13 52]))
#:setup ((hand (vec-slice (shuffle (full-deck)) 0 size)))
(vec-fold (fun (c total) (+ total (card-points c))) 0 hand))
;; Sorting changes the deck it sorts, so every call gets a freshly
;; shuffled one, made outside the time.
(def/bench "sort a shuffled deck"
#:setup-every ((cards (vec->list (shuffle (full-deck)))))
(list-sort cards))
(def/bench "deal with vec-slice" #:group "dealing"
#:setup ((deck (shuffle (full-deck))))
(vec-slice deck 0 5))
(def/bench "deal with a loop" #:group "dealing"
#:setup ((deck (shuffle (full-deck))))
{vecing (vec-ref deck i) (:for i (range 0 5))})
(def/bench "a million hands" #:once 3
(loop (:for i (up-from 0 #:to 1000000))
(:acc total (summing (card-points (vec-ref (full-deck) (% i 52)))))
=> total))
(def/bench "deal from a stacked deck" #:skip "the cheat is not written yet"
(full-deck))
(run-benches)
It is laid out just like a test file. def/bench registers a benchmark where it is written, and (run-benches) at the end becomes the file’s main. The body of a benchmark is ordinary forms, like a function body, and the names its options bind are in scope in it. bjo bench builds every file under benches/ and runs it:
$ bjo bench --all
bench: 10 benchmarks, Release build, .NET 10.0.4, X64, 24 cores, gcServer=false
bench: AMD Ryzen 9 5900X 12-Core Processor, Fedora Linux 43
name size median ± min alloc/op gc/1k 0/1/2
full-deck 309 ns 0.3% 307 ns 1.70 KB 0.20/0/0 1 outlier
shuffle 558 ns 0.3% 555 ns 1.94 KB 0.23/0/0 4 outliers
hand points 5 4.67 ns 1.9% 4.52 ns 0 B - 9 outliers
hand points 13 11.3 ns 1.4% 10.7 ns 0 B - 7 outliers
hand points 52 46.2 ns 0.5% 45.5 ns 0 B - 1 outlier
sort a shuffled deck 3.63 µs 0.5% 3.60 µs 3.30 KB 1.00/0/0 9 outliers
deal with vec-slice 19.4 ns 0.2% 19.3 ns 152 B 0.02/0/0 3 outliers
deal with a loop 51.7 ns 0.7% 51.2 ns 656 B 0.08/0/0 4 outliers
a million hands 320 ms - 320 ms 1.62 GB 208/0/0
(324 ms, 320 ms, 320 ms)
deal from a stacked deck skipped: the cheat is not written yet
The first two lines say what the numbers were measured on, because a time is a fact about one machine. A Debug build, an attached debugger and a CPU frequency governor other than performance add a warning there. bjo bench always builds in Release, whatever -d says, since a Debug build measures code the JIT did not optimize.
The columns are:
| Column | What it says |
|---|---|
name, size | the benchmark, and a column for each of its parameters |
median | the median time per call. Not the mean: one collection can move a mean anywhere |
± | the median absolute deviation, as a percentage of the median: how much the samples agree |
min | the fastest sample, which is what a quiet machine converges to |
alloc/op | the bytes allocated per call |
gc/1k 0/1/2 | the collections of each generation per thousand calls (per call for a #:once benchmark) |
After the columns come the marks. noisy means a spread above 3%, unsteady means the warm-up ran out of time before the times settled, and outliers are samples far outside the rest. They are counted and shown, never dropped: a pause that happens once in fifty calls is part of what the call costs.
Each row is printed as soon as its benchmark is measured, so a long run can be read as it goes. At a terminal, a line below the table says where the run is: which benchmark, which phase, the median so far and an estimate of the time left. It is rewritten in place, and is left out when the output goes to a pipe or a file, under CI, and in an Emacs buffer.
2.10.3 How a benchmark is measured↑
You do not have to know this to use (std bench), but it explains its numbers.
- The body is kept. The value of the body’s last form is written to a sink the JIT cannot remove. A body that computes a value and throws it away is therefore not optimized away, and the sink allocates nothing.
- Calibration. The body is run in batches of
kcalls, andkgrows until a batch takes 10 ms. One sample is one batch, so the clock’s resolution does not matter even for a body of a few nanoseconds. - Warm-up. Batches are run until the medians of the last two windows of five batches agree within 2%, for at least half a second and at most one. The minimum is there because of the JIT. .NET recompiles a hot method into faster code only after a pause with no new compilation, and a body can look steady at the first, slow tier before the faster code arrives. A warm-up that runs out of time leaves the row marked
unsteady. - Samples. Before each sample a collection runs, so that one benchmark’s garbage is not collected in another one’s time. At least 20 samples are taken, for at least 2 s. While the median’s 95% interval is wider than ±1%, it takes more, up to 100 samples or three times the time. A noisy benchmark costs more time, and gets a more precise median for it.
Every benchmark of a file runs in the file’s one process, one after another, so what one benchmark compiled is there for the next. That is usually what you want: it is how the code runs in a program.
2.10.4 Setup, parameters, and the rest↑
The body of a benchmark should be the thing you want to measure and nothing else. The options of def/bench are about keeping everything else out of the time:
| Option | What it does |
|---|---|
#:setup ((x e) ...) | binds names once, outside the time, for the body to use |
#:setup-every ((x e) ...) | binds names again before every call, outside the time; for a body that uses up its input |
#:params ((p values) ...) | a row for each combination of values; the setups and the body use the parameters by name |
#:once n | for a body of seconds: n cold runs, timed one at a time, no warm-up |
#:group "g" | a heading in the menu, and what --compare-within compares within |
#:skip "why" | not run; the reason is shown |
#:setup is the one to reach for first. Its bindings are written like a let*’s, so a binding can use the ones before it, and the body uses them by name. In the example, shuffle measures shuffling a deck, not making one: deck is made once, before the warm-up, and every call shuffles that same deck.
#:setup-every is for a body that changes or uses up what it is given, such as a sort that sorts in place, or a body that consumes a list. Its bindings are made again before every call, so each call gets a fresh input, and each call is then timed on its own, with its setup outside the time and outside the allocation. Timing calls one at a time has a cost, because the clock is read twice per call. A body that takes less than a microsecond is marked, since its time is close to the clock’s resolution.
The two can be used together. What #:setup binds is made once, and #:setup-every can use it to make each call’s input. That is the way to keep an expensive input out of the time and still give every call its own copy:
(def/bench "sort a big shuffled deck"
#:setup ((cards (shuffle (repeat-deck 1000)))) ; 52 000 cards, made once
#:setup-every ((copy (vec->list cards))) ; a fresh list for each call
(list-sort copy))
#:params gives the parameters’ names and values, like a let. The values are a vec of any expressions, evaluated once. There is a row for every combination, in the order written, with the last parameter varying fastest. The setups and the body use the parameters by name:
(def/bench "sort"
#:params ((n [1000 100000])
(order ['sorted 'reversed 'random]))
#:setup ((cards (make-cards n order)))
(list-sort cards))
;; Without a setup, the body uses them directly.
(def/bench "fib" #:params ((n [10 20 30])) (fib n))
Here make-cards and repeat-deck stand for functions of your own.
Each combination is a benchmark of its own for everything else: it is warmed up, calibrated, saved and compared on its own. To try a size the file does not list, give it on the command line: --params n=500,5000 replaces the values of n for that run. A value is read as the parameter’s values are typed: an int, a double, a string, a symbol or a bool. Implementing the trait BenchParam lets other types be read too.
#:once n is for bodies too slow to run in batches. There is no calibration and no warm-up, just n runs timed one at a time, the first of them cold. Each time is shown under the row, as for a million hands above, and the median is the row’s.
2.10.5 Choosing what runs↑
Names on the command line choose benchmarks. A benchmark runs when its name contains one of them, and a parameter written the way the table shows it chooses rows:
bjo bench benches/deck.bjo shuffle # shuffle, and sort a shuffled deck
bjo bench benches/deck.bjo "hand points" size=52
bjo bench --list # the names, and nothing run
Note that shuffle also matches sort a shuffled deck: a name is a part of a name.
With no names, at a terminal, a benchmark file asks what to run:
$ bjo bench
Benchmarks in deck.bjo:
#0 full-deck
#1 shuffle
#2 hand points size: 5 13 52
#3 sort a shuffled deck
dealing
#4 deal with vec-slice
#5 deal with a loop
#6 a million hands
#7 deal from a stacked deck
Choose by number (0 2-4), by name (sort), with parameters (2 n=1000),
or * for all. Nothing quits, or repeats the last choice once there is one.
bench> 2 size=5,52
A choice is numbers, ranges of numbers (2-4), parts of names, or * for everything, in any mix and separated by spaces. A parameter after a benchmark narrows that benchmark to some of its values: 2 size=5,52 runs hand points at sizes 5 and 52 only. The groups and the parameters’ values are shown, so a choice can be made without opening the file.
After a run the menu asks again. Pressing Enter repeats the last choice, so you can change the code in another terminal and run the same benchmarks again with one key. q or the end of the input leaves.
With several files and no names, bjo bench makes one menu of all of them, numbered across the files under a heading for each, and runs each file with the part of the choice that is its own. Not at a terminal, when the output is redirected or CI is set, there is no menu: a run with no names runs everything, as --all does.
2.10.6 More numbers↑
--detail adds three lines under each row: the mean, the standard deviation and the quartiles; the samples in the order they were taken; and a histogram of them:
$ bjo bench benches/deck.bjo "hand points" --detail
name size median ± min alloc/op gc/1k 0/1/2
hand points 5 6.50 ns 6.6% 5.21 ns 0 B - noisy, 10 outliers
mean 6.69 ns, sd 1.02 ns, q1 6.04 ns, q3 6.90 ns, 100 samples of 1600000 calls
in order ██████▄▂▃▃▃▃▃▂▂▃▁▃▃▃▃▁▂▂▄▂▂▃▃▃▃▂▁▂▄▄▂▂▂▃▃▃▃▃▃▂▂▅▄▁▂▃▃▁▁▃▃▅▂▃
histogram 5.21 ns ▃▂▅▄▄▆█▇▅▂▃▂▂▂ ▁ ▃▂▂▂ 9.64 ns
The “in order” line is the one that explains a strange row. Here the first six samples were slower than the rest, and the spread of 6.6% and the noisy mark come from those six; the other 94 agree closely. A second run tells whether it was the machine or the code: run again, the same benchmark had a spread of 1.9%. A line that rises from left to right is a body that slows down as it runs, for example because its heap grows. The histogram shows the shape: two humps are two different speeds, not one noisy speed.
--scaling adds a column to a benchmark with one numeric parameter: how much the time grew from the row before, against how much the parameter grew.
$ bjo bench benches/deck.bjo "hand points" --scaling
name size median ± min alloc/op gc/1k 0/1/2 scaling
hand points 5 4.94 ns 5.4% 4.47 ns 0 B - noisy
hand points 13 9.99 ns 2.7% 8.41 ns 0 B - ×2.02 for ×2.60
hand points 52 41.5 ns 0.7% 40.5 ns 0 B - ×4.16 for ×4.00 3 outliers
From 13 to 52 cards the time grew by as much as the hand did, which is what a linear function does. From 5 to 13 it grew less than the hand, because a few nanoseconds of every call are not spent on the cards at all, and with five cards those nanoseconds are a large part of the time. A column that keeps growing faster than the parameter is the sign of something quadratic.
2.10.7 Which of two ways is faster↑
“Is dealing with vec-slice faster than with a loop, and by how much?” is a question about two benchmarks in the same file. Put them in a #:group, and run with --compare-within:
$ bjo bench benches/deck.bjo deal --compare-within
name median ± min alloc/op gc/1k 0/1/2
deal with vec-slice 20.0 ns 0.9% 19.0 ns 152 B 0.02/0/0 2 outliers
deal with a loop 47.3 ns 0.6% 46.8 ns 656 B 0.08/0/0 14 outliers
deal from a stacked deck skipped: the cheat is not written yet
bench: within dealing, against deal with vec-slice: 20 rounds in this process, paired (Wilcoxon signed-rank); 1 comparison
bench: seed 11 (--seed repeats the order of the cases)
name deal with vec-slice this change 95% interval p ratio verdict
deal with a loop 19.5 ns 47.0 ns +140.9% +140.0% .. +141.7% <0.001 ×2.41 SLOWER
After the usual table, the benchmarks of each group are measured again in rounds: every round takes one sample of each, in an order shuffled anew each round. Each one is then compared with the first of its group. Taking turns is what makes the comparison fair. If the machine gets slower halfway through, both sides get slower in the same rounds, and the comparison, which pairs the samples of a round, does not move.
The ratio is the number to quote. “A loop is 2.4 times as slow as vec-slice” is roughly true on the next machine too, where “47 ns” is not. -N 50 takes 50 rounds rather than 20, and --seed 11 repeats the order of an earlier run. The columns of the comparison, and how a verdict is reached, are explained in the next section.
2.10.8 Saving a run and comparing with it↑
--save FILE keeps a run: every sample of every benchmark, with the facts of the machine it was taken on. --compare FILE measures again and compares with what was kept. Nothing is saved unless you ask, and nothing is saved anywhere you did not name. Where a baseline lives, and whether it is committed, is up to you.
$ bjo bench benches/deck.bjo full-deck shuffle "hand points" --save before.bjodat
...
bench: saved to before.bjodat
The file is bjodat, a benchmark to a line, so a diff of two saved runs shows which benchmarks changed:
(bench-run
(version 1)
(saved "2026-10-16T17:25:06Z")
(machine "95c59c6948245e2e")
(header (build "Release") (dotnet ".NET 10.0.4") (arch "X64") (cores "24") ...)
(case (name "full-deck") (labels) (k 60000) (once #f) (bytes 1744.0) (samples [371.62 312.41 ...]))
...)
Now suppose full-deck is rewritten to collect the cards in a list and turn the list into a vec at the end:
(defun (full-deck)
(list->vec
(loop (:for s all-suits)
(:subloop)
(:for r all-ranks)
(:acc deck (listing (Card (rank r) (suit s))))
=> deck)))
The comparison prints the run’s own table, and then:
$ bjo bench benches/deck.bjo full-deck shuffle "hand points" --compare before.bjodat
...
bench: compared with before.bjodat, saved 2026-10-16T17:25:06Z; unpaired (Mann-Whitney U); 6 comparisons, Holm-corrected
bench: one process each: a difference between processes is not in the p-values
name size was now change 95% interval p verdict
full-deck 311 ns 597 ns +92.1% +91.7% .. +92.5% <0.001 SLOWER
shuffle 623 ns 548 ns -12.0% -12.0% .. -11.8% <0.001 faster
hand points 5 4.58 ns 5.07 ns +10.7% +10.1% .. +11.3% <0.001 SLOWER
hand points 13 10.6 ns 10.2 ns -4.2% -5.0% .. -3.7% <0.001 faster
hand points 52 44.2 ns 44.7 ns +1.0% +0.4% .. +1.4% <0.001 no change (< 2%)
sort a shuffled deck 3.68 µs 3.92 µs +6.8% +6.6% .. +7.0% <0.001 SLOWER
Each benchmark gets four answers:
- The change is the ratio of the two medians, as a percentage.
- The interval is a bootstrap of that ratio: the samples are resampled 10 000 times, and the middle 95% of the resampled ratios is shown. It makes no assumption about the shape of the timings, which are lopsided and have long tails.
- The p-value is the probability of a difference at least this large if nothing had changed, from the Mann–Whitney U test. A run that compares many benchmarks at once will show some differences by chance alone: with 40 benchmarks at a level of 5%, two are expected to look different when nothing changed. So the p-values are corrected for the number of comparisons, by Holm’s method, and the corrected value is the one shown and used.
--p-rawshows the uncorrected values beside them. - The verdict is
fasterorSLOWERonly when the corrected p is below 0.05 (--alpha) and the change is larger than 2% (--threshold). A change that is real but smaller isno change (< 2%): it is there, and it was decided not to matter. Anything else isno change.
The reason for asking for both is that a p-value says how unlikely a difference is, not how large it is: with enough samples, a change of 0.1% has a tiny p.
Now look at the table again. full-deck is 92% slower, as it should be: that is the change. But shuffle did not change at all, since its deck is made in its setup, outside the time. Yet it says faster, by 12%. The rows for hand points and the sorting moved too, and they never call full-deck in their time at all.
This is the weakness of comparing with a saved run. The two runs are two processes, and two processes of the same program can differ by a few percent, in every benchmark at once. Where the code ends up in memory, which core the process runs on, and what the JIT decided all differ from one process to the next. Within one run the samples agree to a fraction of a percent, so the test is very sure about a difference that is really between the processes. The report says this in its second line. Treat a saved-run comparison as a tool for large changes, like full-deck’s. For small ones, use --against with several process pairs, as described next.
A saved run from another machine is still compared, but under a warning above the table, and every verdict is marked (other machine): the numbers are shown, but no verdict is to be believed. Differences in the build, the .NET version, the garbage collector’s mode or the CPU governor are warned about too.
--fail-slower PCT makes the run exit with 1 when anything is SLOWER by more than PCT, for a CI job that guards a hot path. It does not apply against a run from another machine. A file that cannot be read stops the run with exit code 2 before anything is measured.
2.10.9 Two versions against each other: --against↑
The real question behind most benchmarking is “did my change make this slower?”. --against answers it in one run. It builds the benchmark file as it is and as it was at a git ref, and measures the two versions in turns:
$ bjo bench benches/deck.bjo full-deck shuffle "hand points" --against HEAD~1
bench: 6 benchmarks, Release build, .NET 10.0.4, X64, 24 cores, gcServer=false
bench: AMD Ryzen 9 5900X 12-Core Processor, Fedora Linux 43
bench: A/B against HEAD~1, 20 rounds, paired (Wilcoxon signed-rank); 6 comparisons, Holm-corrected
bench: one process per side: a difference between processes is not in the p-values (--processes P measures it)
bench: seed 42 (--seed repeats the order of the cases)
name size HEAD~1 now change 95% interval p verdict
full-deck 360 ns 649 ns +80.2% +73.9% .. +93.9% <0.001 SLOWER
shuffle 659 ns 616 ns -6.5% -63.1% .. +2.4% 0.097 no change
hand points 5 5.60 ns 5.46 ns -2.4% -5.5% .. +1.5% 0.046 faster
hand points 13 11.5 ns 12.8 ns +11.0% +5.7% .. +17.6% <0.001 SLOWER
hand points 52 47.2 ns 50.3 ns +6.6% +4.7% .. +10.9% <0.001 SLOWER
sort a shuffled deck 4.09 µs 4.14 µs +1.3% -4.5% .. +8.4% 0.50 no change
This is what happens:
REFis checked out in a temporary git worktree, which is removed afterwards, also when something fails. It can also be a path: another checkout, or another.bjofile.- Both sides are built in Release, by the same compiler. Both programs are started, and
bjo benchdrives them over a small protocol on their standard input and output. - Every benchmark both sides have is warmed up and calibrated on both, with the smaller of the two batch sizes, so that a sample of each is the same number of calls.
- Then come
-Nrounds, 20 by default. Each round takes one sample of every benchmark from each side, in an order shuffled from--seed, which is printed so that a run can be repeated. Which side goes first alternates every round, so that neither side always runs on a cache the other one warmed. - Each round’s two samples are a pair. The p-value is the Wilcoxon signed-rank test on the pairs, and the interval a bootstrap that resamples whole rounds. If the machine slows down during the run, both samples of a round slow down, and the comparison does not move.
A benchmark that only one side has is listed, not compared.
Look at the table, though. Rounds cancel a slowdown during the run, but not the difference between the two processes as a whole, and that difference is still there: hand points is called SLOWER by 11% at size 13, and it has not changed. The report says so in its fourth line. This is why there is --processes:
$ bjo bench benches/deck.bjo full-deck shuffle "hand points" --against HEAD~1 -N 60 --processes 6
bench: A/B against HEAD~1, 60 rounds, 6 process pairs (t-test on their log ratios); 6 comparisons, Holm-corrected
bench: seed 42 (--seed repeats the order of the cases)
name size HEAD~1 now change 95% interval p between processes verdict
full-deck 344 ns 614 ns +81.2% +72.4% .. +90.4% <0.001 4.7% SLOWER
shuffle 596 ns 585 ns -2.1% -10.3% .. +6.9% 0.73 8.3% no change
hand points 5 5.55 ns 5.13 ns -9.6% -18.7% .. +0.6% 0.24 10.1% no change
hand points 13 12.1 ns 11.4 ns -7.6% -20.8% .. +7.7% 0.73 14.7% no change
hand points 52 45.2 ns 45.0 ns -3.1% -10.5% .. +4.9% 0.73 7.6% no change
sort a shuffled deck 3.91 µs 4.04 µs +3.6% +0.5% .. +6.7% 0.15 2.8% no change
With --processes 6, the rounds are spread over six pairs of processes. Each pair gives one ratio of medians, and the test is a t-test on the six ratios, so a difference between processes is now part of what is tested rather than hidden from it. The “between processes” column says how large it was. For these tiny benchmarks it was 3 to 15%, more than any of the “changes” the single pair reported. Now only full-deck gets a verdict, which is the right answer.
So which to use?
| Question | Use |
|---|---|
| is it about this fast | time-it, or a benchmark file |
| which of two ways is faster, and by how much | #:group and --compare-within: one process |
| did my change make something a lot slower | --against REF, or --compare with a saved run |
| did my change make something a few percent slower | --against REF --processes 6 or more, and more rounds |
--against has a few more options. --against-warmup S and --against-sample MS set the warm-up limit and the sample length of both sides. A warm-up much shorter than the default second can mistake the JIT’s first tier for steady, and a benchmark that did not get steady is named in a warning. --against-build "CMD" builds the other side with a command of your own, for a side that is not built the way this one is. --save FILE keeps both sides’ samples: this code’s in FILE, and the other side’s beside it, with .against before the extension. --fail-slower, --alpha, --threshold and --p-raw work as they do for --compare.
A CI runner is one machine for the length of one job, which is exactly what --against needs. A job that guards performance runs bjo bench --against origin/main --processes 6 --fail-slower 5 and fails when something got more than 5% slower.
2.10.10 The flags↑
| Flag | What it does |
|---|---|
NAME | runs the benchmarks whose names contain it; n=10 chooses rows |
--all | runs every benchmark, without the menu |
--list | names the benchmarks and their parameters, and runs nothing |
--quick, --long | halves or quadruples the time targets |
--min-samples N | takes at least N samples (20) |
--max-time S | lets a noisy benchmark sample for up to S seconds |
--params P=V,... | replaces a parameter’s values for the run |
--detail | the mean, the deviation, the quartiles, the samples in order, a histogram |
--scaling | how the time grows with a numeric parameter |
--compare-within | compares each #:group’s benchmarks with its first, in rounds |
-N ROUNDS | the rounds of --compare-within and --against (20) |
--seed N | repeats the order of an earlier run’s rounds |
--save FILE | writes every sample of the run to FILE |
--compare FILE | compares with the run saved in FILE |
--alpha P | the significance level of a verdict (0.05) |
--threshold PCT | the smallest change a verdict counts (2%) |
--p-raw | shows the uncorrected p-values too |
--fail-slower PCT | exits with 1 when something is SLOWER by more than PCT |
--against REF | runs the file against itself at a git ref, or against a path |
--processes P | with --against: P pairs of processes (1) |
--against-warmup S | with --against: the warm-up limit of both sides (1 s) |
--against-sample MS | with --against: the sample length of both sides (10 ms) |
--against-build CMD | with --against: builds the other side with CMD |
A percentage can be written 2 or 2%. A benchmark file is an ordinary program, so bjo run benches/deck.bjo --quick shuffle runs it just as well. bjo bench adds the Release build, the files under benches/, the menu over several files and --against.
2.10.11 Driven by another program↑
--against works because a benchmark program can be driven. Started with --serve, it reads commands on its standard input and answers each with one line of bjodat on its standard output. Below, > marks what is sent and < what comes back:
> (list)
< (cases (case 0 (name "full-deck") (labels) (group "") (once 0) (skip "")) (case 1 (name "shuffle") (labels) ...) ...)
> (prepare 1 :warmup 1000 :sample 10)
< (prepared 1 :k 20000 :steady #t)
> (sample 1 :k 20000)
< (sample 1 :ns 13663495 :bytes 39680000 :gc (4 0 0))
> (sample 9 :k 1)
< (error "case 9 is not prepared")
> (quit)
prepare runs a benchmark’s setup and warms it up and calibrates it, as a run would. sample takes one sample of k calls, and :ns is the whole sample’s time. Anything wrong is answered with (error "why"), and the session goes on. A served program prints nothing else, so its output is the protocol and nothing more. A script that compares two builds made by two different compilers, which is not something bjo bench does by itself, can drive both of them this way, or use --against PATH --against-build "CMD".
2.11 Performance↑
Bjolang is compiled into pretty straight-forward c#, but without spans or ref structs. It performs pretty similar to c#, except for some small corners of the language that are different.
2.11.1 Strings↑
Strings in bjolang are utf8, as such they do not support random access. This means we navigate them differently, using string-cursors, which is explained in the part about strings. Navigating a string using cursors is slower than blasting through integer indices, and if your code needs to be really fast, there are some things you can think about:
- Use the provided search functions and the loop form. They work on raw memory, and can thus work around some of the limits that the safety of string-cursor give.
- use
string-cursor-next+refinstead of next and then ref. - some of the higher order functions are also faster, depending on use case. Benchmark and see.
- Use slices when copying is the bottleneck: an operation ending with
/slicewill return a view into the string instead of copying. This will retain the whole string in memory, so use it wisely.
When parsing strings with a known format, (std scan) will be a lot faster than (std rx) and the good old string-split. Often by an order of magnitude. The only cost is IL code size, since it builds the parser in the code, which will have other costs, such as no inlining.
2.11.2 loops↑
Most of the time you should use the loop form. It will be the fastest, and is usually flexible enough to do what you need. The only case is for hot loops (ones that are run often) that are performance sensitive. For those, using the various -fold, -for-each, and -iter functions will be faster for the collections that are tree-based. Those collections are: Vec, Map, HashSet, OrderedMap, and OrderedMap. The overhead of loop vs. fold is often something like 3x, with fold being as fast or slightly faster thana foreach on a C# List<T>. It does not, however, beat using indexed iteration over the List<T> due to the foreach doing some safety checks to detect concurrent mutation.
The absolutely fastest collection to loop over is, unsurprisingly, the Array. In bjolang it should be just as fast as in c# (again, without spans).
The loop form gets turned into a kind of goto soup. For the single level case, it is just as efficient as a c# while loop, but for really complex cases with many levels and subloops with clauses the JIT in dotnet may struggle. In that case you either live with the clarity and simplicity it gives, or you reach for the glorified gotos of names lets. This is probably not going to be a problem though. I tried to rewrite the worst loop form in bjolang (a 16 line monstrosity with one large multi-level claused subloop and a regular subloop) and there was no difference.
2.11.3 Choosing a collection↑
Bjolang has two pairs of collections that do mostly the same job: List and Vec, and Map and OrderedMap. Which one you pick matters less for small collections than you think, and more for large ones than you would hope. The tables below come from the benchmarks in bench/collections/ in the bjolang repository (run them with ./run.sh). They were measured on one machine with .NET 10 on Linux, so trust the ratios more than the exact times.
List or Vec
| Operation | List | Vec |
|---|---|---|
| memory per int | 32 B | about 7 B |
| first item | 1 ns | 2 ns |
| drop the first item | 1 ns | 21 ns |
| add at the front | 1 ns | 14–200 ns |
| add at the back | copies the whole list | 10 ns |
| last item, length | 1 ns per item | 1–2 ns |
| item at an index | 1 ns per item before it | 1–2 ns |
read every item (loop, fold) | 1 ns per item | 0.6 ns per item |
| build with an accumulator, 1000+ items | 1 | 5–8 times faster |
| map, reverse, append, slice, 100+ items | 1 | much faster |
| recursive match that binds the rest | 1 ns per item | 30 ns and 230 B per item |
| match the first items, ignore the rest | 2 ns | 2 ns |
Use a List when the work happens at the front and goes item by item: a stack, a result built with cons, or a recursive function that matches (Cons h t) and continues with t. That is what a list does in constant time, and there it is 3 to 30 times faster than a vec.
Use a Vec for everything else. It knows its length and last item, it reads any index in a couple of nanoseconds, it is faster to iterate, and it uses about a fifth of the memory. Some things to keep in mind:
- Build vecs with an accumulator (
vecinginloop, or a vec builder), not withvec-addin a loop. Eachvec-addcopies the tail of the vec, which makes avec-addloop 3–4 times slower than aconsloop. - Do not bind the rest of a vec in a recursive match.
[a rest ...]slices the vec at every step.[a b _ ...]ignores the rest and does not slice. To walk a vec, useloop, a fold or an index. list-ref,lengthand the last item of a list read the list from the start. 64 random reads in a list of 100,000 items take 3 ms. In a vec they take 240 ns.
Map or OrderedMap
| Operation | Map | OrderedMap |
|---|---|---|
| lookup, int keys | 1 | 1.6–3 times slower |
| lookup, string keys | 1 | 2–6.5 times slower |
| update, insert, remove | 1 | 1.2–1.9 times slower, 2× the allocation |
| build | 1 | up to 2.9 times slower |
| read every entry, 10,000+ entries | 1 | 2–6 times faster |
| smallest key | reads every entry | 2 ns |
| 64 entries in a key range | reads every entry | about 400 ns at any size |
| every key, in order | collect and sort | about 4 times faster |
Use a Map to look up, update, insert and remove by key. It wins at all of those at every size. The difference is largest for string keys, because a map hashes the key once and an ordered map compares it at every step down the tree.
Use an OrderedMap when the order of the keys matters: the smallest or largest key, the entries between two keys, or the entries in key order. A map can only answer those by reading every entry. An ordered map is also faster to iterate in full, so a program that walks a large map more often than it looks things up in it may prefer one.
2.11.4 Simd vectors↑
A CPU can do one operation on several numbers at once: 4 doubles, 8 ints or 32 bytes in one 256-bit register with AVX2. Bjolang does not do this for you, because the .NET JIT does not turn a loop into vector code. Two modules do it explicitly.
Try (std array) first. array-sum, array-dot, array-min, array-max, array-count, array-index-of, array-add! and array-scale! are each one vector loop in the runtime, with the tail handled for you. On 4 million elements a sum of doubles took 0.8 ms instead of 2.6 ms for a loop, and a sum of ints 0.21 ms instead of 0.88 ms.
For a loop that the kernels do not cover, write it with (std simd). A loop over an array has two parts: whole vectors, and the elements after the last whole vector, one at a time:
(import (std simd))
(: dot (-> (Array double) (Array double) double))
(defun (dot a b)
(def w (simd256-lanes a)) ; 4 doubles in a Float64x4
(def whole (simd256-whole a)) ; where the tail starts
(def acc
(loop (:for i (up-from 0 #:to whole #:by w))
(:acc v (folding (simd256-zero)
(simd-add v (simd-mul (simd256-load a i) (simd256-load b i)))))
=> v))
(+ (simd-sum acc)
(loop (:for i (up-from whole #:to (array-length a)))
(:acc s (folding 0.0 (+ s (* (array-ref a i) (array-ref b i)))))
=> s)))
A comparison gives a mask instead of a bool: (simd-lt v limit) has every bit of a lane set where the lane is less. Use simd-select to choose lanes by a mask, simd-count-true to count them, and simd-and and simd-or to combine masks. A branch in a scalar loop is often a select in a vector loop.
Some things to keep in mind:
- Use more than one accumulator in a sum. An addition waits for the one before it. On 4 million doubles the loop above took 2.9 ms, the same with four
Float64x4accumulators 1.4 ms, and a scalar loop 9.9 ms. - Put a margin of zeros round a grid. A load at a neighbour’s offset then needs no bounds test, and the zeros match nothing. Make each row longer than the grid by a whole vector, so that the last vector of a row does not read the next row.
- A vector loop pays when the work is dense and does not stop early. On Advent of Code 2025 day 4, a neighbour count over a 3000x3000 grid, the vectors were 9 times faster than the same loop one byte at a time. On day 9, a search that usually stops after a few comparisons, they were 1.6 times faster. On a small input, reading the input takes almost all of the time, and vectors change nothing. The
-avxsolutions inExamples/aocshow all three. - Use 128 bits for code that must run everywhere. A 256-bit vector is emulated on a CPU without AVX2, and on ARM64, and is then much slower than scalar code.
simd256-accelerated?says which you have. - A Native AOT build targets a baseline x86-64 CPU, without AVX2, unless the manifest says
(aot-cpu "native")or a level such as(aot-cpu "x86-64-v3"). The executable then does not start on a CPU without those instructions. A program that runs on .NET uses what the CPU has. - Shifts, conversions between element types and shuffles are not there yet.
3 The standard library↑
A short tour of the standard library. Each module’s full reference is its own page under Modules.
3.1 std/prelude↑
The prelude is what every module has without importing anything: the collections and their functions, strings and characters, files and ports, Concurrent ML and scopes, and the macros the language is written with, such as cond, if-let and type/derive. Its reference also covers the builtins, the functions compiled into the runtime rather than written in Bjolang, since every program gets them with the prelude. At the REPL, :show name prints the same documentation.
For more details, see (std prelude).
3.2 std/maths↑
Numbers beyond the operators. Integer division has Racket’s names: quotient and remainder are what / and % do, and modulo takes the sign of the divisor, so (modulo -1 10) is 9. There are gcd, lcm, an integer expt and an exact integer-sqrt at every integer type, the exact- roundings from a double to an int, and the rest of System.Math over doubles. The prelude has abs, min, max and clamp from here without an import.
For more details, see (std maths).
3.3 std/array and std/simd↑
Two modules for numbers in bulk, with the CPU’s vector instructions. (std array) has whole-array operations: array-sum, array-dot, array-min, array-max, array-add!, array-scale!, array-count, array-index-of and array-fill!. Each is one loop in the runtime, which works on as many elements per instruction as a vector register holds when the element is a primitive number, and one at a time for a type such as bigint. At double, a sum adds lane by lane, so its last bits can differ from a left fold. (std simd) has the vectors themselves, for the loops that the kernels do not cover. (Simd128 %a) and (Simd256 %a) are .NET’s Vector128<T> and Vector256<T>, with a name per element type: Float64x4 is four doubles, UInt8x32 is 32 bytes. The operations are the methods of one trait, Simd, at both widths: simd-add, simd-mul, the comparisons simd-lt and the others, which give masks, simd-select, simd-count-true, simd-sum and more. A vector is made with simd256-load, simd128-splat or simd256-zero, and simd256-whole says where the elements after the last whole vector start. A 128-bit vector is fast on every x86-64 and ARM64 CPU. A 256-bit vector needs AVX2, and simd256-accelerated? says whether the CPU has it. The section on performance in “Writing programs” says when vectors pay.
For more details, see (std array) and (std simd).
3.4 std/fmt↑
Formatting in two layers: interpolation and layout. println* prints strings, ints, doubles, chars and bools written in one vec. The layout layer is made of blocks: a Block is one piece of output, and every combinator takes its content as a block and gives back a block. A string, an int and a character are blocks, and so is a vec of blocks, such as ["x = " 7 nl]. With them you pad, cut and align text, wrap and justify paragraphs, format numbers, and lay out tables with borders, a header, and columns that fit the width. The section on formatting text in “Writing programs” is a guide, with what goes in the brackets and what to write when it does not compile.
For more details, see (std fmt).
3.5 std/rx↑
Bjolang provides a built-in regular expression engine. Unlike languages that use strings for regex (like "[a-z]+"), Bjolang uses structural S-expressions. This means you never have to double-escape characters, and the compiler validates your patterns at compile time. Built-in character sets and anchors are written as keywords (e.g., :bos, :digit), and operations that group or repeat patterns are function-like lists (e.g., (seq ...)).
For more details, see (std rx).
3.6 std/scan↑
Reading a line by a pattern, after OCaml’s Scanf. The pattern is a quoted list of what the line holds, in order: (scan "3-17" '(long "-" long)) is 3 and 17, and '(int "x" int ": " int ... #:sep " ") reads "12x5: 1 0 3" as two ints and a vec of them. There are items for numbers, a character, a word, the rest of the line, a run of given characters and the text up to one, with a width, a skip, a repetition, an option and a choice. The type of the result comes from the pattern, so '(int "," int) gives a (Tuple int int). The macro compiles the pattern into straight code over the line’s bytes, and a pattern of numbers and strings allocates nothing. The whole line must match. try-scan gives an Err that says at which column it failed, instead of raising. A scan does not search and does not backtrack: use (std rx) to find text in a line.
For more details, see (std scan).
3.7 std/http↑
The std/http module allows you to build and send HTTP requests. Sending a request three ways — blocking, suspending, or as an event — is the only thing that differs between the entry points. There is no C# in this module: System.Net.Http is reached with import/extern, and the response body is an ordinary TextInputPort.
For more details, see (std http).
3.8 std/url↑
URLs by RFC 3986. A URL is written as a quoted literal, as (std run) writes a pipeline: (link '(url https "example.com" (path "users" ,name) (query (page 2) (tag ,tag)) (fragment "top"))), or '(path "users" ,id "edit") for a path on this site. Each value is encoded for the part that it is in, so a / in a computed segment does not make a second segment, and a & in a computed value does not make a second parameter. A grammar reads the literal, so the compiler checks its parts where it is written: a misspelled part, a part given twice, a port out of range or a host that is no host is a compile error. A computed scheme, host or port is made with scheme, host or port, which return a Result, so make-url does not fail. parse-url reads a URL or a relative reference strictly, and refuses text that is not one with the column where it goes wrong. A Url keeps each part as it was written, so url->string gives back the same text. url-resolve reads a link where a page is. url-segments and url-params decode the path and the query, and there is one percent-encoder for each part of a URL.
For more details, see (std url).
3.9 std/run↑
The std/run module provides a way to run external programs. A form is one thing to run, written as a quoted literal, like '(cat "hej.txt"). You can connect programs in a pipe with '(pipe (cat "a") (wc -l)), and redirect input and output to files. Redirections can nest, and you can even splice computed arguments or write stages in Bjolang.
For more details, see (std run).
3.10 std/random↑
The random module provides a ChaCha20 generator, seeded with 256 bits from the OS CSPRNG, with one generator per thread. The default path locks nothing and shares nothing, making it safe and fast on a bjoroutine. The ambient source has no state to race on, so drawing from multiple threads is efficient. You can generate random integers and more.
For more details, see (std random).
3.11 std/uuid↑
Uuids as RFC 9562 specifies them. uuid-v4 is random, for an identifier that must tell nothing. uuid-v7 starts with the time, for a database key: new keys go at the end of an index, and keys sort by when they were made, and the ones made by one process always increase. uuid-v5 is made from a namespace and a name, so the same input gives the same uuid. A Uuid is 16 bytes and compares as its bytes do, which is also the order of its text and of Postgres’ uuid. uuid->string writes the one text form, and string->uuid reads it in either case. The random bits come from current-random and the time from current-clock, so a test can make the same uuids in every run.
For more details, see (std uuid).
3.12 std/crypto/hash↑
Hashes, HMAC and password hashing, from .NET’s System.Security.Cryptography. hash-bytes, hash-string and hash-file make a digest, SHA-256 unless #:algorithm says otherwise, and make-hasher makes one a piece at a time. hmac-bytes and hmac-string sign a message with a key. A digest is fast, so it is not a password hash: password-hash uses PBKDF2 with a random salt and gives a PHC string, which holds the algorithm and the iteration count, so password-verify needs only the password and the string, and password-needs-rehash? says which to replace after the count is raised. A password hash takes a few hundred milliseconds, and in a bjoroutine it runs on a thread of its own. Compare secrets with constant-time-equal?, not =.
For more details, see (std crypto hash).
3.13 std/atomic↑
An atomic is a cell that threads can read and write without a lock. (make-atomic v) makes one of any type, and atomic-ref, atomic-set!, atomic-swap!, atomic-compare-and-swap! and atomic-update! work on it. For int, uint, long and ulong there are also atomic-increment!, atomic-decrement!, atomic-add! and atomic-sub!, which compile to direct calls to System.Threading.Interlocked and wrap on overflow. Swap and compare-and-swap return the value from before the call; the other operations that change the atomic return the new value. The width of a counter comes from type inference, so write (make-atomic 0L) for a long counter: (make-atomic 0) is an int.
For more details, see (std atomic).
3.14 std/stm↑
Software transactional memory, after the OCaml library kcas. A (Loc T) holds one value, and a transaction is a function of a Tx that reads and writes locations with tx-ref, tx-set!, tx-update! and tx-swap!. atomically runs it and commits all of its writes as one step, and runs it again if another transaction changed what it read. tx-retry stops a transaction until a location that it read changes, and atomically-evt is the transaction as a CML event, so waiting for a condition combines with timeouts, channels and cancellation in a choose. tx-or-else tries alternatives. Three modules build data structures out of locations: (std stm queue) has the FIFO queue TQueue, with the channel names queue-send, queue-recv, queue-put and queue-get outside a transaction; (std stm map) has the hash map TMap, whose operations are named as those of the prelude’s Map; and (std stm counter) has TCounter, a number that fibers on different CPUs add to without conflicts. Part II has a guide in the section on concurrency.
For more details, see (std stm), (std stm queue), (std stm map) and (std stm counter).
3.15 std/datetime↑
This module provides types and functions for handling time. The clock is an argument (with a default that a program can rebind), and the types refuse operations that mean nothing, such as adding a calendar day to an instant. The module separates Date (a calendar date without a timezone), Time (a time of day without a timezone), and DateTime (a date and time without a timezone), as well as handling timezones.
For more details, see (std datetime).
3.16 std/eq and std/clr-ord↑
(std eq) has the traits Eq and Ord. Eq has = and eq-hash, and every type has an implementation: a type with no implementation of its own uses .NET’s Equals and GetHashCode. Ord has one method, compare. The other functions of the module, such as less?, least, greatest, list-sort, list-min and list-max, use only compare, so a type that implements compare gets all of them. Strings compare by code point, so the order is the same on every machine. The prelude re-exports every name of (std eq), so you do not usually import it. (std clr-ord) adds Ord for the .NET time types, Guid and Version.
For more details, see (std eq) and (std clr-ord).
3.17 std/monad↑
The Monad trait has pure and bind, with instances for List, Option and Result. A (do ...) block writes a chain of bind calls as a list of steps: (:bind x e), (:let x e) and (:return e). For Option and Result, the block stops at the first None or Err. For List, it runs once for each combination of items.
For more details, see (std monad).
3.18 std/effect↑
Effect handlers that resume once. defeffect declares operations and their default handlers. A call of an operation calls the handler that is installed, and with-handler installs other handlers for an extent. A test can, for example, collect what the code logs. A handler runs on the stack of the caller, so it can return a value or raise an exception, and nothing else: no continuation is captured. The prelude declares its own effects, such as log, warn and the filesystem operations, so you import this module to handle them.
For more details, see (std effect).
3.19 std/hashset, std/orderedset and std/orderedmap↑
Persistent collections beside the prelude’s Map: an update returns a new collection and does not change the old one. (std hashset) has the unordered HashSet, a CHAMP trie that compares elements with Eq. (std orderedset) and (std orderedmap) have OrderedSet and OrderedMap, B-trees kept in the order of compare. They add what the order gives: -min, -max, -successor, -predecessor and the range walks -range, -from and -until. Each collection has a builder, which makes it in one pass, and a transient, which edits it in place and gives it back as a persistent collection in O(1).
For more details, see (std hashset), (std orderedset) and (std orderedmap).
3.20 std/priority-queue↑
A persistent priority queue, least priority first. A (PriorityQueue a p) holds elements under priorities, and orders the priorities, so the elements need no order of their own. priorityqueue-empty orders priorities by their Ord, and priorityqueue-by by a comparison you give it. Adding or removing gives a new queue and leaves the old one as it was. It is a pairing heap: peeking, adding and priorityqueue-merge are O(1), and priorityqueue-remove-min is O(log n) amortised. That bound assumes each queue is used once. Call priorityqueue-balance on a queue you keep and remove from many times. Entries of equal priority come out in the order they went in. There is a transient and a builder, as for the other persistent collections. For a queue that is used in one place and never shared, (std mutable heap) is faster.
For more details, see (std priority-queue).
3.21 std/mutable↑
Collections that change in place. Use them for a cache, an index, a worklist or a scratch buffer, where you do not need old versions. (std mutable vec), (std mutable map), (std mutable set) and (std mutable heap) are the .NET List<T>, Dictionary<K,V>, HashSet<T> and PriorityQueue<T,P> themselves, not wrappers, so .NET code that takes those types accepts them as they are. They compare with .NET’s equality and order. (std mutable deque) is a double-ended queue with O(1) amortized push and pop at both ends. None of them has a lock.
For more details, see (std mutable vec), (std mutable map), (std mutable set), (std mutable heap) and (std mutable deque).
3.22 std/inbox↑
An inbox is how .NET code gives work to Bjolang. A thread that is not a fiber, such as a timer, a FileSystemWatcher or a Kestrel thread, posts an item and returns. A fiber takes the item with its own handlers, parameters and cancellation scope. An inbox always has a capacity, and a WhenFull policy says what a post does when the inbox is full. inbox-call is a request and a reply: the .NET caller gets a Task, and a fiber answers it.
For more details, see (std inbox).
3.23 std/ports and std/net↑
(std ports) reads and writes bytes on the prelude’s ports. The byte reads read-some-evt, read-bytes-evt and eof-evt are CML events: a read that loses its choose takes nothing, so a read can race a timeout. read-line and read-bytes can be used on the same port. The module also drains ports into lists and vecs, streams them as a Seq or a channel, and has byte-pipe, a pair of ports with no file and no network, for tests. (std net) gives a TCP connection as a pair of these ports, so a protocol written for ports also runs over TCP. accept-evt is an event too: a branch that loses accepts nothing.
For more details, see (std ports) and (std net).
3.24 std/test and std/simpletest↑
(std test) has tests that are silent unless they fail. deftest registers a test, check reports the values that it saw when it fails, and (run-tests) is the main of the file. Each test runs in a scope with a deadline. The test fails if it leaves a resource open, and the scope waits for the fibers that the test started. bjo test builds and runs the test files of a project. (std simpletest) is the older harness: an assertion prints one line, and a failure prints a line that starts with FAILURE:. Its with-fake-fs, a filesystem in memory, also works inside a deftest.
(std test prop) has property tests. for-all runs its body many times with generated values. When a run fails, for-all shrinks it to a small case, reports that case, and stores it beside the test file, so the next run tries it first. assume discards a run, label names the failures in its body, and classify and collect count the runs for --stats. (std test gen) has the generators, and Arbitrary, which gives the default generator of a type. (std test expect) has expect-output, which compares the output of a body with a text in the test, and --promote updates that text.
For more details, see (std test), (std test prop), (std test gen), (std test expect) and (std simpletest).
3.25 std/bench↑
Benchmarks, written like tests. def/bench registers a benchmark, with a setup outside the time, parameters that make a row for each combination of values, cold runs for a slow body, and groups. (run-benches) is the main of the file. A run warms each benchmark up until its times are steady, calibrates how many calls make a sample, and takes samples until the median is known to ±1%. It prints a table of the median, the spread, the minimum, the allocation and the collections, a row as each benchmark finishes. With no names at a terminal, a menu asks what to run. --save and --compare keep a run in a file and compare with it, with p-values corrected for the number of comparisons and a verdict that needs both a significant and a large enough change. bjo bench FILE --against REF runs a file against itself at a git ref, the two versions measured in turns. time-it is here too: a body measured in place, on one line.
(std bench stats) has the statistics: medians, quantiles, bootstrap intervals of a ratio, and the Wilcoxon, Mann–Whitney and t tests, each checked against SciPy. (std bench store) has saved runs as data, and the two kinds of comparison.
The section on benchmarking in “Writing programs” shows all of it with the card game. For more details, see (std bench), (std bench stats) and (std bench store).
3.26 std/stopwatch↑
Stopwatches that read the ambient clock, so a test can bind a clock and get an exact time. The module also has time-it, which is (std bench)’s: a body measured as a benchmark is, reported on one line.
For more details, see (std stopwatch).
3.27 std/syntax-match↑
syntax-match takes a form apart with patterns, with ... for repetition. Write macro transformers with it, and build the result with #'.
For more details, see (std syntax-match).
3.28 text/bjodat↑
bjodat is Bjolang’s shapes read as data: no evaluation, no macro expansion, no environment. It is what a package manifest, a lockfile, or a config file is written in. You can parse text straight into a record using bjodat-parse-<Name>, which avoids intermediate trees and allocations. (text bjodat-core) is the machinery underneath: the Bjodat tree, with parse-bjodat, bjodat-ref and the writer, for data whose shape is not known until the program runs.
For more details, see (text bjodat) and (text bjodat-core).
3.29 text/json and text/json-codec↑
(text json) reads JSON, exactly as RFC 8259 has it, into a Json: a union with a case for each kind of value. parse-json reads a string and read-json a port. A document that is not JSON is an Err that says the line and column where it went wrong. A number without a fraction or an exponent is a JsonInt, and an object is a (Map string Json), so the order of its keys is not kept. (text json-codec) reads a record straight from the tree and back: def/json-type declares a record and the instances of ->json and json-> for it, with defaults, optional fields and other names for the keys. An Option is null when it is None. There is no writer yet, so a Json cannot be written out as text.
For more details, see (text json) and (text json-codec).
3.30 text/xml↑
XML, in three modules over System.Xml. (text xml) is the tree: names, elements, texts, comments and processing instructions, built by xml-element and the others, and taken apart by views. (text xml read) reads XML in four layers: a stream of signals, a fold after SSAX, a tree of your own type, and parse-xml, which gives a Document. xml-for-each reads a large document one element at a time. The defaults are the safe ones: a document with a DOCTYPE is refused unless asked for, and nothing external is ever resolved. (text xml write) writes a node or a document to a port or a string. It chooses the namespace prefixes, and content that XML cannot hold is an XmlWriteError rather than an exception.
For more details, see (text xml), (text xml read) and (text xml write).
3.31 text/encodings↑
Every character encoding .NET knows, as a value of the prelude’s Encoding, to give to string->bytes and bytes->string, or to reencode-input-port and reencode-output-port from (std ports) for a port. There is one for each of the 139, named in lower case as .NET names them: utf-16be, iso-8859-1, windows-1252, shift_jis, koi8-r and the rest. .NET’s little-endian utf-16 and utf-32 are utf-16le and utf-32le here. encoding-named finds one by name and encoding-of-code-page by number, both as an Option. No encoding writes or skips a byte order mark. Each encoding is made the first time it is used, so importing the module costs nothing.
For more details, see (text encodings).
4 Appendices↑
4.1 Coming from Scheme or ML↑
Bjolang sits at the crossroads of two major functional programming lineages. From the Scheme and Lisp tradition, it draws its uniform S-expression syntax, prefix notation, code-as-data perspective, and hygienic macro system. From the ML family—specifically OCaml and F#—it inherits static Hindley-Milner type inference, algebraic data types, compile-time exhaustiveness checking, traits, and high-performance immutable data structures.
Depending on which background you bring to Bjolang, parts of the language will feel like second nature while other parts will require shifting your mental habits.
4.1.1 Coming from Scheme↑
If you have written Scheme, Racket, or Common Lisp, the visual rhythm of Bjolang will feel immediately comfortable:
- S-expression syntax and prefix notation: Expressions are nested parenthesized lists, and operators are ordinary functions called in prefix position:
(+ 1 2 (* 3 4)). - Familiar control forms: Core branching and binding forms like
let,let*,cond,when,unless, andbeginbehave as you expect. - First-class closures: Functions are first-class values created with
fun(ordefunat top level), capturing lexical scope, and passing into or returning from higher-order functions. - Persistent linked lists: The
Listtype provides standard singly-linked functional lists withConsandNil, along with familiar combinators such aslist-head,list-tail,list-map, andlist-filter. - Hygienic macros: Bjolang features a compile-time syntactic macro system based on pattern matching with
syntax-match, syntax quotation with#'(...), and explicit identifier injection withinject.
However, several fundamental differences will quickly become apparent:
- Static typing and mandatory top-level signatures: Dynamic typing is absent. While types inside function bodies are inferred automatically using Hindley-Milner type inference, every top-level definition and exported value requires an explicit signature:
(: name type). Type errors are caught at compile time before any code runs. - No truthiness tricks or
#f-as-nil: In Scheme, functions likememqorassocreturn either a value or#fto signal absence, and#fis often treated as a general falsey sentinel. In Bjolang, absent values are strictly represented by the(Option %a)union (SomeorNone), and operations that may fail return(Result %e %a)(OkorErr). Booleans (#tand#f) are strictly booleans; conditionals do not coerce non-boolean values. - Vectors and maps are persistent by default: In Scheme, vectors are mutable flat arrays. In Bjolang,
(Vec %a)is an immutable Relaxed Radix Balanced (RRB) tree with efficient O(log n) random indexing, updates, and concatenation. Similarly,(Map %k %v)is an immutable Compressed Hash-Array Mapped Prefix-tree (CHAMP). In-place mutation is never the default: mutable state requires explicit declaration withdef/mutableand modification withset!. - Structural pattern matching over
car/cdr: Destructuring lists, records, and unions is done using thematchconstruct rather than long chains ofcar,cdr, orcadr. The compiler checks pattern matches for exhaustiveness and warns or errors if any potential value is unaccounted for. - Concurrency without
call/cc: Scheme systems often implement coroutines or thread libraries using first-class continuations (call/cc). Bjolang does not providecall/cc. Instead, it offers direct-style cooperative fibers (bjoroutines), typed channels, and Concurrent ML (CML) event combinators running on a dedicated fiber scheduler.
4.1.2 Coming from F# or OCaml↑
If you come from F#, OCaml, or Haskell, the conceptual model of Bjolang’s type system and runtime behavior will feel very natural:
- Hindley-Milner type inference: Types within local expressions and function bodies are completely inferred. Polymorphic functions declare type variables with a leading percent sign, such as
%aor%elem. - Algebraic data types: Data structures are modeled as sum types (
Union) and product types (Recordon the heap, or value-typeStructon the stack). - Exhaustive pattern matching: The compiler tracks which variants of a union or struct combination have been handled in a
matchordefbinding, rejecting patterns that fail to cover every possible case. - Immutability by default: Data structures, record fields, and bindings are immutable unless explicitly declared mutable.
- Traits and ad-hoc polymorphism: Bjolang’s
def/traitandimplsystem works much like Haskell typeclasses or Rust traits. Traits can define default method implementations, require super-traits, and specify associated types. - Direct-style concurrency: Concurrency is written in direct style without callback pyramids, closely resembling F#’s async computations or OCaml 5 effect handlers.
The main shifts you will encounter coming from ML are:
- Syntax and parentheses: There is no indentation-based syntax (like F#) or
let ... inkeyword framing (like OCaml). Everything is an S-expression with prefix notation. While this takes an afternoon to get used to, it makes code structure uniform and simplifies automated tooling. - First-class compile-time macros: Rather than relying on external preprocessors like OCaml’s PPX or compiler plugins, Bjolang allows writing hygienic macros in the language itself using
def/macro. These transformers run inside the compiler process during the expansion phase. - Concurrent ML events instead of raw tasks: Rather than manipulating raw asynchronous tasks or promises directly, Bjolang builds upon Concurrent ML. The primitive unit of asynchronous composition is the
(Event %a). Events can be combined withchoose, wrapped with transformations viawrap, and selectively synchronized usingsync. - Automatic colour twins: In many typed asynchronous languages, asynchronous functions have distinct types (
Task<'T>orAsync<'T>) that cannot be passed to standard higher-order functions likeList.mapwithout creating asynchronous duplicates (List.mapAsync). Bjolang solves this “function colouring problem” at the compiler level: higher-order functions declaring-?->parameters automatically generate both synchronous and suspending twins, allowing the exact samelist-mapto be used whether your callback performs I/O or pure computation.
4.2 Reading compiler errors↑
Bjolang’s frontend is designed to catch structural, typing, and concurrency errors early, providing descriptive diagnostics that point directly to the source of the problem before code reaches the C# compiler. Below are some of the most common compiler messages you will encounter, what causes them, and how to resolve them.
4.2.1 1. Missing top-level type signature↑
(defun (greet name)
(println (str "Hello, " name)))
When compiled, the frontend rejects this with:
Type Error: Function 'greet' requires a type signature (: greet ...) at hello.bjo:1:1
Why it happens: Unlike local helper functions defined inside another function, every top-level definition in a module must carry an explicit type signature. The compiler requires this so that module interfaces are self-documenting and can be emitted into the compiled assembly’s public metadata without requiring whole-program inference across files. The only exception is main: the compiler knows what it takes, the arguments as a (Vec string), and what it returns is the exit code when it is an int.
How to fix it: Add an explicit signature right before the definition:
(: greet (-> string void))
(defun (greet name)
(println (str "Hello, " name)))
4.2.2 2. Calling a bjoroutine from a synchronous function↑
(: fetch-data (-> string string))
(defbjo (fetch-data url)
(http-get url))
(: process-request (-> string string))
(defun (process-request url)
(fetch-data url))
The compiler detects that a synchronous function is attempting to suspend:
Type Error at server.bjo:7:10: calling 'fetch-data' is a yield point, and a yield point is not allowed here. 'process-request' is defined with (defun ...). Define it with (defbjo ...).
Why it happens: In Bjolang, calls to bjoroutines look identical to ordinary function calls—there is no explicit await keyword. However, a bjoroutine can yield or suspend its fiber. A function defined with defun compiles to a regular synchronous C# method and cannot suspend. Allowing a synchronous method to call a suspending bjoroutine directly would require blocking the thread or breaking call-stack invariants.
How to fix it: If the caller needs to perform suspending operations, define it using defbjo instead of defun:
(: process-request (-> string string))
(defbjo (process-request url)
(fetch-data url))
If the caller must remain synchronous, spawn the bjoroutine inside an isolated scope or handle it asynchronously using the concurrency runtime.
4.2.3 3. Mismatch between signature and argument count↑
(: render-card (-> Card Suit string))
(defun (render-card card)
(card->string card))
The compiler flags the arity divergence immediately:
Type Error: Function 'render-card' has 1 mandatory args but signature specifies 2 at cards.bjo:2:1
Why it happens: The parameter list in the defun head does not match the arrow type declared in the preceding (: ...) form. Here, the signature promised two mandatory arguments (Card and Suit), but the definition provided only one parameter (card).
How to fix it: Align the parameter list with the signature, either by updating the type signature or by adding the missing parameters to the function head.
4.2.4 4. Non-exhaustive pattern matching↑
(type (: Suit (Union Clubs Diamonds Hearts Spades)))
(: suit-color (-> Suit string))
(defun (suit-color s)
(match s
(Hearts "red")
(Diamonds "red")))
The exhaustiveness checker discovers the missing cases:
Pattern Error at game.bjo:6:3: this match does not cover every value. Clubs reaches no clause.
Why it happens: Bjolang verifies that pattern matches cover every possible variant of a union or structure. If an unhandled case is matched at runtime, there would be no clause to execute. The error message includes a concrete witness (such as Clubs) showing an unhandled value that would fail to match.
How to fix it: Add the missing cases to the match expression, or provide a wildcard fallback _ if other cases should receive default handling:
(: suit-color (-> Suit string))
(defun (suit-color s)
(match s
(Hearts "red")
(Diamonds "red")
((or Clubs Spades) "black")))
4.2.5 5. Exporting a binding that references a private type↑
Suppose you define a module player.bjo where a type is private, but an exported function returns it:
(export make-player)
(type (: Player (Record (: name string) (: score int))))
(: make-player (-> string Player))
(defun (make-player name)
(Player name 0))
Compiling player.bjo fails with:
Export Error: the exported binding 'make-player' names the type 'Player', which this module declares and does not export. A type crosses a module boundary only when it is named in an (export ...), so an importer has no way to resolve this. Write (export Player), or (export Player) with the declaration marked #:opaque to keep its representation to this module's code.
Why it happens: If another file imports make-player, the calling module will receive a value of type Player. But because Player was not exported, the importing module cannot name the type in signatures or inspect its fields.
How to fix it: Export the type in the module header:
(export Player make-player)
If you want to keep the internal fields and constructor private to player.bjo, mark the type as #:opaque:
(export Player make-player)
(type #:opaque (: Player (Record (: name string) (: score int))))
4.2.6 6. Accessing fields of an opaque type↑
Following from the previous example, suppose an importing module attempts to read a field from an opaque record:
(import "player.bjo")
(defun (main args)
(let ((p (make-player "Alice")))
(println (int->string (record-ref p score))))
0)
The compiler prevents the private access:
Type Error at main.bjo:5:37: 'score' cannot be read here. player/Player is exported #:opaque.
Why it happens: When a type is exported with #:opaque, its record layout and constructor are visible only to the module that defined it. Outside modules may pass values of the type around, but they cannot directly read or write fields with record-ref or pattern match on internal components.
How to fix it: Expose a public accessor function in the defining module (e.g. player-score) and export that function alongside the type.
4.2.7 7. Using a macro where it is defined↑
(import (std prelude))
(import (std syntax-match))
(def/macro (def/thunk form inject compare)
(syntax-match form
((_ name body)
#'(defun (,name) ,body))))
(def/thunk answer 42)
When compiled, the macro expander reports:
'def/thunk' is a macro defined in this module, and a macro cannot be used where it is defined, at thunk.bjo:9:1. Its transformer runs inside the compiler, so it has to be compiled before whatever uses it is read — which cannot be true of the file it is written in. Move it to a module of its own and import that. An (include ...) will not do: an included file becomes part of this one.
Why it happens: Bjolang macros are not textual string replacements. A macro transformer is written in Bjolang, compiled into a .NET assembly, and executed dynamically inside the compiler process to expand syntax trees. Because the macro must already be compiled before the compiler can read and expand code using it, a file cannot use macros defined within its own source text.
How to fix it: Move your macro definitions into a dedicated module (such as macros.bjo), and then import that module into the files where you wish to use them:
;; In main.bjo:
(import "macros.bjo")
(def/thunk answer 42)
4.3 How it compiles↑
Bjolang targets the .NET 10 CLR by compiling to readable, high-performance C# 12 code, which is then compiled into managed assemblies by the Roslyn compiler. Rather than running an interpreter or emitting raw IL bytes, compiling through C# allows Bjolang to leverage RyuJIT’s advanced optimization pipeline, benefit from modern runtime features like value types and spans, and interoperate seamlessly with the broader .NET ecosystem.
For the curious engineer, this appendix explains what happens under the hood when you build a Bjolang program.
4.3.1 The compilation pipeline↑
When you run bjo build or bjo run, the compiler processes source files through several distinct phases:
- Parsing and macro expansion: Source text is read into untyped S-expression ASTs. Imported macro modules are loaded dynamically, and macro transformers are executed to expand custom syntactic forms into core language primitives.
- Colour twin generation: Higher-order functions with polymorphic effect arrows (
-?->) and functions that call dual-mode primitives are duplicated as source ASTs before type checking, producing both synchronous and suspending versions. - Hindley-Milner type inference: Types of all expressions are inferred, top-level signatures are checked against function bodies, pattern match exhaustiveness is verified, and trait constraints (
whereclauses) are gathered. - Monomorphisation: Generic functions constrained by traits are specialized at concrete call sites (such as
intor custom types) to eliminate virtual interface dispatch. - Trait inlining: Direct trait method calls on known types are rewritten into primitive C# operations (such as
==,<, and arithmetic operators). - C# codegen: The typed AST is emitted as standard C# source files, wrapping functions in static module classes and mapping Bjolang identifiers to legal C# names.
- Roslyn compilation: The emitted C# code is handed to Roslyn (either directly or via the persistent
VBCSCompilerserver), producing a.dllor.exe.
4.3.2 The C# output model and runtime assemblies↑
Each Bjolang source file compiles to a static C# class named after the module: deck.bjo becomes deck_Module.
Because Bjolang allows characters in identifiers that are invalid in C# (such as hyphens, exclamation marks, and question marks), the code generator applies deterministic name mangling:
- Hyphens become
sub:card-pointsbecomescardsubpoints. - Exclamation marks become
bang:set!becomessetbang. - Question marks become
p:empty?becomesemptyp. - Type variable sigils (
%) becometv_:%elembecomestv_elem.
Every compiled program links against a small set of optimized runtime assemblies:
| Assembly | Role in compiled programs |
|---|---|
BjolangRuntime | Core primitives, string formatting, dynamic environment, and conversions |
Collections | Persistent (Vec %a) based on Relaxed Radix Balanced (RRB) trees |
SchemeList | Persistent singly-linked (List %a) with Cons and Nil |
Map | Persistent (Map %k %v) based on Compressed Hash-Array Mapped Prefix-trees (CHAMP) |
BjoSet | Persistent hash set (HashSet %a) |
BjoOrderedMap | Balanced ordered map (OrderedMap %k %v) |
BjoOrderedSet | Balanced ordered set (OrderedSet %a) |
Bjoml | Concurrency runtime: lightweight fibers, channels, promises, and CML scheduler |
These assemblies reside once in the Bjolang installation directory. A compiled program does not copy these DLLs next to its binary; instead, it registers a custom assembly resolver on startup that points directly back to the runtime installation directory.
4.3.3 Colour twins: solving the function colouring problem↑
In languages with asynchronous functions (such as JavaScript, Python, C#, or Rust), functions are typically divided into two “colours”: synchronous functions that return T, and asynchronous functions that return Task<T> or Promise<T>. This dichotomy often splits standard libraries in two: an author must write map for synchronous callbacks, and a separate mapAsync for asynchronous callbacks.
Bjolang solves this problem at the compiler level using colour twins:
1. The polymorphic arrow -?->: When writing a higher-order function that accepts a callback, you can declare its parameter with -?-> rather than a fixed arrow:
(: list-map (-> (List %a) (-?-> %a %b) (List %b)))
2. Source-level twin generation: Before type checking occurs, the ColourTwins pass scans all declarations. When it detects a function with a -?-> parameter, it automatically generates a second definition from the same source body, repainted with suspending signatures. Both definitions are checked independently. This ensures that effects and await points are fully verified without needing error-prone AST copying after inference.
3. Reaching twins: Any function defined with defun that calls a dual-mode function (such as defbjouble primitives that provide both synchronous and fiber implementations) automatically receives a suspending twin as well, and so does one with a (by-colour ...) in it, whose #:bjo body goes into the twin and whose #:sync body stays in the original. If an author explicitly wants a function to remain strictly synchronous, they can mark it with #:sync.
4. Automatic call-site selection: When a function is called, the compiler checks the colour of the caller. If an ordinary defun calls list-map, it calls the synchronous twin. If a bjoroutine (defbjo) calls list-map with a suspending callback, it automatically links to the suspending twin. The author writes the higher-order function once, and the language transparently provides both versions.
4.3.4 Monomorphisation and trait inlining↑
Generic code bounded by traits (such as (where (Eq %a))) is initially desugared using dictionary passing:
(: least (-> %a %a %a) (where (Ord %a)))
(defun (least a b)
(if (<= a b) a b))
Under standard dictionary passing, <= is compiled into an interface method call: _dict_Ord_a.le(a, b). While flexible, virtual calls through interface dictionaries carry notable overhead: they prevent inlining, require heap-allocated dictionary references, and force value types (like int or structs) to be boxed.
To eliminate this cost, Bjolang employs monomorphisation:
- Call-site specialization: Before trait inlining runs, the compiler inspects every call to a constrained generic function. When a function like
leastis called with concrete types—for instance,(least 3 5)—the compiler generates a specialized copy ofleasttailored specifically forint. - Stable naming: Specialized copies are named deterministically using an FNV-1a hash of their concrete type arguments. This ensures that incremental builds generate stable symbol names across separate runs.
- Trait inlining: Once a copy is checked at a concrete type,
TraitInlinereplaces the dictionary method call with the direct C# operation. Forint,_dict_Ord_int.le(a, b)becomes a directa <= bprimitive comparison in the generated C# code. - Cross-module monomorphisation: What if
leastis exported from a library and called from an application module? An exported constrained function embeds its untyped source AST directly into the compiled.dll’s metadata. When the importing module callsleastwith its own local types, it reads the AST from the imported metadata and monomorphises it locally against the caller’s own trait registry.
As a result, generic functional abstractions in Bjolang compile down to the same tight loops and direct machine instructions as hand-written C#.
4.3.5 Incremental builds and .bjobuild↑
To keep development fast, Bjolang includes an incremental compilation system designed for sub-50ms turnarounds.
When a compilation finishes successfully, the compiler writes a build manifest named <source>.bjobuild beside the source file:
mode release
output /home/linus/game.exe
compiler /home/linus/bjolang/bin/Release/net10.0/Bjolang.dll
source /home/linus/game.bjo
source /home/linus/deck.bjo
dep /home/linus/bjolang/lib/std/prelude.dll
dep /home/linus/bjolang/BjolangRuntime/bin/Release/net10.0/BjolangRuntime.dll
When you run bjo run game.bjo again:
bjoreadsgame.bjobuildand checks the file timestamps of every input: the compiler binary, each transitive source file, and linked dependencies.- If nothing has changed, it executes the existing binary immediately without launching dotnet or the compiler.
- If any source file, dependency, or the compiler itself is newer than the binary, it triggers a rebuild.
You can pass -c to force an unconditional rebuild, or -d to create an unoptimized debug build with AST and generated C# dumps.
4.4 Editor support↑
Editing Bjolang code is most pleasant in Emacs, where specialized major modes support both Bjolang source files and Samizdat documentation.
4.4.1 Bjolang mode: bjomode.el↑
bjomode.el is located in the root of the Bjolang repository. It provides syntax highlighting, indentation, and structural navigation tailored to Bjolang’s syntax.
To use it in your Emacs configuration, add the repository directory to your load-path and require the mode:
(add-to-list 'load-path "/path/to/bjolang")
(require 'bjo-mode)
bjo-mode associates automatically with *.bjo and *.protobjo files.
Key capabilities of the mode include:
- Comprehension brace pairing: Bjolang uses curly braces for list and vector comprehensions:
{for x in xs :yield (* x 2)}. Inbjo-mode, braces are configured in the syntax table as paired delimiters. Structural navigation commands likeC-M-f(forward-sexp),C-M-b(backward-sexp), andshow-paren-modetreat curly braces exactly like parentheses. - Syntax propertizing for character literals: In Lisp syntax tables, a semicolon normally starts a line comment. The literal
#\\;is recognized by a custom syntax-propertize rule so that the semicolon is treated as a character literal rather than swallowing the remainder of the line in comment coloring. Similarly,#\\(is recognized as a character rather than an unmatched opening parenthesis. - Multiline raw string fences: Triply-quoted raw strings (
"""...""") are detected and marked with string fence syntax, ensuring that quotes inside raw strings do not disrupt subsequent syntax highlighting. - Scheme-style indentation: Indentation follows Scheme conventions. Forms with bodies (
defun,defbjo,let,match,type,when,unless) indent their bodies by 2 spaces. Branching arms inifforms indent by 4 spaces. Standard function calls and comprehension clauses line their arguments up under the first expression.
4.4.2 Samizdat mode: samizdat-mode.el↑
All documentation for Bjolang—including this manual—is written in Samizdat (.sz). The major mode samizdat-mode.el provides syntax highlighting and editing commands for Samizdat documents.
To configure it:
(add-to-list 'load-path "/path/to/samizdat")
(require 'samizdat-mode)
(add-to-list 'auto-mode-alist '("\\.sz\\'" . samizdat-mode))
Key editing features and keybindings include:
- Command highlighting: Samizdat directives like
@section,@subsection,@codeblock, and@includeare highlighted distinctly from regular prose text. - Verbatim block handling: Verbatim code blocks enclosed in
|{ ... }|and comments enclosed in@;{ ... }are syntax-propertized so that code symbols or@characters inside code are not mistaken for Samizdat markup. C-c C-s: Insert a section or subsection header at point, prompting for the heading level and title.C-c C-e: Insert a Samizdat command at point, offering autocompletion over standard directive names. If a region is active, wraps the selected text in the command.C-c C-b: Wrap the selected region in a verbatim code block (@codeblock|{ ... }|), prompting for an optional#:langspecifier.C-c C-l: Wrap the selected region in a link command (...).C-c ': Edit the verbatim code block at point in a dedicated indirect buffer using that block’s native major mode (such asbjo-mode,sh-mode, oremacs-lisp-mode). PressingC-c 'again commits the changes back to the document.C-c C-o: Follow the reference at point, opening the target URL or jumping directly to an included file.- Document navigation: Seamless integration with
outline-minor-modeandimenuallows folding sections and navigating complex documents through hierarchical headings.