BjoManual/_02_.Writing-programs.sz

1285 lines
69 KiB
Text
Raw Normal View History

2026-10-01 11:36:18 +02:00
@section{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.
2026-10-01 11:36:18 +02:00
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 @code{#card(Q hearts)} literal.
2026-10-01 11:36:18 +02:00
@subsection{Modules}
In Bjolang, every @code{.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.
2026-10-01 11:36:18 +02:00
@subsubsection{Splitting the game into files}
Here is the deck from the first part as a module of its own. Put it in @code{deck.bjo}:
@codeblock[#:lang "bjolang"]|{
(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 (->str Suit)
(defun (->str s)
(match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠"))))
(impl (->str Rank)
(defun (->str r)
(match r
((Pip n) (int->string n))
(Jack "J") (Queen "Q") (King "K") (Ace "A"))))
(impl (->str Card)
(defun (->str c) #"${(record-ref c rank)}${(record-ref c suit)}"))
;; 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 @code{game.bjo} next to it:
@codeblock[#:lang "bjolang"]|{
(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 @code{bjo run game.bjo} outputs something like @code{Your hand: [3♥ 10♠ 8♥ 10♦ 7♣]}.
Notice how the imports and exports interact here:
2026-10-01 11:36:18 +02:00
@read-list{
- Relative imports: @code{(import "deck.bjo")} specifies a file path relative to the importing file. Bjolang compiles @code{deck.bjo} into @code{deck.dll} ahead 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 @code{Card} is exported, @code{game.bjo} can freely construct @code{(Card (rank Ace) (suit Spades))} and pattern-match on its fields.
- Trait implementations are automatic: Although @code{->str} was not explicitly listed in the export list, its implementations still work in @code{game.bjo}. Because @code{impl} blocks 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 @code{all-suits} remain private to @code{deck.bjo} and are inaccessible from outside.
2026-10-01 11:36:18 +02:00
}
Every exported definition must have an explicit top-level type signature. The compiler needs this signature to write the metadata into the compiled @code{.dll}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
Export Error: Exported item 'helper' is missing a mandatory type signature at bad1.bjo:1
}|
@subsubsection{The standard library, and the prelude}
A list-shaped module path such as @code{(std random)} refers to a standard library module. The compiler resolves these relative to the Bjolang installation rather than the current directory, so @code{(std random)} always points to the same library regardless of where you invoke @code{bjo}. A few notable modules include:
2026-10-01 11:36:18 +02:00
@read-table{
| Module | What it has |
|---------------------------------+------------------------------------------------------|
| @code{(std random)} | random numbers, @code{shuffle-vec} |
| @code{(std set)} | sets; @code{(std orderedset)}, @code{(std orderedmap)} |
| @code{(std ports)} | reading a whole port: @code{port->lines} and friends |
| @code{(std fmt)} | text layout |
| @code{(std rx)} | regular expressions |
| @code{(std run)} | running other programs |
| @code{(std effect)} | effect handlers |
| @code{(std simpletest)} | tests |
| @code{(std syntax-match)} | writing macros |
| @code{(text json)} | JSON |
}
By default, @code{(std prelude)} is imported implicitly into every module, providing core functions like @code{println}, @code{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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(import (except (std prelude) list-map))
}|
@subsubsection{Exporting types}
Types are private unless listed in @code{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.
2026-10-01 11:36:18 +02:00
When you want external modules to use a type without inspecting or constructing its internal representation directly, mark the type definition with @code{#:opaque}. For example, here is a card draw pile in @code{pile.bjo} that only permits drawing from the top:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{Pile} values, use @code{Pile} in type annotations, and call functions like @code{new-pile} or @code{draw}. However, it cannot construct a pile directly with @code{(Pile ...)}, access its internal @code{cards} field, or pattern-match on its structure:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
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 @code{pile.bjo} remain available to consumers. For instance, an @code{(impl (->str Pile) ...)} inside @code{pile.bjo} allows any importing module to convert a @code{Pile} to a string.
2026-10-01 11:36:18 +02:00
@subsubsection{Import modifiers}
Imports can be wrapped in modifiers to filter which definitions are brought into scope or rename them to prevent naming collisions:
2026-10-01 11:36:18 +02:00
@read-table{
| Modifier | Does |
|---------------------------------+-------------------------------------------------------|
| @code{(only m a b ...)} | only these functions and macros |
| @code{(except m a b ...)} | everything but these |
| @code{(prefix m "p/")} | puts @code{p/} in front of every name |
| @code{(postfix m "/p")} | the same, at the end |
| @code{(prefix-defs m "p/")} | a prefix on functions and macros only |
| @code{(prefix-types m "P/")} | a prefix on types, constructors and traits only |
| @code{(rename m (old new) ...)} | renames functions and macros |
}
@codeblock[#:lang "bjolang"]|{
(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, @code{(prefix (except (std set) set-map) "s/")} excludes @code{set-map} and prefixes all remaining names with @code{s/}.
2026-10-01 11:36:18 +02:00
Note that @code{only} and @code{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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{Card} type, they remain distinct types; the unqualified name @code{Card} simply resolves to whichever import came last. To disambiguate between them, use @code{prefix-types}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(import (prefix-types "poker.bjo" "P/")
(prefix-types "bridge.bjo" "B/"))
(: convert (-> P/Card B/Card))
}|
@subsubsection{Which name wins}
When multiple definitions share the same identifier, name resolution follows three precedence rules:
2026-10-01 11:36:18 +02:00
@read-list{
- Local definitions take precedence over imported names.
- For collisions between two imports, the later import shadows the earlier one. This shadowing behavior is how modules can freely override names from the prelude, which is loaded as the very first import.
- Explicit renames or aliases created by import modifiers or @code{:alias} must be unique. If an alias collides with an existing name, the compiler raises an error to prevent ambiguity.
2026-10-01 11:36:18 +02:00
}
@subsubsection{re-export and :alias}
While @code{export} publishes definitions authored in the current module, @code{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 @code{=}, @code{compare}, and @code{list-sort} from @code{(std eq)}. In our card game:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
;; 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 @code{Card} referenced via @code{cards.bjo} is identical to one from @code{deck.bjo}. However, trait implementations (@code{impl}) do not transfer through re-exports. They remain bound to the module where they were defined, meaning any file that wants to format cards with @code{->str} must still import @code{deck.bjo}.
2026-10-01 11:36:18 +02:00
You can provide alternative names for bindings or macros using @code{(:alias new old)}. Combining @code{:alias} with @code{export} or @code{re-export} allows libraries to offer cleaner or more idiomatic public APIs:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(:alias deal full-deck)
(export deal)
}|
@subsubsection{include}
Unlike @code{import}, which compiles a separate module, @code{(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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
;; helpers.bjo
(: double (-> int int))
(defun (double x) (* x 2))
}|
@codeblock[#:lang "bjolang"]|{
(include "helpers.bjo")
(defun (main)
(println (double 21)) ;; 42
0)
}|
As a general guideline: use @code{include} when you want to divide a single large module across multiple files, and use @code{import} when the target file represents an independent component with its own encapsulation boundaries.
2026-10-01 11:36:18 +02:00
@subsection{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.
2026-10-01 11:36:18 +02:00
A project is simply a directory containing a @code{manifest.bjodat} file. When operating inside a project directory, @code{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.
2026-10-01 11:36:18 +02:00
@subsubsection{Making a project}
@codeblock[#:lang "text"]|{
mkdir cards && cd cards
bjo init
bjo run
}|
Running @code{bjo init} scaffolds a new project layout:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
cards/
manifest.bjodat package metadata and dependencies
2026-10-01 11:36:18 +02:00
src/
main.bjo executable entry point
2026-10-01 11:36:18 +02:00
tests/
.gitignore
}|
The generated manifest defines the package identity:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(package
(name (cards))
(version "0.1.0"))
}|
All source files in a package live under @code{src/}, where each module's name mirrors its relative path within that directory. For example, moving @code{deck.bjo} into @code{src/} exposes it as the module @code{(cards deck)}, which @code{src/main.bjo} can import just like any library module:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(import (cards deck))
(defun (main args)
(println #"Your hand: ${(vec-slice (shuffle (full-deck)) 0 5)}")
(println #"Arguments: ${args}")
0)
}|
Because @code{bjo} automatically detects the project root by walking upward until it finds a @code{manifest.bjodat}, you can invoke CLI commands from any sub-directory within the project:
2026-10-01 11:36:18 +02:00
@read-table{
| Command | What it does |
|---------------------------+----------------------------------------------------------------|
| @code{bjo init [--lib]} | Initializes a new project in the current directory |
| @code{bjo fetch} | Fetches external dependencies without compiling |
| @code{bjo build} | Fetches dependencies and compiles the program or library |
| @code{bjo run . args ...} | Builds and runs the project with the specified arguments |
| @code{bjo check} | Type-checks the entire project without emitting output binaries |
| @code{bjo repl} | Starts the REPL preloaded with access to the project's modules |
2026-10-01 11:36:18 +02:00
}
In @code{bjo run . one two}, the dot specifies the project itself. Any arguments following the target are forwarded directly to the program's @code{main} function, binding @code{args} to @code{[one two]}. You can also point @code{bjo run} at a specific test file, such as @code{bjo run tests/deck-test.bjo}; the file will be compiled and linked against the project's declared packages.
2026-10-01 11:36:18 +02:00
@subsubsection{Libraries}
A package that lacks an executable entry point (@code{src/main.bjo}) is treated as a library. Running @code{bjo init --lib} initializes a library project, and running @code{bjo build} inside it compiles all modules found under @code{src/}.
2026-10-01 11:36:18 +02:00
To see this in action, we can split out our card deck into a separate library residing alongside the game:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
mkdir cardlib && cd cardlib
bjo init --lib
mv ../cards/src/deck.bjo src/
}|
We can then declare a path dependency in @code{cards/manifest.bjodat} and import the library module as @code{(cardlib deck)}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(package
(name (cards))
(version "0.1.0")
(depends
(package (name (cardlib))
(source (path (dir "../cardlib"))))))
}|
@codeblock[#:lang "bjolang"]|{
(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.
2026-10-01 11:36:18 +02:00
@subsubsection{Dependencies from git}
Once a library is hosted in a Git repository, you can switch from a local path to a Git dependency:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{vX.Y.Z}. Git dependencies specify version constraints using @code{(version-at-least "1.2")}, @code{(version-between "1.2" "2.0")}, or @code{(version-at-most "2.0")}.
2026-10-01 11:36:18 +02:00
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.
2026-10-01 11:36:18 +02:00
Commands like @code{bjo fetch}, @code{build}, and @code{run} clone external dependencies into a local @code{.bjo/} directory and record the exact resolved commits in @code{bjo.lock}. You should always check @code{bjo.lock} into version control, while leaving @code{.bjo/} ignored (as configured in the default @code{.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 @code{--locked} to guarantee that the build fails if any dependency deviates from the locked snapshot.
2026-10-01 11:36:18 +02:00
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.
2026-10-01 11:36:18 +02:00
@subsubsection{NuGet packages}
You can pull in third-party .NET packages directly from NuGet by listing them in the @code{packages} section:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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: @code{"9.0.3"} matches version 9.0.3 or higher, while bracketed syntax like @code{"[9.0.3]"} locks to that exact version. Bjolang delegates package restoration to the .NET SDK via the @code{dotnet} tool, storing the resolution graph in @code{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.
2026-10-01 11:36:18 +02:00
@subsubsection{Publishing a package}
Publishing a Bjolang package requires three basic steps:
2026-10-01 11:36:18 +02:00
@read-list{
- Organize your source files under @code{src/}, and create a @code{manifest.bjodat} defining the package @code{name} and @code{version}.
- Run @code{bjo build} to verify that all modules compile cleanly without errors.
- Tag your commit with the semantic version—e.g. @code{git tag -a v0.1.0 -m "First release"}—and push the tag to your remote repository. Dependencies require valid @code{vX.Y.Z} tags to resolve.
2026-10-01 11:36:18 +02:00
}
@subsection{Files and I/O}
@subsubsection{Whole files}
For files that comfortably fit into memory, the prelude provides straightforward functions to read or write entire files in a single call:
2026-10-01 11:36:18 +02:00
@read-table{
| Function | Description |
|-------------------------------------------+----------------------------------------------------|
| @code{(file-read-text path)} | Reads the entire file into a string |
| @code{(file-write-text path s)} | Writes @code{s}, overwriting any existing contents |
| @code{(file-append-text path s)} | Appends @code{s} to the file, creating it if needed |
| @code{(file-read-lines path)} | Reads lines as a @code{(Vec string)} |
| @code{(file-write-lines path lines)} | Writes a @code{(Vec string)}, one line per element |
| @code{(file-read-bytes path)} | Reads the raw bytes as an @code{(Array byte)} |
| @code{(file-exists? path)} | Checks whether a file exists at the given path |
| @code{(file-delete path)} | Deletes a file; succeeds silently if file is absent |
| @code{(file-copy from to)} | Copies a file, raising an error if target exists |
| @code{(file-move from to)} | Moves or renames a file or directory |
| @code{(file-info path)} | Returns size, modification time, and kind as an @code{Option} |
2026-10-01 11:36:18 +02:00
}
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 @code{try} expression.
2026-10-01 11:36:18 +02:00
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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(: 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)) ;; [(ada, 31) (bo, 27)]
0)
}|
@subsubsection{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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(get-environment-variable "HOME") ;; (Some "/home/linus"), or None
(set-environment-variable! "MODE" "x") ;; for this process and the ones it starts
(current-directory)
}|
@subsubsection{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.
2026-10-01 11:36:18 +02:00
Attempting to open a file returns a @code{Result}, treating potential I/O errors (such as missing files) as expected outcomes. The @code{Err} variant wraps the underlying .NET exception, allowing you to match on specific exception types with @code{:is}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{call-with-input-file} or @code{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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(: count-lines (-> string (Result Exception int)))
(defun (count-lines path)
(call-with-input-file path
(fun (port)
(loop (:for i (up-from 0))
(:break-let (Some line) (read-line/opt port))
(:acc lines (counting line))
=> lines))))
(count-lines "scores.txt") ;; (Ok 2)
}|
@read-table{
| Function | Description |
2026-10-01 11:36:18 +02:00
|-----------------------------------+---------------------------------------------------------|
| @code{(open-input-file path)} | Returns a @code{(Result Exception TextInputPort)} |
| @code{(open-output-file path)} | Opens for writing; @code{#:mode} can be @code{Truncate}, @code{Append}, or @code{CreateNew} |
| @code{(read-line/opt p)} | Reads the next line, or returns @code{None} at EOF |
| @code{(read-char/opt p)} | Reads the next character, or returns @code{None} at EOF |
| @code{(read-all p)} | Reads the remaining contents into a single string |
| @code{(port-eof? p)} | Checks if the end of the input stream has been reached |
| @code{(write-string p s)} | Writes string @code{s} to the port |
| @code{(writeln p s)} | Writes string @code{s} followed by a newline |
| @code{(close-input-port p)} | Closes an input port |
| @code{(close-output-port p)} | Closes an output port |
| @code{(open-input-string s)} | Creates a port reading from an in-memory string |
| @code{(open-output-string)} | Creates a string-builder port; read with @code{get-output-string} |
2026-10-01 11:36:18 +02:00
}
The variants @code{read-line} and @code{read-char} (without the @code{/opt} suffix) also exist, but throw an end-of-file exception if called at EOF.
2026-10-01 11:36:18 +02:00
Convenience helpers in @code{(std ports)} can ingest an entire port into collections: @code{port->lines}, @code{port->list}, @code{port->vec}, as well as lazy sequences via @code{port->seq} and @code{file->seq}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{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 @code{call-with-input-file}.
2026-10-01 11:36:18 +02:00
@subsubsection{Running other programs}
The @code{(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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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. @code{,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 @code{pipe}, @code{into-file}, @code{from-file}, @code{append-to-file}, and @code{errors-into-file} are validated at compile time; any unrecognized head identifier is executed as an external executable.
2026-10-01 11:36:18 +02:00
@read-table{
| Function | Returns |
2026-10-01 11:36:18 +02:00
|---------------------------+------------------------------------------------------|
| @code{(run/string form)} | Standard output as a single trimmed string |
| @code{(run/strings form)} | Standard output split into a vector of lines |
| @code{(run/status form)} | Process exit code as an integer |
| @code{(run/output form)} | Tuple of exit code and standard output string |
| @code{(run form)} | A running @code{Proc} handle with streaming I/O ports |
2026-10-01 11:36:18 +02:00
}
All of these functions return a @code{Result} and execute synchronously, blocking the calling thread until completion. When running inside a bjoroutine, non-blocking asynchronous alternatives are available; refer to the @code{(std run)} module documentation for details.
2026-10-01 11:36:18 +02:00
@subsection{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.
2026-10-01 11:36:18 +02:00
@subsubsection{Bjoroutines}
Functions that may suspend execution—whether waiting on a channel, sleeping, or performing asynchronous I/O—are declared with @code{defbjo} rather than @code{defun}, and their type signatures use the @code{-bjo->} arrow. Suspending and waiting for an event is performed using @code{sync}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(: think (-bjo-> int int))
(defbjo (think n)
(sync (timeout 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 @code{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.
2026-10-01 11:36:18 +02:00
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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
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, @code{main} can itself be defined with @code{defbjo}.
2026-10-01 11:36:18 +02:00
In practice, this coloring is less infectious than in many other languages. Most standard I/O functions (such as @code{file-read-text}) are automatically emitted with dual implementations: a synchronous blocking path and an asynchronous suspending path. An ordinary @code{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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(: 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)
}|
@subsubsection{spawn and channels}
The @code{(spawn (f args ...))} form creates a new concurrent fiber running the invocation @code{(f args ...)}. Arguments are evaluated eagerly in the spawning fiber before transferring execution to the new fiber.
2026-10-01 11:36:18 +02:00
Fibers communicate over channels created with @code{make-chan}. Rather than performing immediate I/O, expressions like @code{(chan-send ch v)} and @code{(chan-recv ch)} construct first-class *events*, which are then synchronized using @code{sync}. Unbuffered channels operate as rendezvous points: sending blocks until a receiver is ready, and receiving blocks until a sender arrives.
2026-10-01 11:36:18 +02:00
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 (@code{main}) draws cards round-robin from each seat:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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)
}|
@codeblock[#:lang "text"]|{
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.
2026-10-01 11:36:18 +02:00
@subsubsection{Events are values}
Crucially, calling @code{(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 @code{sync}. Because events are first-class values, they can be parameterized, composed, and transformed before being awaited:
2026-10-01 11:36:18 +02:00
@read-table{
| Function | Description |
2026-10-01 11:36:18 +02:00
|---------------------------+-------------------------------------------------------------|
| @code{(chan-recv ch)} | Receives a value from channel @code{ch} |
| @code{(chan-send ch v)} | Sends @code{v} on channel @code{ch} |
| @code{(timeout ms)} | Fires after @code{ms} milliseconds |
| @code{(choose e1 e2 ...)} | Fires when the first of the supplied events occurs |
| @code{(wrap e f)} | Fires when @code{e} occurs, applying @code{f} to its value |
2026-10-01 11:36:18 +02:00
}
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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(: within (-> int (Event %a) (Event (Option %a))))
(defun (within ms ev)
(choose (wrap ev #(Some &))
(wrap (timeout ms) (fun (u) None))))
(: slow-player (-bjo-> (Chan string) void))
(defbjo (slow-player seat)
(sync (timeout 200))
(sync (chan-send seat "7♣")))
(defbjo (main)
(def seat (make-chan))
(spawn (slow-player seat))
(println (sync (within 50 (chan-recv seat)))) ;; None
(println (sync (within 500 (chan-recv seat)))) ;; (Some 7♣)
0)
}|
Notice that @code{within} is defined with standard @code{defun} rather than @code{defbjo}. Simply constructing or composing event values does not suspend execution; suspending only occurs when an event is submitted to @code{sync}.
2026-10-01 11:36:18 +02:00
Also notice how the first timeout behaves: when the 50 ms deadline expires before the card arrives, the @code{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.
2026-10-01 11:36:18 +02:00
@subsubsection{Collecting fiber results: bjo}
2026-10-01 11:36:18 +02:00
While @code{spawn} runs a fiber for its side effects and discards its return value, @code{bjo} returns a joinable promise. You can await this promise using @code{(promise-join p)}, which yields a @code{Result} wrapping either the computed value or an exception if the fiber failed:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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.
2026-10-01 11:36:18 +02:00
@subsubsection{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.
2026-10-01 11:36:18 +02:00
The top-level @code{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:
2026-10-01 11:36:18 +02:00
@read-table{
| Form | Description |
2026-10-01 11:36:18 +02:00
|---------------------------------------+------------------------------------------------------------|
| @code{(with-scope body ...)} | Waits for child fibers and finalizes open file ports |
| @code{(with-cancel (cancel) body ...)} | Binds a @code{cancel} procedure to signal child fibers |
| @code{(with-deadline ms body ...)} | Cancels the scope automatically after @code{ms} ms |
| @code{(with-shield body ...)} | Shields cleanup operations from parent cancellation |
2026-10-01 11:36:18 +02:00
}
@codeblock[#:lang "bjolang"]|{
(: fetch (-bjo-> string void))
(defbjo (fetch name)
(sync (timeout 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 1000
(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.
2026-10-01 11:36:18 +02:00
Depending on the desired lifecycle and supervision strategy, fibers can be started using four distinct forms:
2026-10-01 11:36:18 +02:00
@read-table{
| Form | Scope behavior |
2026-10-01 11:36:18 +02:00
|------------------------------------+----------------------------------------------------------|
| @code{(spawn (f x))} | Awaited by scope; propagates failure on error |
| @code{(bjo (f x))} | Awaited by scope; yields a promise holding value/failure |
| @code{(spawn/daemon (f x))} | Cancelled on scope exit without being awaited |
| @code{(spawn/detached (f x))} | Runs detached; unmonitored by the enclosing scope |
2026-10-01 11:36:18 +02:00
}
Because scope forms suspend until their child fibers conclude, they must be called from within a bjoroutine.
2026-10-01 11:36:18 +02:00
@subsubsection{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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
;; 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 @code{defun} cannot suspend with @code{sync}, it can block its caller thread using @code{sync/blocking}. Avoid calling @code{sync/blocking} inside a bjoroutine, as doing so freezes a thread pool worker that other fibers rely on:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(: wait-for-card (-> (Chan Card) Card))
(defun (wait-for-card ch)
(sync/blocking (chan-recv ch)))
}|
@subsection{Effects}
Our deck is shuffled using @code{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: *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.
2026-10-01 11:36:18 +02:00
@subsubsection{Declaring an effect}
@codeblock[#:lang "bjolang"]|{
(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))
}|
@code{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 @code{with-handler}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{with-handler} block, including inside deeply nested helper calls and child fibers, without requiring @code{deal-hand} to pass any parameters around.
2026-10-01 11:36:18 +02:00
Several key semantics govern effect handlers:
2026-10-01 11:36:18 +02:00
@read-list{
- Default implementations: If an effect omits @code{#:default} and 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 @code{->} can be invoked from any standard function. Operations declared with @code{-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 @code{(handler-of op)}:
2026-10-01 11:36:18 +02:00
}
@codeblock[#:lang "bjolang"]|{
(with-handler ((log (let ((outer (handler-of log)))
(fun (s) (outer (str "[game] " s))))))
(log "dealing")) ;; "[game] dealing" on stderr
}|
@subsubsection{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:
2026-10-01 11:36:18 +02:00
@read-table{
| Operation | Default |
|----------------------------+----------------------------------------------------------|
| @code{(log s)} | Writes a line to standard error |
| @code{(warn s)} | Writes a warning line to standard error |
| @code{(getenv name)} | Reads an environment variable as an @code{Option} |
| @code{(monotonic-ms)} | High-resolution monotonic clock for elapsed timing |
| The @code{FS} operations | Physical filesystem operations |
2026-10-01 11:36:18 +02:00
}
Because all prelude file I/O routes through the built-in @code{FS} effect operations, tests can replace the entire filesystem with an in-memory mock using @code{with-fake-fs} (covered in the Testing section below).
2026-10-01 11:36:18 +02:00
Similarly, you can capture diagnostic output in memory instead of dumping it to the terminal by installing a custom handler for @code{log}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{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 @code{with-handler} block so the handler remains active until all fibers finish writing.
2026-10-01 11:36:18 +02:00
@subsection{.NET interop}
2026-10-01 11:36:18 +02:00
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.
2026-10-01 11:36:18 +02:00
@subsubsection{Methods: import/extern}
For example, we can render suit symbols in color in the terminal using .NET's @code{System.Console}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{import/extern} specifies a local Bjolang identifier, the fully qualified CLR member, and its signature. For overloaded methods (such as @code{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 @code{#:get} and modified with @code{#:set}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(import/extern
(trim (: System.String.Trim (-> string string)))
(max-int (: System.Int32.MaxValue #:get)))
(trim " hi ") ;; "hi"
max-int ;; 2147483647
}|
Avoid binding an external method to the name of an existing prelude function. Locally bound names and prelude symbols take precedence over @code{import/extern} declarations, which means an alias named @code{println} would simply be shadowed and ignored.
2026-10-01 11:36:18 +02:00
@subsubsection{Classes: import/class}
The @code{import/class} form maps a .NET class to a Bjolang type, exposing its constructor as a function suffixed with a dot (e.g. @code{StringBuilder.}). CLR enums map similarly, with individual members accessed using dot notation like @code{Type.Member} (seen with @code{ConsoleColor.Red} above).
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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"
}|
As shown in the example above, you do not need to declare every method up front with @code{import/extern}. Once an object's type is known to the type checker, you can invoke any public method using @code{(.Method obj args ...)} and inspect properties using @code{(.-Property obj)}. Because @code{Append} returns the builder itself and Bjolang enforces explicit handling of return values, discard unused returns intentionally using @code{ignore}.
2026-10-01 11:36:18 +02:00
@subsubsection{When .NET fails}
Idiomatic .NET code reports errors by throwing exceptions. Bjolang provides three distinct ways to translate these exceptions into safe value types:
2026-10-01 11:36:18 +02:00
@read-list{
- Generic @code{try} blocks: Wrap arbitrary code in @code{try}, as introduced in Part 1.
- Exception-mapped constructors: Annotating a constructor with @code{#:exceptions} in @code{import/class} causes the constructor to return a @code{Result} instead of throwing on specified exceptions.
- Output parameter conversion: Common .NET methods adhering to the @code{bool TryParse(..., out T result)} pattern automatically transform into Bjolang functions that return an @code{(Option T)}.
2026-10-01 11:36:18 +02:00
}
@codeblock[#:lang "bjolang"]|{
(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
}|
@subsubsection{Async methods, and blocking calls}
2026-10-01 11:36:18 +02:00
A .NET method returning a @code{Task} or @code{Task<T>} can be imported using the @code{#: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 @code{await} required:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{#: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 @code{(sync (blocking (fun () ...)))}.
2026-10-01 11:36:18 +02:00
@subsubsection{Generics}
Generic .NET types are imported by parameterizing them with type variables, and generic methods specify those type variables in their signature:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{Func<A, B>} corresponds to @code{(-> %a %b)}, @code{IEnumerable<T>} maps to @code{(Seq %a)}, C# tuples map to @code{Tuple}, and native arrays @code{T[]} correspond to @code{(Array %a)}. Note that constructing generic .NET classes dynamically and pattern matching on .NET objects via constructor patterns are not currently supported.
2026-10-01 11:36:18 +02:00
@subsubsection{Culture and formatting}
2026-10-01 11:36:18 +02:00
Bjolang programs execute using the invariant culture by default. For example, floating-point numbers format with a decimal point (@code{2.5}), avoiding regional discrepancies on machines where local culture settings would otherwise produce @code{2,5}. If your application requires localized formatting, you can configure @code{CultureInfo.CurrentCulture} explicitly.
2026-10-01 11:36:18 +02:00
@subsubsection{Calling Bjolang from C#}
2026-10-01 11:36:18 +02:00
Every Bjolang module compiles to a public static class named @code{<module>_Module}, exposing top-level functions defined with @code{defun} as static methods and @code{def} values as static fields. Identifiers with characters invalid in C# are automatically mangled (for example, hyphenated names like @code{full-deck} become @code{fullsubdeck}), while collections remain Bjolang's persistent data structures. You can inspect the emitted C# source code directly by passing the debug flag: @code{bjo build -d}, which generates @code{out.cs}.
2026-10-01 11:36:18 +02:00
@subsection{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 @code{cond}, @code{when}, @code{type/derive}, and @code{with-test}—are actually implemented as standard library macros.
2026-10-01 11:36:18 +02:00
@subsubsection{def/macro}
Macros are defined using @code{def/macro}. A macro transformer accepts three parameters: @code{form}, @code{inject}, and @code{compare}. Most macros are written using pattern matching via @code{syntax-match} from the @code{(std syntax-match)} library:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{syntax-match} form decomposes syntax by structural shape. A wild-card pattern @code{_} matches the macro name itself, variable names bind matched syntax fragments, and ellipsis @code{...} matches repeated sequences. Templates are constructed using syntax quotation @code{#'(...)}, where comma @code{,x} unquotes an individual syntax value and @code|{,@xs}| splices a list of syntax objects into the form. Malformed invocations can be rejected at compile time using @code{syntax-error}, which highlights the offending source location.
2026-10-01 11:36:18 +02:00
Pattern fragments like @code{((name value) ...)} match lists of pairs and bind @code{name} and @code{value} to lists of syntax elements. Quoted symbols such as @code{'else} match that literal identifier.
2026-10-01 11:36:18 +02:00
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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
'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, @code{cardmacros.bjo}. Importing that module with @code{(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.
2026-10-01 11:36:18 +02:00
The input passed to a macro is a value of type @code{Syntax}, a union type with variants like @code{(SSym s)} for symbols, @code{(SInt text)} for numbers, and @code{(SList items)} for parenthesized forms. Because macros are ordinary Bjolang functions, you can invoke helper functions, inspect @code{Syntax} records directly, and use recursion.
2026-10-01 11:36:18 +02:00
@subsubsection{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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
;; (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 @code{tmp} and the caller's outer variable @code{tmp} are treated as two distinct symbols, allowing the expression to evaluate correctly to @code{9}. Furthermore, free identifiers referenced in the template (such as @code{let}, @code{if}, and @code{=}) always resolve to their prelude definitions, even if the calling scope has shadowed those names locally.
2026-10-01 11:36:18 +02:00
If a macro intentionally needs to introduce an unhygienic binding into the caller's environment, it can explicitly opt out of renaming using @code{(inject 'name)}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
;; (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"))
}|
@subsubsection{Splicing multiple definitions: begin}
2026-10-01 11:36:18 +02:00
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 @code{(begin ...)} form, whose child elements are spliced directly into the enclosing scope:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
;; (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 @code{count} is generated hygienically and cannot collide with any @code{count} variable in the importing module, while @code{next-id} takes on the identifier provided by the caller.
2026-10-01 11:36:18 +02:00
@subsubsection{Pattern macros: def/pattern}
The @code{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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
;; 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 @code{or} pattern just as if it had been written by hand.
2026-10-01 11:36:18 +02:00
@subsubsection{Hash macros: def/hash-extend}
Literal prefixes like @code{#map(...)} are reader-level hash macros. You can register custom hash literals using @code{def/hash-extend}. For example, we can implement a custom literal syntax for playing cards, converting ranks and suits into their typed constructors:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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)"))))
}|
@codeblock[#:lang "bjolang"]|{
(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))) ;; True
(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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
The hash macro 'card' failed at bad.bjo:3: a suit is clubs, diamonds, hearts or spades — in hurts
}|
Notice the @code{re-export} in @code{cardmacros.bjo}: because the macro expansion emits constructor symbols like @code{Card} and @code{Queen}, those types must be resolvable in the module invoking the macro. Re-exporting them ensures that importing @code{cardmacros.bjo} alone brings the required types into scope. Importing @code{deck.bjo} alongside it remains recommended so that the corresponding @code{->str} implementations are also present.
2026-10-01 11:36:18 +02:00
@subsubsection{Literals that elaborate into your unions}
Many embedded domain-specific languages do not require macros at all. In contexts where the type checker expects a specific union type, quoted lists can elaborate directly into union cases. Union cases specify their literal keyword tag using @code{#:tag}:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(type (: Action (Union (: Play int #:tag play)
(: Say string #:tag say)
(: Pass #:tag 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))
}|
Because elaboration is handled by the type checker rather than a syntactic macro, misspelled tags or invalid argument counts produce standard type errors:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
Type Error at elab2.bjo:5: `sya` is not a tag of the union elab2/Action and no case of it
carries a list, so this literal cannot be one. Its tags are play, say or pass.
}|
This elaboration mechanism only applies in positions where the type checker already knows that a specific union type is expected (which is why @code{script} and @code{more} include explicit type signatures). Standard library modules like @code{(std run)} and @code{(std fmt)} use this exact technique for command pipelines and text layouts.
@subsubsection{Macro limitations}
2026-10-01 11:36:18 +02:00
When designing macros, keep the following constraints in mind:
2026-10-01 11:36:18 +02:00
@read-list{
- 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 @code{def/pattern}). They cannot be used as loop clauses, let bindings, or type annotations.
- Macros cannot override built-in special forms; a macro named @code{if} will 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.
2026-10-01 11:36:18 +02:00
}
@subsection{Testing and the REPL}
@subsubsection{Tests with (std simpletest)}
Bjolang includes a lightweight test harness in @code{(std simpletest)}. Its core assertions are straightforward: @code{(expect label actual wanted)} checks equality between values, @code{(expect-true label ok)} asserts a boolean condition, and @code{(fail label)} explicitly flags an unreachable branch. Passing checks output @code{ok: label}, while test failures emit descriptive @code{FAILURE:} diagnostics:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
;; 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)
}|
@codeblock[#:lang "text"]|{
$ 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: @code{FAILURE: deliberately wrong gave 2 but wanted 3}.
2026-10-01 11:36:18 +02:00
For asynchronous or resource-intensive testing, @code{(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 @code{with-test} forms a concurrency scope, it must be invoked from within a bjoroutine.
2026-10-01 11:36:18 +02:00
You can also mock filesystem interactions using @code{(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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "bjolang"]|{
(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 @code{with-fake-fs} is implemented via algebraic effects, test modules using it must also import @code{(std effect)}. In standard project layouts, test files live in @code{tests/} and can be executed individually using @code{bjo run tests/<test-name>.bjo}.
2026-10-01 11:36:18 +02:00
@subsubsection{The REPL}
You can launch an interactive Read-Eval-Print Loop by running @code{bjo repl}. When invoked inside a project directory, the REPL automatically discovers and allows importing any of the project's local modules. The REPL delegates terminal line editing to @code{rlwrap} if it is present on your system. Submitting an expression evaluates it and prints the result, while entering definitions compiles them and prints the bound names:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
bjo> (+ 1 2)
3
bjo> (import "deck.bjo")
bjo> (vec-slice (full-deck) 0 3)
[2♣ 3♣ 4♣]
bjo> (defun (double (: x int)) : int (* x 2))
double
bjo> (double 21)
42
}|
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.
2026-10-01 11:36:18 +02:00
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 @code{defun}, or submit a standalone signature on its own line immediately preceding the definition:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
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:
2026-10-01 11:36:18 +02:00
@codeblock[#:lang "text"]|{
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 (@code{impl}): an implementation entered at the prompt is visible to subsequent entries, but cannot retroactively modify calls compiled earlier. Type @code{:help} to view available REPL commands, or press @code{Ctrl-D} (or type @code{:quit}) to exit.