The manual as a site: a landing page, the chapters, and a page per module

index.sz is a landing page pointing to the manual, now manual.sz, and to
modules/, where each library module has a page made by @generate-docs from
the docs it was compiled with. The chapters gain appendices, and part III
links each module to its page. glasnost's page cache is ignored.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-5-5)

Co-Authored-By: eca-agent <git@eca.dev>
This commit is contained in:
Linus Björnstam 2026-10-01 18:37:22 +02:00
parent e54bd31d69
commit 27c7d64641
24 changed files with 759 additions and 241 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
_cache/

View file

@ -18,13 +18,13 @@ Bjolang compiles to c#. Many of the design decisions behind bjolang stems from t
This can then be run using @code{bjo run filename}, and it should print @code{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 unction, and thus has to declare it's 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 unit. unit is the name for nothing.
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 unit. unit is the name for nothing.
@subsection{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:
@codeblock[#:lang "bjolang"]{
(: usermap (Map Keyword String))
(: usermap (Map Keyword string))
(def usermap #map((:admin "Linus") (:moderator "Sunil") (:janitor "Björnstam")))
}
@ -32,8 +32,8 @@ You can also declare your own types. Either aliases of other types, records of y
@codeblock[#:lang "bjolang"]{
(type
(: Suit (Union Hearts Diamons Clubs Spades))
(: Card (Struct (: rank byte) (: suit Suit))
(: Suit (Union Hearts Diamonds Clubs Spades))
(: Card (Struct (: rank byte) (: suit Suit)))
(: User (Record (: name string)
(: age byte)
(: favourite-playing-card Card)))))
@ -59,11 +59,10 @@ This can then be destructed using pattern matching.
(_ (panic! "This should not happen."))))
}
@subsubsection{Editor support}
Bjolang was written in emacs, and emacs is currently the only editor with limited bjolang support through the file bjomode.el in the main github repository. It lacks any fancy thins, but handles a modicum of indentation and syntax highlighting.
Bjolang was written in emacs, and emacs is currently the only editor with limited bjolang support through the file bjomode.el in the main github repository. It lacks any fancy things, but handles a modicum of indentation and syntax highlighting.
@subsection{Values, types and signatures}
These are the bjolang literals:
@ -182,9 +181,29 @@ Generics can also be constrained to work on Traits. For example this signature,
(: (-> (Tuple %a %b) (Vec %a) bool) (where (Eq %a)))
}
@subsubsection{Local functions}
@subsubsection{Local functions and inline signatures}
A local function looks exactly like a regular functions, but does not need a type signature. In fact, it does not even support (: ...) signatures. When needed you can use inline signatures, which TODO: WRITE INLINE SIGNATURES EXPLANATION.
A local function defined inside another function looks like a top-level @code{defun}, but does not need a separate @code{(: ...)} type signature because local types are inferred automatically with Hindley-Milner type inference. In fact, local functions do not support separate @code{(: ...)} signatures at all.
When you want to document types explicitly or constrain inference, you can write the signature inline directly on the @code{defun} by annotating each parameter with @code{(: param Type)} and following the parameter list with @code{: ReturnType}:
@codeblock[#:lang "bjolang"]{
(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:
@codeblock[#:lang "bjolang"]{
(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 (@code{where} clauses) and keyword argument defaults cannot be written inline and require top-level signatures.
@subsection{Bindings and control flow}

View file

@ -1,13 +1,13 @@
@section{Writing programs}
The first part was about the language itself, one file at a time. This part is about everything around it: splitting a program into modules, making it a project that can depend on other people's code, reading and writing files, doing several things at once, talking to .NET, extending the language with macros, and testing what you wrote.
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.
The card game comes along. By the end of this part it is a project with a deck module, a high-score file, players that play concurrently, and a @code{#card(Q hearts)} literal.
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.
@subsection{Modules}
Every @code{.bjo} file is a module. A module decides what other modules get to see by exporting it, and everything it does not export stays private. That is the whole model: there are no namespaces inside a file, and no module declaration at the top. The file name is the module name.
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.
@subsubsection{Splitting the game into files}
@ -72,16 +72,18 @@ And the game, in @code{game.bjo} next to it:
0)
}|
@code{bjo run game.bjo} prints something like @code{Your hand: [3♥ 10♠ 8♥ 10♦ 7♣]}. A few things to notice:
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:
@read-list{
- @code{(import "deck.bjo")} is a path, relative to the file that imports it. The imported file is compiled to @code{deck.dll} first, and rebuilt whenever it (or anything it includes) changes.
- Exporting a type exports all of it: the union's cases, the struct's constructor and its fields. @code{game.bjo} can write @code{(Card (rank Ace) (suit Spades))} and match on it.
- The @code{->str} implementations were not in the export list, and still work in @code{game.bjo}. An @code{impl} has no name, so it cannot be exported. It travels with the module that wrote it.
- @code{all-suits} was not exported, so @code{game.bjo} cannot see it.
- 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.
}
Every exported binding needs a signature in the module that defines it, since the signature is what gets written into the compiled @code{.dll} for other modules to read:
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}:
@codeblock[#:lang "text"]|{
Export Error: Exported item 'helper' is missing a mandatory type signature at bad1.bjo:1
@ -89,7 +91,7 @@ Export Error: Exported item 'helper' is missing a mandatory type signature at ba
@subsubsection{The standard library, and the prelude}
A module path in a list, like @code{(std random)}, names a module of the standard library. These are found relative to the compiler installation, not the current directory, so @code{(std random)} means the same file wherever you run @code{bjo} from. Some of them:
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:
@read-table{
| Module | What it has |
@ -106,7 +108,7 @@ A module path in a list, like @code{(std random)}, names a module of the standar
| @code{(text json)} | JSON |
}
The prelude, @code{(std prelude)}, is imported into every module without asking. That is where @code{println}, @code{list-map} and nearly everything in the first part comes from. If you want to get rid of a name from it, import it yourself with a modifier (modifiers are below), and the implicit import is dropped:
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:
@codeblock[#:lang "bjolang"]|{
(import (except (std prelude) list-map))
@ -114,9 +116,9 @@ The prelude, @code{(std prelude)}, is imported into every module without asking.
@subsubsection{Exporting types}
A type is only visible to an importer if it is in the export list. A module cannot export a function whose signature mentions a type it keeps to itself, since the importer would have no way to read that signature. That is an error where the library is built, not where it is used.
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.
Sometimes you want importers to be able to hold on to a value without being able to look inside it. That is what @code{#:opaque} is for. Here is a pile of cards, in @code{pile.bjo}, that you can only draw from the top:
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:
@codeblock[#:lang "bjolang"]|{
(import "deck.bjo")
@ -139,7 +141,7 @@ Sometimes you want importers to be able to hold on to a value without being able
([top rest ...] (Some (Tuple top (Pile (cards rest)))))))
}|
Another module can call @code{new-pile}, @code{draw} and @code{pile-size}, and write @code{Pile} in its signatures, but it cannot build one with @code{(Pile ...)}, match on it, or read its fields:
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:
@codeblock[#:lang "text"]|{
Type Error at game3.bjo:4: 'cards' cannot be read here. pile/Pile is exported #:opaque,
@ -147,11 +149,11 @@ so its representation is visible only to the code of pile. A value of it can be
passed on here, and built and taken apart through the functions that module exports.
}|
The @code{impl}s of an opaque type still work everywhere, so if @code{pile.bjo} had an @code{(impl (->str Pile) ...)}, printing a pile would work in any module.
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.
@subsubsection{Import modifiers}
An import can be wrapped in modifiers that decide what the names it brings in are called, and which of them arrive:
Imports can be wrapped in modifiers to filter which definitions are brought into scope or rename them to prevent naming collisions:
@read-table{
| Modifier | Does |
@ -176,9 +178,9 @@ An import can be wrapped in modifiers that decide what the names it brings in ar
0)
}|
Modifiers nest, and are read inside out: @code{(prefix (except (std set) set-map) "s/")} drops @code{set-map} and prefixes the rest.
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/}.
@code{only} and @code{except} work on functions and macros, never on types. The types a module exports always arrive, because the signatures of the functions that do arrive might need them:
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:
@codeblock[#:lang "bjolang"]|{
(import (only "deck.bjo" full-deck)
@ -190,7 +192,7 @@ Modifiers nest, and are read inside out: @code{(prefix (except (std set) set-map
0)
}|
A type belongs to the module that declared it. If two libraries both declare a @code{Card}, those are two different types, and the bare name @code{Card} means the one from the later import. @code{prefix-types} gives each of them a name of its own:
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}:
@codeblock[#:lang "bjolang"]|{
(import (prefix-types "poker.bjo" "P/")
@ -201,17 +203,17 @@ A type belongs to the module that declared it. If two libraries both declare a @
@subsubsection{Which name wins}
When two things have the same name, three rules decide, in this order:
When multiple definitions share the same identifier, name resolution follows three precedence rules:
@read-list{
- A name defined in the module itself wins over anything imported.
- Between two imports, the later one wins, silently. This is what lets any module override the prelude: the prelude is always the first import.
- A name that a modifier or an alias made up may not collide with anything else. That is an error, since you asked for that name by hand and two answers mean the request was ambiguous.
- 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.
}
@subsubsection{re-export and :alias}
@code{export} publishes what a module defines. @code{re-export} publishes something the module imported, which is how you write a module that gathers several others behind one import. The prelude passes on @code{=}, @code{compare} and @code{list-sort} from @code{(std eq)} this way. For the game:
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:
@codeblock[#:lang "bjolang"]|{
;; cards.bjo: one import for everything about cards
@ -220,9 +222,9 @@ When two things have the same name, three rules decide, in this order:
(re-export Card Rank Suit full-deck shuffle Pile new-pile draw)
}|
A re-exported type is the same type, not a copy, so a @code{Card} made through @code{cards.bjo} is a @code{Card} to @code{deck.bjo} as well. The @code{impl}s do not come along though: they travel with the module that wrote them, so a program that wants to print cards has to import @code{deck.bjo} too.
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}.
@code{(:alias new old)} gives a binding or a macro a second name. Together with a re-export or an export, that is how a library publishes something under a nicer name than it was written under:
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:
@codeblock[#:lang "bjolang"]|{
(:alias deal full-deck)
@ -231,7 +233,7 @@ A re-exported type is the same type, not a copy, so a @code{Card} made through @
@subsubsection{include}
@code{(include "file.bjo")} pastes the file's forms in where the include is, as if you had written them there. No module is made, nothing needs exporting, and everything in the included file is in scope directly:
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:
@codeblock[#:lang "bjolang"]|{
;; helpers.bjo
@ -247,13 +249,13 @@ A re-exported type is the same type, not a copy, so a @code{Card} made through @
0)
}|
The rule of thumb: use @code{include} to split one module that has grown too big across several files, and @code{import} when the other file is a module in its own right, with its own idea of what is public.
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.
@subsection{Projects and packages}
So far every program has been a file, and the files it imports are found by path. That works fine for small things. For anything that depends on someone else's code, or that you want others to depend on, make it a project.
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 a directory with a @code{manifest.bjodat} in it. Inside a project @code{bjo} knows what your code is called, what it needs and where to get it. Outside one, every command works on the file you give it, exactly like before.
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.
@subsubsection{Making a project}
@ -263,18 +265,18 @@ bjo init
bjo run
}|
@code{bjo init} makes this:
Running @code{bjo init} scaffolds a new project layout:
@codeblock[#:lang "text"]|{
cards/
manifest.bjodat what the package is called, and what it needs
manifest.bjodat package metadata and dependencies
src/
main.bjo the program
main.bjo executable entry point
tests/
.gitignore
}|
and the manifest says what the package is called:
The generated manifest defines the package identity:
@codeblock[#:lang "bjolang"]|{
(package
@ -282,7 +284,7 @@ and the manifest says what the package is called:
(version "0.1.0"))
}|
All the modules of a package live in @code{src/}, and a module's name is its path below it. So if you move @code{deck.bjo} into @code{src/}, it becomes the module @code{(cards deck)}, and @code{src/main.bjo} imports it like any library module:
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:
@codeblock[#:lang "bjolang"]|{
(import (cards deck))
@ -293,26 +295,26 @@ All the modules of a package live in @code{src/}, and a module's name is its pat
0)
}|
@code{bjo} finds the project by looking upwards for a manifest, so every command works from anywhere inside it.
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:
@read-table{
| Command | What it does |
|---------------------------+----------------------------------------------------------------|
| @code{bjo init [--lib]} | makes a project in the current directory |
| @code{bjo fetch} | fetches the dependencies, compiles nothing |
| @code{bjo build} | fetches, then compiles the program, or every module of a library |
| @code{bjo run . args ...} | builds, then runs the program with the arguments |
| @code{bjo check} | reports every error in the project, and builds nothing |
| @code{bjo repl} | the REPL, where the project's modules can be imported |
| @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 |
}
The @code{.} in @code{bjo run . one two} stands for "the project". Everything after the file (or the dot) is passed to your program, so here @code{args} is @code{[one two]}. A file named inside a project, like @code{bjo run tests/deck-test.bjo}, is compiled against the project's packages, which is how you run tests.
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.
@subsubsection{Libraries}
A package whose entry file, @code{src/main.bjo}, does not exist is a library. @code{bjo init --lib} makes one, and @code{bjo build} in it compiles every module under @code{src/}.
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/}.
So let us make the deck a library of its own, in a directory next to the game:
To see this in action, we can split out our card deck into a separate library residing alongside the game:
@codeblock[#:lang "text"]|{
mkdir cardlib && cd cardlib
@ -320,7 +322,7 @@ bjo init --lib
mv ../cards/src/deck.bjo src/
}|
The game then depends on it by path, in @code{cards/manifest.bjodat}, and imports it as @code{(cardlib deck)}:
We can then declare a path dependency in @code{cards/manifest.bjodat} and import the library module as @code{(cardlib deck)}:
@codeblock[#:lang "bjolang"]|{
(package
@ -339,11 +341,11 @@ The game then depends on it by path, in @code{cards/manifest.bjodat}, and import
0)
}|
A path is relative to the manifest that names it. A path dependency has no version: what is in the directory is what you get, which is what you want while you are working on both.
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.
@subsubsection{Dependencies from git}
When the library is published somewhere, depend on it through git instead:
Once a library is hosted in a Git repository, you can switch from a local path to a Git dependency:
@codeblock[#:lang "bjolang"]|{
(depends
@ -352,17 +354,17 @@ When the library is published somewhere, depend on it through git instead:
(source (git (url "https://github.com/someone/cardlib")))))
}|
A version is a git tag of exactly the form @code{vX.Y.Z}, and a git dependency must say which versions it takes. There are three ways to say it: @code{(version-at-least "1.2")}, @code{(version-between "1.2" "2.0")} and @code{(version-at-most "2.0")}.
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")}.
Versions are chosen by minimal version selection, which is simpler than it sounds. Every package in the graph says the lowest version it needs of each of its dependencies, and the version picked is the highest of those. Not the newest version that exists, but the lowest one that everyone who asked can live with. This means that a new release of a dependency changes nothing in your build until some manifest asks for it, and that the same manifests build the same code a year from now. An upper bound is only ever checked, never used to pick an older version.
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.
@code{bjo fetch} (and @code{build} and @code{run}, which fetch first) clones what is needed into @code{.bjo/} and writes @code{bjo.lock}, with the exact commit each dependency resolved to. Commit the lock file, and do not commit @code{.bjo/} (the generated @code{.gitignore} already leaves it out). If a tag is moved to a different commit after it was locked, the build stops rather than quietly using different code under the same version. @code{--locked} turns any difference from the lock file into an error, which is what you want on a build server.
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.
If your own manifest gives a dependency a source, that source is used everywhere in the graph, including for the dependencies of your dependencies. That is how you swap in a fork, or a path to a local checkout while you debug something.
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.
@subsubsection{NuGet packages}
.NET libraries from NuGet are listed under @code{packages}:
You can pull in third-party .NET packages directly from NuGet by listing them in the @code{packages} section:
@codeblock[#:lang "bjolang"]|{
(package
@ -371,41 +373,43 @@ If your own manifest gives a dependency a source, that source is used everywhere
(packages (nuget (id "Npgsql") (version "9.0.3"))))
}|
Their types are then used like any other .NET type (see the .NET section below). The version means what NuGet says it means: @code{"9.0.3"} is that version or newer, and @code{"[9.0.3]"} is exactly that version. NuGet packages are restored by the .NET SDK, so this needs the @code{dotnet} command, and what they resolved to is recorded in @code{packages.lock.json}, which should also be committed. For now, the built program loads the packages from the NuGet cache, so it only runs on the machine that built it.
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.
@subsubsection{Publishing a package}
Publishing a Bjolang package requires three basic steps:
@read-list{
- Put the modules under @code{src/}, and write a manifest with a @code{name} and a @code{version}.
- @code{bjo build} to check that everything compiles.
- Tag the release, @code{git tag -a v0.1.0 -m "First release"}, and push the tag. A version that is not a @code{vX.Y.Z} tag cannot be depended on.
- 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.
}
@subsection{Files and I/O}
@subsubsection{Whole files}
When a file fits in memory, the simplest thing is to read or write all of it at once:
For files that comfortably fit into memory, the prelude provides straightforward functions to read or write entire files in a single call:
@read-table{
| Function | Does |
|-------------------------------------------+---------------------------------------------------|
| @code{(file-read-text path)} | the whole file, as a string |
| @code{(file-write-text path s)} | writes @code{s}, replacing what was there |
| @code{(file-append-text path s)} | adds @code{s} at the end, creating the file |
| @code{(file-read-lines path)} | the lines, as a @code{(Vec string)} |
| @code{(file-write-lines path lines)} | writes a @code{(Vec string)}, one line each |
| @code{(file-read-bytes path)} | the whole file, as an @code{(Array byte)} |
| @code{(file-exists? path)} | whether there is a file there |
| @code{(file-delete path)} | deletes it; a missing file is not an error |
| @code{(file-copy from to)} | copies, and fails rather than overwrite @code{to} |
| @code{(file-move from to)} | moves a file or a directory |
| @code{(file-info path)} | size, modified time and kind, as an @code{Option} |
| 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} |
}
These throw when something goes wrong, the way the .NET methods under them do. Wrap a call in @code{try} when a failure is something you expect.
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.
Here is a high-score file for the game. Each line is a name and a number, and a line that is not one is skipped rather than crashing the game:
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:
@codeblock[#:lang "bjolang"]|{
(: score-file string)
@ -441,7 +445,7 @@ Here is a high-score file for the game. Each line is a name and a number, and a
@subsubsection{Paths, directories and the environment}
Paths are strings, and the path functions only work on the text. They never touch the disk:
Paths in Bjolang are represented as standard strings. The path manipulation utilities perform purely lexical operations on the string representations without touching the filesystem:
@codeblock[#:lang "bjolang"]|{
(path-combine "a" "b" "c.txt") ;; "a/b/c.txt"
@ -451,7 +455,7 @@ Paths are strings, and the path functions only work on the text. They never touc
(path-absolute "deck.bjo") ;; the full path
}|
The directory functions do touch the disk:
When you do need to inspect or modify the filesystem layout, use the directory functions:
@codeblock[#:lang "bjolang"]|{
(directory-create "saves/2026") ;; creates every missing directory on the way
@ -468,7 +472,7 @@ The directory functions do touch the disk:
(directory-walk "." #:into? (fun (d) (not (= (path-filename d) (Some ".git"))))))
}|
And the environment:
Process environment variables and working directory state can be inspected and updated similarly:
@codeblock[#:lang "bjolang"]|{
(get-environment-variable "HOME") ;; (Some "/home/linus"), or None
@ -478,9 +482,9 @@ And the environment:
@subsubsection{Ports}
For files that are too big to read at once, or for reading and writing a bit at a time, there are ports. A text input port is something you can read lines and characters from, and an output port is something you can write to. A file port and a string port are the same type, so a function that reads from a port does not care where the text comes from.
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.
Opening a file answers a @code{Result}, since a missing file is something you should expect. The @code{Err} holds the real .NET exception, so you can tell the failures apart with @code{:is}:
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}:
@codeblock[#:lang "bjolang"]|{
(match (open-input-file "nope.txt")
@ -489,7 +493,7 @@ Opening a file answers a @code{Result}, since a missing file is something you sh
((Err e) "some other error"))
}|
The easiest way to use a port is to let @code{call-with-input-file} or @code{call-with-output-file} open it, hand it to a function, and close it however that function ends. Here is counting the lines of a file of any size:
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:
@codeblock[#:lang "bjolang"]|{
(: count-lines (-> string (Result Exception int)))
@ -505,24 +509,25 @@ The easiest way to use a port is to let @code{call-with-input-file} or @code{cal
}|
@read-table{
| Function | Does |
| Function | Description |
|-----------------------------------+---------------------------------------------------------|
| @code{(open-input-file path)} | a @code{(Result Exception TextInputPort)} |
| @code{(open-output-file path)} | the same for writing; @code{#:mode} is @code{Truncate}, @code{Append} or @code{CreateNew} |
| @code{(read-line/opt p)} | the next line, or @code{None} at the end |
| @code{(read-char/opt p)} | the next character, or @code{None} at the end |
| @code{(read-all p)} | everything that is left, as one string |
| @code{(port-eof? p)} | whether there is nothing more to read |
| @code{(write-string p s)} | writes @code{s} |
| @code{(writeln p s)} | writes @code{s} and a newline |
| @code{(close-input-port p)} | and @code{close-output-port} |
| @code{(open-input-string s)} | a port that reads from a string |
| @code{(open-output-string)} | a port that collects what is written to it; @code{get-output-string} reads it |
| @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} |
}
@code{read-line} and @code{read-char} also exist without the @code{/opt}. They throw at the end of the input.
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.
@code{(std ports)} has the functions that read a whole port into a collection: @code{port->lines}, @code{port->list}, @code{port->vec} and the lazy @code{port->seq} and @code{file->seq}:
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}:
@codeblock[#:lang "bjolang"]|{
(import (std ports))
@ -530,11 +535,11 @@ The easiest way to use a port is to let @code{call-with-input-file} or @code{cal
(call-with-input-file "scores.txt" port->lines) ;; (Ok ["ada 31" "bo 27"])
}|
A file port belongs to the scope it was opened in (scopes are in the concurrency section), and is closed when that scope ends, whether you closed it or not. @code{main} is a scope, so a port you forget is closed at the latest when the program ends. For anything long-running, close ports yourself or use @code{call-with-input-file}.
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}.
@subsubsection{Running other programs}
@code{(std run)} runs other programs. The command is written as a quoted list, and pipes and redirections are part of the same notation:
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:
@codeblock[#:lang "bjolang"]|{
(import (std run))
@ -548,27 +553,27 @@ A file port belongs to the scope it was opened in (scopes are in the concurrency
(run/strings '(grep ,who "scores.txt")) ;; (Ok ["ada 31"])
}|
@code{,who} puts the value of a variable into the command. Every word is one argument, spaces and all, so there is no quoting to get wrong. @code{pipe}, @code{into-file}, @code{from-file}, @code{append-to-file} and @code{errors-into-file} are part of the notation, and their arguments are checked when the program is compiled. Anything else at the head of a form is the name of a program to run.
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.
@read-table{
| Function | Answers |
| Function | Returns |
|---------------------------+------------------------------------------------------|
| @code{(run/string form)} | what the command wrote to stdout |
| @code{(run/strings form)} | the same, one string per line |
| @code{(run/status form)} | the exit code |
| @code{(run/output form)} | the exit code and stdout |
| @code{(run form)} | a running @code{Proc}, whose input and output are yours |
| @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 |
}
All of them answer a @code{Result}, and all of them block the thread they run on. Inside a bjoroutine there are versions that do not; see the documentation of @code{(std run)}.
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.
@subsection{Concurrency}
Bjolang's concurrency is built on three ideas: lightweight threads called bjoroutines, channels to talk between them, and scopes that make sure nothing started inside them outlives them. The channel operations are Concurrent ML's, which means an operation like "receive from this channel" is a value that can be combined with others before you wait for it.
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.
@subsubsection{Bjoroutines}
A function that may wait for something (a channel, a timer, a file) is defined with @code{defbjo} instead of @code{defun}. Its signature is written with @code{-bjo->}. Waiting is done with @code{sync}:
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}:
@codeblock[#:lang "bjolang"]|{
(: think (-bjo-> int int))
@ -577,9 +582,9 @@ A function that may wait for something (a channel, a timer, a file) is defined w
(* n n))
}|
A bjoroutine runs as a fiber on a pool of threads. When it waits, it gives its thread back, so ten thousand of them waiting on a channel cost ten thousand small objects, not ten thousand threads.
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.
The catch is colour. Calling a bjoroutine is a place where the caller may have to wait too, so only a bjoroutine may call one:
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:
@codeblock[#:lang "text"]|{
Type Error at colour.bjo:7: calling 'think' is a yield point, and a yield point is not allowed here.
@ -589,9 +594,9 @@ Type Error at colour.bjo:7: calling 'think' is a yield point, and a yield point
whoever calls 'twice' needs to be one too.
}|
@code{main} may be a @code{defbjo}, and that is how a program gets into the world of bjoroutines.
To enter concurrent execution from the start, @code{main} can itself be defined with @code{defbjo}.
The colour does not spread as far as you might fear. Most of the prelude's I/O, like @code{file-read-text}, is written so that it has two copies: one that blocks and one that waits. An ordinary @code{defun} that calls one of them gets two copies too, without you writing anything, and a bjoroutine calling it gets the one that waits:
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:
@codeblock[#:lang "bjolang"]|{
(: size-of (-> string int))
@ -604,11 +609,11 @@ The colour does not spread as far as you might fear. Most of the prelude's I/O,
@subsubsection{spawn and channels}
@code{(spawn (f args ...))} starts @code{f} as a new fiber and carries on. The arguments are evaluated where the @code{spawn} is written, and only the call happens in the new fiber.
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.
A channel, made with @code{make-chan}, is how fibers talk. @code{(chan-send ch v)} and @code{(chan-recv ch)} are events, and @code{sync} performs them. A channel is a rendezvous: a send waits until someone receives, and the other way around.
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.
Here are the players of the card game as bjoroutines. Each player gets a seat, which is a channel, and plays one card into it per trick, highest first. The table (@code{main}) takes one card from each seat in turn:
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:
@codeblock[#:lang "bjolang"]|{
(import "deck.bjo")
@ -642,23 +647,23 @@ Trick 3: [6♠ 4♥ 3♣]
Trick 4: [6♦ 3♦ 2♦]
}|
Since each send waits for its receive, a player cannot run ahead and play two cards into the same trick.
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.
@subsubsection{Events are values}
@code{(chan-recv seat)} does not receive anything. It describes a receive that has not happened yet, and only @code{sync} makes it happen. Until then it is a value like any other, and can be passed around and combined:
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:
@read-table{
| Function | Is the event that |
| Function | Description |
|---------------------------+-------------------------------------------------------------|
| @code{(chan-recv ch)} | receives a value from @code{ch} |
| @code{(chan-send ch v)} | sends @code{v} on @code{ch} |
| @code{(timeout ms)} | happens after @code{ms} milliseconds |
| @code{(choose e1 e2 ...)} | happens when the first of its events happens |
| @code{(wrap e f)} | happens when @code{e} does, and answers @code{f} of its value |
| @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 |
}
With those, a deadline is an ordinary function that works on any event at all. A player who takes too long forfeits:
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:
@codeblock[#:lang "bjolang"]|{
(: within (-> int (Event %a) (Event (Option %a))))
@ -679,13 +684,13 @@ With those, a deadline is an ordinary function that works on any event at all. A
0)
}|
Note that @code{within} is a @code{defun}. Building an event is not waiting for it, so no colour is involved until the @code{sync}.
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}.
The part worth noticing is the second line. When the timeout won the first race, the receive was withdrawn, not performed, so the card was not taken off the channel and lost. It was still there for the second try.
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.
@subsubsection{Answers from a fiber: bjo}
@subsubsection{Collecting fiber results: bjo}
@code{spawn} throws away what the function answers. @code{bjo} instead gives you a promise of it, which you can wait for with @code{promise-join}. The answer is a @code{Result}, since the fiber might have failed:
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:
@codeblock[#:lang "bjolang"]|{
(def a (bjo (think 3)))
@ -694,21 +699,21 @@ The part worth noticing is the second line. When the timeout won the first race,
(sync (promise-join b)) ;; (Ok 16)
}|
Both fibers think at the same time, so this takes 50 ms and not 100.
Because both fibers execute concurrently across thread pool workers, the combined operations complete in roughly 50 ms rather than running sequentially for 100 ms.
@subsubsection{Scopes}
Every fiber is started inside a scope, and a scope does not end until every fiber started in it has ended. So when a scope returns, the work started inside it is over. Nothing keeps running in the background by accident, and if a fiber fails, the failure is reported when the scope ends instead of disappearing.
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.
@code{main} is a scope, which is why the players above did not need anything special. You make more of them with these forms:
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:
@read-table{
| Form | Is a scope that |
| Form | Description |
|---------------------------------------+------------------------------------------------------------|
| @code{(with-scope body ...)} | waits for its fibers, and closes its files |
| @code{(with-cancel (cancel) body ...)} | also binds @code{cancel}, which tells its fibers to stop |
| @code{(with-deadline ms body ...)} | is cancelled after @code{ms} milliseconds |
| @code{(with-shield body ...)} | is not cancelled when its parent is; for cleaning up |
| @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 |
}
@codeblock[#:lang "bjolang"]|{
@ -741,24 +746,24 @@ Every fiber is started inside a scope, and a scope does not end until every fibe
0)
}|
Cancelling does not interrupt anything. A cancelled fiber notices the next time it waits for something, and a fiber in a loop that never waits never notices. A fiber that stopped because it was cancelled has not failed; that is how a worker normally ends.
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.
There are four ways to start a fiber, and they differ only in what the scope does about it:
Depending on the desired lifecycle and supervision strategy, fibers can be started using four distinct forms:
@read-table{
| Form | The scope |
| Form | Scope behavior |
|------------------------------------+----------------------------------------------------------|
| @code{(spawn (f x))} | waits for it, and reports it if it fails |
| @code{(bjo (f x))} | waits for it; the promise is yours, and so is the failure |
| @code{(spawn/daemon (f x))} | cancels it when the scope ends, but does not wait for it |
| @code{(spawn/detached (f x))} | has nothing to do with it |
| @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 |
}
The scope forms wait, so they can only be written in a bjoroutine.
Because scope forms suspend until their child fibers conclude, they must be called from within a bjoroutine.
@subsubsection{Work that is not a fiber}
A fiber that blocks its thread, with a .NET call that does not know about fibers, or with a long computation, holds up one of the threads all the other fibers share. There are two ways to move such work off the pool, and both give you an event:
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:
@codeblock[#:lang "bjolang"]|{
;; Work that waits: a call that parks its thread.
@ -768,7 +773,7 @@ A fiber that blocks its thread, with a .NET call that does not know about fibers
(sync (spawn/thread #(loop (:for i (range 0 1000)) (:acc (summing i))))) ;; (Ok 499500)
}|
The other direction also exists. An ordinary @code{defun} cannot @code{sync}, but it can wait for an event by parking its own thread with @code{sync/blocking}. Do not use it inside a bjoroutine, since the thread it parks is one the fibers need:
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:
@codeblock[#:lang "bjolang"]|{
(: wait-for-card (-> (Chan Card) Card))
@ -778,7 +783,9 @@ The other direction also exists. An ordinary @code{defun} cannot @code{sync}, bu
@subsection{Effects}
The deck is shuffled with @code{shuffle-vec}, which is random. That is what you want when playing, and exactly what you do not want in a test that checks who wins a trick. The usual answer is to pass a shuffle function down to everything that needs one. Effects are the other answer: the function performs an operation, and whoever called it decides what the operation does.
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.
@subsubsection{Declaring an effect}
@ -794,7 +801,7 @@ The deck is shuffled with @code{shuffle-vec}, which is random. That is what you
(vec-slice (shuffle-deck (full-deck)) 0 n))
}|
@code{shuffle-deck} is now a function with the declared type, and @code{deal-hand} calls it like any other function. With nobody handling it, it does its default, which here is the real shuffle. A test installs another one with @code{with-handler}:
@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}:
@codeblock[#:lang "bjolang"]|{
(deal-hand 5) ;; five random cards
@ -802,15 +809,15 @@ The deck is shuffled with @code{shuffle-vec}, which is random. That is what you
(deal-hand 5)) ;; [2♣ 3♣ 4♣ 5♣ 6♣], every time
}|
The handler applies to everything called inside the @code{with-handler}, however deep, and to fibers spawned inside it. Nothing had to be passed through @code{deal-hand}.
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.
A few things to know:
Several key semantics govern effect handlers:
@read-list{
- An effect without a @code{#:default} raises an exception when it is performed and nobody handles it. Leave the default out when running without a handler is a mistake.
- A handler is called like a function, and answers once. There are no continuations: it cannot resume twice, or not at all, except by throwing.
- An operation declared with @code{->} can be performed from any function. One declared with @code{-bjo->} can only be performed from a bjoroutine, and needs a bjoroutine as its handler.
- Inside its own handler, performing an operation calls the handler again. @code{(handler-of op)} is the handler that was installed before, which is how a handler adds something and passes the rest on:
- 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)}:
}
@codeblock[#:lang "bjolang"]|{
@ -821,21 +828,21 @@ A few things to know:
@subsubsection{The prelude's own effects}
Some things in the prelude are effects already, so you can handle them without changing the code that uses them:
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:
@read-table{
| Operation | Default |
|----------------------------+----------------------------------------------------------|
| @code{(log s)} | writes a line to stderr |
| @code{(warn s)} | writes a line to stderr |
| @code{(getenv name)} | reads an environment variable, as an @code{Option} |
| @code{(monotonic-ms)} | a clock for measuring time |
| the @code{FS} operations | the real file system |
| @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 |
}
Everything in the prelude that reaches the disk goes through the @code{FS} operations, so a test can replace the whole file system. That is @code{with-fake-fs}, which is in the testing section below.
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).
Collecting the log lines of a piece of code, instead of printing them, is a handler:
Similarly, you can capture diagnostic output in memory instead of dumping it to the terminal by installing a custom handler for @code{log}:
@codeblock[#:lang "bjolang"]|{
(def lines (make-box (list)))
@ -845,15 +852,15 @@ Collecting the log lines of a piece of code, instead of printing them, is a hand
(box-ref lines) ;; '("two" "one")
}|
A @code{with-handler} does not wait for anything, unlike a scope. If the code inside it spawns fibers that log, put the scope that waits for them inside the handler, or you will read the lines before they are written.
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.
@subsection{.NET}
@subsection{.NET interop}
Bjolang compiles to C#, so every .NET library is available. You tell the compiler which methods and classes you want and what their types are, and they become ordinary bjolang functions and types.
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.
@subsubsection{Methods: import/extern}
Let us give the suits colours in the terminal. @code{System.Console} has what is needed:
For example, we can render suit symbols in color in the terminal using .NET's @code{System.Console}:
@codeblock[#:lang "bjolang"]|{
(import "deck.bjo")
@ -884,7 +891,7 @@ Let us give the suits colours in the terminal. @code{System.Console} has what is
0)
}|
Each entry in @code{import/extern} is a name for bjolang, the full .NET name, and a type. Methods with many overloads, like @code{Console.Write}, are resolved from the type you give. An instance method takes the object as its first argument. @code{#:get} and @code{#:set} read and write a property or a field:
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}:
@codeblock[#:lang "bjolang"]|{
(import/extern
@ -895,11 +902,11 @@ Each entry in @code{import/extern} is a name for bjolang, the full .NET name, an
max-int ;; 2147483647
}|
Do not give an import the name of a prelude function. A name the module binds itself, or gets from the prelude, wins over an @code{import/extern} alias, so an alias called @code{println} would never be used.
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.
@subsubsection{Classes: import/class}
@code{import/class} makes a .NET class a bjolang type, and its constructor a function spelled with a dot at the end. Enums work the same way, and their members are written @code{Type.Member}, as with @code{ConsoleColor.Red} above.
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).
@codeblock[#:lang "bjolang"]|{
(import/class
@ -913,16 +920,16 @@ Do not give an import the name of a prelude function. A name the module binds it
(.ToUpper "shout") ;; "SHOUT"
}|
As the last lines show, you do not have to import every method. @code{(.Method obj args ...)} calls a method, and @code{(.-Property obj)} reads a property, as long as the type of @code{obj} is known at that point. @code{Append} answers the builder again, and a value that is thrown away has to be thrown away on purpose, with @code{ignore}.
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}.
@subsubsection{When .NET fails}
.NET methods report failure by throwing. There are three ways to turn that into a value:
Idiomatic .NET code reports errors by throwing exceptions. Bjolang provides three distinct ways to translate these exceptions into safe value types:
@read-list{
- @code{try}, around any code, which was covered in the first part.
- @code{#:exceptions} on a constructor in @code{import/class}, which makes the constructor answer a @code{Result}.
- @code{(out T)} parameters: a @code{TryParse}-style method, which answers a @code{bool} and puts its result in an @code{out} parameter, becomes a function that answers an @code{Option}.
- 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)}.
}
@codeblock[#:lang "bjolang"]|{
@ -941,9 +948,9 @@ As the last lines show, you do not have to import every method. @code{(.Method o
(parse-int "forty-two") ;; None
}|
@subsubsection{Async methods, and ones that block}
@subsubsection{Async methods, and blocking calls}
A .NET method that answers a @code{Task} is imported with @code{#:async}. Calling it from a bjoroutine waits for the task without blocking a thread, and you never write @code{await}:
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:
@codeblock[#:lang "bjolang"]|{
(import/extern
@ -954,11 +961,11 @@ A .NET method that answers a @code{Task} is imported with @code{#:async}. Callin
0)
}|
A method that blocks its thread should be marked @code{#:blocking}. You then get a warning when you call it from a bjoroutine, and the fix is to call it through @code{(sync (blocking (fun () ...)))}, as in the concurrency section.
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 () ...)))}.
@subsubsection{Generics}
A generic .NET type is imported with type variables, and a generic method needs a signature that says what they are:
Generic .NET types are imported by parameterizing them with type variables, and generic methods specify those type variables in their signature:
@codeblock[#:lang "bjolang"]|{
(import/class
@ -969,23 +976,23 @@ A generic .NET type is imported with type variables, and a generic method needs
(-> (Dict %k %v) %k (out %v) (Option %v)))))
}|
Between the two worlds, a @code{Func<A, B>} is a @code{(-> %a %b)}, an @code{IEnumerable<T>} is a @code{(Seq %a)}, a C# tuple is a @code{Tuple} and @code{T[]} is an @code{(Array %a)}. Constructing a generic .NET class is not supported yet, and neither is matching on .NET objects with constructor patterns.
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.
@subsubsection{Culture}
@subsubsection{Culture and formatting}
A bjolang program runs in the invariant culture, so a double is always written @code{2.5}, even on a Swedish machine where .NET would write @code{2,5}. If you want the user's own formatting, set @code{CultureInfo.CurrentCulture} yourself.
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.
@subsubsection{Calling bjolang from C#}
@subsubsection{Calling Bjolang from C#}
A module compiles to a static class called @code{<module>_Module}, with a static method for each @code{defun} and a static field for each @code{def}, so C# can call it. Names that are not valid C# are mangled (@code{full-deck} becomes @code{fullsubdeck}), and the types are bjolang's own collections. @code{bjo build -d} writes the generated C# to @code{out.cs} if you want to see what you would be calling.
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}.
@subsection{Macros}
A macro is a function that the compiler runs while it reads your program. It gets the form you wrote, as data, and answers the form to use instead. Much of what looks like syntax in bjolang, @code{cond}, @code{when}, @code{type/derive} and @code{with-test} among others, is macros from the prelude and the standard library.
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.
@subsubsection{def/macro}
A macro is defined with @code{def/macro}, takes three arguments, @code{form}, @code{inject} and @code{compare}, and is almost always written with @code{syntax-match} from @code{(std syntax-match)}:
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:
@codeblock[#:lang "bjolang"]|{
(import (std syntax-match))
@ -998,11 +1005,11 @@ A macro is defined with @code{def/macro}, takes three arguments, @code{form}, @c
(bad (syntax-error bad "(with-each-card (name deck) body ...)"))))
}|
@code{syntax-match} takes the form apart by shape. @code{_} matches anything (here the macro's own name), a name binds what it matches, and @code{...} after a pattern matches the rest. @code{#'(...)} builds the answer: a template where @code{,x} puts in a piece of syntax, and @code|{,@xs}| splices in a list of them. @code{syntax-error} rejects the input with a message pointing at where the macro was used.
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.
A pattern like @code{((name value) ...)} matches any number of pairs and binds @code{name} and @code{value} to lists, one element per pair. A quoted name, @code{'else}, matches that identifier and nothing else.
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.
A macro cannot be used in the module that defines it. It is compiled code that the compiler runs, so it has to be compiled before the code that uses it is read:
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:
@codeblock[#:lang "text"]|{
'twice' is a macro defined in this module, and a macro cannot be used where it is defined,
@ -1012,13 +1019,13 @@ module of its own and import that. An (include ...) will not do: an included fil
part of this one.
}|
So the macros of the card game go in @code{cardmacros.bjo}, and @code{(import "cardmacros.bjo")} is enough to use them. Macros need no export: every macro in a module arrives with it.
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.
The input to a macro is a value of the type @code{Syntax}, a union with cases like @code{(SSym s)} for an identifier, @code{(SInt text)} for a number and @code{(SList items)} for a parenthesized form. A macro is an ordinary bjolang function, so it can call other functions, match on the @code{Syntax} directly, and recurse.
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.
@subsubsection{Hygiene and inject}
Names that a template introduces are renamed so that they cannot clash with the names at the place the macro is used:
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:
@codeblock[#:lang "bjolang"]|{
;; (or-else a b) is a, unless a is 0.
@ -1031,9 +1038,9 @@ Names that a template introduces are renamed so that they cannot clash with the
(or-else 0 tmp) ;; 9, not 0
}|
The @code{tmp} in the template and the @code{tmp} the caller passed are two different variables, so the answer is the caller's 9. Names the template uses without binding them, like @code{let}, @code{if} and @code{=} here, still mean what they mean in the prelude, even if the calling module has something else by that name.
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.
Sometimes you do want the macro to bind a name the caller can see. @code{(inject 'name)} makes an identifier that is not renamed:
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)}:
@codeblock[#:lang "bjolang"]|{
;; (aif test then else): in `then`, `it` is what was inside the Some.
@ -1049,9 +1056,9 @@ Sometimes you do want the macro to bind a name the caller can see. @code{(inject
(println "nothing"))
}|
@subsubsection{Several forms: begin}
@subsubsection{Splicing multiple definitions: begin}
A macro answers one form. To define several things, answer a @code{(begin ...)}, whose contents are spliced in where the macro was used:
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:
@codeblock[#:lang "bjolang"]|{
;; (def/counter name) defines a function that counts how often it is called.
@ -1071,11 +1078,11 @@ A macro answers one form. To define several things, answer a @code{(begin ...)},
(next-id) ;; 2
}|
@code{count} is written in the template, so it is renamed like everything else, and a @code{count} in the calling module is not disturbed. @code{next-id} came from the caller, so it is defined under that name.
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.
@subsubsection{Pattern macros: def/pattern}
@code{def/pattern} defines a macro that is used in patterns instead of expressions. A pattern macro without arguments can be written as a bare capitalized name:
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:
@codeblock[#:lang "bjolang"]|{
;; Face matches a jack, a queen or a king.
@ -1091,11 +1098,11 @@ A macro answers one form. To define several things, answer a @code{(begin ...)},
(_ #f)))
}|
Pattern macros are expanded before the exhaustiveness check, so the compiler sees the @code{or} and checks it like one you wrote yourself.
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.
@subsubsection{Hash macros: def/hash-extend}
@code{#map(...)} is a hash macro, and you can write your own with @code{def/hash-extend}. Here is a card literal. The rank and suit are turned into constructors by two helper functions:
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:
@codeblock[#:lang "bjolang"]|{
(import (std syntax-match) "deck.bjo")
@ -1136,17 +1143,17 @@ Pattern macros are expanded before the exhaustiveness check, so the compiler see
0)
}|
A mistake is reported where the literal is written:
Invalid literal syntax is flagged with a compile-time error at the point of use:
@codeblock[#:lang "text"]|{
The hash macro 'card' failed at bad.bjo:3: a suit is clubs, diamonds, hearts or spades — in hurts
}|
The @code{re-export} in @code{cardmacros.bjo} is there because the template writes @code{Card}, @code{Queen} and the rest, and those have to mean something in the module where the macro is used. With the re-export, importing @code{cardmacros.bjo} is enough to make them resolve. It is still a good idea to import @code{deck.bjo} as well, since that is where the @code{->str} implementations live.
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.
@subsubsection{Literals that elaborate into your unions}
Many small languages do not need a macro at all. Where a union is expected, a quoted list is turned into the union's cases, and a case can declare with @code{#:tag} the name it is written under:
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}:
@codeblock[#:lang "bjolang"]|{
(type (: Action (Union (: Play int #:tag play)
@ -1161,31 +1168,33 @@ Many small languages do not need a macro at all. Where a union is expected, a qu
(def (: more (List Action)) '((play ,n) pass))
}|
This is checked by the type checker, not by a macro, so a wrong tag or the wrong number of arguments is a type error:
Because elaboration is handled by the type checker rather than a syntactic macro, misspelled tags or invalid argument counts produce standard type errors:
@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.
}|
It only works where the type checker knows a union is expected, which is why @code{script} and @code{more} have types. @code{(std run)}'s commands and @code{(std fmt)}'s layouts are both written this way.
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{What a macro cannot do}
@subsubsection{Macro limitations}
When designing macros, keep the following constraints in mind:
@read-list{
- Be used in the module that defines it.
- Be used anywhere but where an expression or a definition goes (or a pattern, for @code{def/pattern}). Not as a @code{loop} clause, a @code{let} binding or a type.
- Replace a special form. A macro called @code{if} is never used.
- See types. Macros are expanded before type checking.
- Be passed around as a value.
- Add an import. Write the import in the file that uses the macro.
- 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.
}
@subsection{Testing and the REPL}
@subsubsection{Tests with (std simpletest)}
@code{(std simpletest)} is a small test library. @code{(expect label actual wanted)} compares two values, @code{(expect-true label ok)} checks a boolean, and @code{(fail label)} marks a branch that should not be reached. Each prints @code{ok: label} or a line starting with @code{FAILURE:}:
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:
@codeblock[#:lang "bjolang"]|{
;; tests/deck-test.bjo
@ -1205,11 +1214,11 @@ ok: an ace is worth 11
ok: shuffling keeps the cards
}|
A failure shows both values: @code{FAILURE: deliberately wrong gave 2 but wanted 3}.
Whenever an assertion fails, the output reports both the computed value and the expected value: @code{FAILURE: deliberately wrong gave 2 but wanted 3}.
@code{(with-test "name" body ...)} runs a test body in a scope with a deadline. The test gets a timeout that names it, fibers it spawned are waited for, and it fails if it left a file open. Since it is a scope, it has to be in a bjoroutine.
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.
@code{(with-fake-fs ((path contents) ...) body ...)} replaces the file system with a map for everything inside it. Here is a test of the high-score code from the I/O section, with the score functions moved to a module of their own. The real @code{scores.txt} is not touched:
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:
@codeblock[#:lang "bjolang"]|{
(import (std simpletest) (std effect) "scores.bjo")
@ -1230,11 +1239,11 @@ A failure shows both values: @code{FAILURE: deliberately wrong gave 2 but wanted
0)
}|
@code{with-fake-fs} is written with @code{with-handler}, so the test has to import @code{(std effect)} as well. Tests go in @code{tests/} in a project, and are run with @code{bjo run}.
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}.
@subsubsection{The REPL}
@code{bjo repl} starts the REPL (inside a project, the project's modules can be imported). It has no line editing of its own, so if @code{rlwrap} is installed, @code{bjo} uses it. An entry is either one expression, whose value is printed, or a group of definitions, which prints the names it defined:
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:
@codeblock[#:lang "text"]|{
bjo> (+ 1 2)
@ -1248,9 +1257,9 @@ bjo> (double 21)
42
}|
The REPL is not an interpreter. Every entry is compiled by the same compiler as a file, into a small assembly of its own, so it behaves exactly like the same code in a file would.
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.
A top-level @code{defun} needs a signature at the prompt too. Either write the types on the definition, as with @code{double} above, or type the signature on its own line first, and the next entry picks it up:
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:
@codeblock[#:lang "text"]|{
bjo> (: triple (-> int int))
@ -1258,7 +1267,7 @@ bjo> (defun (triple x) (* x 3))
triple
}|
Redefining a name shadows the old one, but code that was already compiled against the old one keeps calling it:
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:
@codeblock[#:lang "text"]|{
bjo> (defun (f (: x int)) : int (+ x 1))
@ -1272,4 +1281,4 @@ bjo> (g 10)
12
}|
The same goes for @code{impl}s. An implementation typed at the prompt is used by the entries after it, not by the ones before. @code{:help} lists the commands, and @code{:quit} or Ctrl-D leaves.
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.

View file

@ -1,43 +1,43 @@
@section{Part III — The standard library}
A short tour of the standard library. The full documentation for these modules can be found in the reference documentation under @code{Docs/std/} and @code{prelude.org}.
A short tour of the standard library. Each module's full reference is its own page under @link["modules/index.html"]{Modules}; the prelude's is still @code{Docs/prelude.org}.
@subsection{std/fmt}
Formatting in Bjolang is divided into two layers: interpolation and layout.
Interpolation provides a simple way to combine mixed scalar types into strings or print them directly using @code{println*} and @code{str*}.
The layout layer provides a composable block algebra for more complex text formatting, such as padding, aligning, wrapping paragraphs, and laying out tables.
For more details, see @code{Docs/std/fmt.org}.
For more details, see @link["modules/std/fmt.html"]{@code{(std fmt)}}.
@subsection{std/rx}
Bjolang provides a built-in regular expression engine. Unlike languages that use strings for regex (like @code{"[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., @code{:bos}, @code{:digit}), and operations that group or repeat patterns are function-like lists (e.g., @code{(seq ...)}).
For more details, see @code{Docs/std/rx.org}.
For more details, see @link["modules/std/rx.html"]{@code{(std rx)}}.
@subsection{std/http}
The @code{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: @code{System.Net.Http} is reached with @code{import/extern}, and the response body is an ordinary @code{TextInputPort}.
For more details, see @code{Docs/std/http.org}.
For more details, see @link["modules/std/http.html"]{@code{(std http)}}.
@subsection{std/run}
The @code{std/run} module provides a way to run external programs. A form is one thing to run, written as a quoted literal, like @code{'(cat "hej.txt")}. You can connect programs in a pipe with @code{'(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 @code{Docs/std/run.org}.
For more details, see @link["modules/std/run.html"]{@code{(std run)}}.
@subsection{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 @code{Docs/std/random.org}.
For more details, see @link["modules/std/random.html"]{@code{(std random)}}.
@subsection{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 @code{Date} (a calendar date without a timezone), @code{Time} (a time of day without a timezone), and @code{DateTime} (a date and time without a timezone), as well as handling timezones.
For more details, see @code{Docs/std/datetime.org}.
For more details, see @link["modules/std/datetime.html"]{@code{(std datetime)}}.
@subsection{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 @code{bjodat-parse-<Name>}, which avoids intermediate trees and allocations.
For more details, see @code{Docs/std/bjodat.org}.
For more details, see @link["modules/text/bjodat.html"]{@code{(text bjodat)}}.
@subsection{text/json}

416
_04.Appendices.sz Normal file
View file

@ -0,0 +1,416 @@
@section{Appendices}
@subsection{Appendix A — 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.
@subsubsection{Coming from Scheme}
If you have written Scheme, Racket, or Common Lisp, the visual rhythm of Bjolang will feel immediately comfortable:
@read-list{
- S-expression syntax and prefix notation: Expressions are nested parenthesized lists, and operators are ordinary functions called in prefix position: @code{(+ 1 2 (* 3 4))}.
- Familiar control forms: Core branching and binding forms like @code{let}, @code{let*}, @code{cond}, @code{when}, @code{unless}, and @code{begin} behave as you expect.
- First-class closures: Functions are first-class values created with @code{fun} (or @code{defun} at top level), capturing lexical scope, and passing into or returning from higher-order functions.
- Persistent linked lists: The @code{List} type provides standard singly-linked functional lists with @code{Cons} and @code{Nil}, along with familiar combinators such as @code{list-head}, @code{list-tail}, @code{list-map}, and @code{list-filter}.
- Hygienic macros: Bjolang features a compile-time syntactic macro system based on pattern matching with @code{syntax-match}, syntax quotation with @code{#'(...)}, and explicit identifier injection with @code{inject}.
}
However, several fundamental differences will quickly become apparent:
@read-list{
- 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: @code{(: name type)}. Type errors are caught at compile time before any code runs.
- No truthiness tricks or @code{#f}-as-nil: In Scheme, functions like @code{memq} or @code{assoc} return either a value or @code{#f} to signal absence, and @code{#f} is often treated as a general falsey sentinel. In Bjolang, absent values are strictly represented by the @code{(Option %a)} union (@code{Some} or @code{None}), and operations that may fail return @code{(Result %e %a)} (@code{Ok} or @code{Err}). Booleans (@code{#t} and @code{#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, @code{(Vec %a)} is an immutable Relaxed Radix Balanced (RRB) tree with efficient O(log n) random indexing, updates, and concatenation. Similarly, @code{(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 with @code{def/mutable} and modification with @code{set!}.
- Structural pattern matching over @code{car}/@code{cdr}: Destructuring lists, records, and unions is done using the @code{match} construct rather than long chains of @code{car}, @code{cdr}, or @code{cadr}. The compiler checks pattern matches for exhaustiveness and warns or errors if any potential value is unaccounted for.
- Concurrency without @code{call/cc}: Scheme systems often implement coroutines or thread libraries using first-class continuations (@code{call/cc}). Bjolang does not provide @code{call/cc}. Instead, it offers direct-style cooperative fibers (bjoroutines), typed channels, and Concurrent ML (CML) event combinators running on a dedicated fiber scheduler.
}
@subsubsection{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:
@read-list{
- 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 @code{%a} or @code{%elem}.
- Algebraic data types: Data structures are modeled as sum types (@code{Union}) and product types (@code{Record} on the heap, or value-type @code{Struct} on the stack).
- Exhaustive pattern matching: The compiler tracks which variants of a union or struct combination have been handled in a @code{match} or @code{def} binding, 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 @code{def/trait} and @code{impl} system 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:
@read-list{
- Syntax and parentheses: There is no indentation-based syntax (like F#) or @code{let ... in} keyword 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 @code{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 @code{(Event %a)}. Events can be combined with @code{choose}, wrapped with transformations via @code{wrap}, and selectively synchronized using @code{sync}.
- Automatic colour twins: In many typed asynchronous languages, asynchronous functions have distinct types (@code{Task<'T>} or @code{Async<'T>}) that cannot be passed to standard higher-order functions like @code{List.map} without creating asynchronous duplicates (@code{List.mapAsync}). Bjolang solves this "function colouring problem" at the compiler level: higher-order functions declaring @code{-?->} parameters automatically generate both synchronous and suspending twins, allowing the exact same @code{list-map} to be used whether your callback performs I/O or pure computation.
}
@subsection{Appendix B — 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.
@subsubsection{1. Missing top-level type signature}
@codeblock[#:lang "bjolang"]|{
(defun (greet name)
(println (str "Hello, " name)))
}|
When compiled, the frontend rejects this with:
@codeblock[#:lang "text"]|{
Type Error: Function 'greet' requires a type signature (: greet ...) at hello.bjo:1:1
}|
@strong{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 @code{main}, which has a fixed signature known to the compiler.
@strong{How to fix it:} Add an explicit signature right before the definition:
@codeblock[#:lang "bjolang"]|{
(: greet (-> string unit))
(defun (greet name)
(println (str "Hello, " name)))
}|
@subsubsection{2. Calling a bjoroutine from a synchronous function}
@codeblock[#:lang "bjolang"]|{
(: 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:
@codeblock[#:lang "text"]|{
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 ...).
}|
@strong{Why it happens:} In Bjolang, calls to bjoroutines look identical to ordinary function calls—there is no explicit @code{await} keyword. However, a bjoroutine can yield or suspend its fiber. A function defined with @code{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.
@strong{How to fix it:} If the caller needs to perform suspending operations, define it using @code{defbjo} instead of @code{defun}:
@codeblock[#:lang "bjolang"]|{
(: 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.
@subsubsection{3. Mismatch between signature and argument count}
@codeblock[#:lang "bjolang"]|{
(: render-card (-> Card Suit string))
(defun (render-card card)
(card->string card))
}|
The compiler flags the arity divergence immediately:
@codeblock[#:lang "text"]|{
Type Error: Function 'render-card' has 1 mandatory args but signature specifies 2 at cards.bjo:2:1
}|
@strong{Why it happens:} The parameter list in the @code{defun} head does not match the arrow type declared in the preceding @code{(: ...)} form. Here, the signature promised two mandatory arguments (@code{Card} and @code{Suit}), but the definition provided only one parameter (@code{card}).
@strong{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.
@subsubsection{4. Non-exhaustive pattern matching}
@codeblock[#:lang "bjolang"]|{
(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:
@codeblock[#:lang "text"]|{
Pattern Error at game.bjo:6:3: this match does not cover every value. Clubs reaches no clause.
}|
@strong{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 @code{Clubs}) showing an unhandled value that would fail to match.
@strong{How to fix it:} Add the missing cases to the @code{match} expression, or provide a wildcard fallback @code{_} if other cases should receive default handling:
@codeblock[#:lang "bjolang"]|{
(: suit-color (-> Suit string))
(defun (suit-color s)
(match s
(Hearts "red")
(Diamonds "red")
((or Clubs Spades) "black")))
}|
@subsubsection{5. Exporting a binding that references a private type}
Suppose you define a module @code{player.bjo} where a type is private, but an exported function returns it:
@codeblock[#:lang "bjolang"]|{
(export make-player)
(type (: Player (Record (: name string) (: score int))))
(: make-player (-> string Player))
(defun (make-player name)
(Player name 0))
}|
Compiling @code{player.bjo} fails with:
@codeblock[#:lang "text"]|{
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.
}|
@strong{Why it happens:} If another file imports @code{make-player}, the calling module will receive a value of type @code{Player}. But because @code{Player} was not exported, the importing module cannot name the type in signatures or inspect its fields.
@strong{How to fix it:} Export the type in the module header:
@codeblock[#:lang "bjolang"]|{
(export Player make-player)
}|
If you want to keep the internal fields and constructor private to @code{player.bjo}, mark the type as @code{#:opaque}:
@codeblock[#:lang "bjolang"]|{
(export Player make-player)
(type #:opaque (: Player (Record (: name string) (: score int))))
}|
@subsubsection{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:
@codeblock[#:lang "bjolang"]|{
(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:
@codeblock[#:lang "text"]|{
Type Error at main.bjo:5:37: 'score' cannot be read here. player/Player is exported #:opaque.
}|
@strong{Why it happens:} When a type is exported with @code{#: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 @code{record-ref} or pattern match on internal components.
@strong{How to fix it:} Expose a public accessor function in the defining module (e.g. @code{player-score}) and export that function alongside the type.
@subsubsection{7. Using a macro where it is defined}
@codeblock[#:lang "bjolang"]|{
(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:
@codeblock[#:lang "text"]|{
'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.
}|
@strong{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.
@strong{How to fix it:} Move your macro definitions into a dedicated module (such as @code{macros.bjo}), and then import that module into the files where you wish to use them:
@codeblock[#:lang "bjolang"]|{
;; In main.bjo:
(import "macros.bjo")
(def/thunk answer 42)
}|
@subsection{Appendix C — 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.
@subsubsection{The compilation pipeline}
When you run @code{bjo build} or @code{bjo run}, the compiler processes source files through several distinct phases:
@read-list{
- 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 (@code{-?->}) 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 (@code{where} clauses) are gathered.
- Monomorphisation: Generic functions constrained by traits are specialized at concrete call sites (such as @code{int} or custom types) to eliminate virtual interface dispatch.
- Trait inlining: Direct trait method calls on known types are rewritten into primitive C# operations (such as @code{==}, @code{<}, 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 @code{VBCSCompiler} server), producing a @code{.dll} or @code{.exe}.
}
@subsubsection{The C# output model and runtime assemblies}
Each Bjolang source file compiles to a static C# class named after the module: @code{deck.bjo} becomes @code{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:
@read-list{
- Hyphens become @code{sub}: @code{card-points} becomes @code{cardsubpoints}.
- Exclamation marks become @code{bang}: @code{set!} becomes @code{setbang}.
- Question marks become @code{p}: @code{empty?} becomes @code{emptyp}.
- Type variable sigils (@code{%}) become @code{tv_}: @code{%elem} becomes @code{tv_elem}.
}
Every compiled program links against a small set of optimized runtime assemblies:
@read-table{
| Assembly | Role in compiled programs |
|----------|---------------------------|
| @code{BjolangRuntime} | Core primitives, string formatting, dynamic environment, and conversions |
| @code{Collections} | Persistent @code{(Vec %a)} based on Relaxed Radix Balanced (RRB) trees |
| @code{SchemeList} | Persistent singly-linked @code{(List %a)} with @code{Cons} and @code{Nil} |
| @code{Map} | Persistent @code{(Map %k %v)} based on Compressed Hash-Array Mapped Prefix-trees (CHAMP) |
| @code{BjoSet} | Persistent hash set @code{(Set %a)} |
| @code{BjoOrderedMap} | Balanced ordered map @code{(OrderedMap %k %v)} |
| @code{BjoOrderedSet} | Balanced ordered set @code{(OrderedSet %a)} |
| @code{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.
@subsubsection{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 @code{T}, and asynchronous functions that return @code{Task<T>} or @code{Promise<T>}. This dichotomy often splits standard libraries in two: an author must write @code{map} for synchronous callbacks, and a separate @code{mapAsync} for asynchronous callbacks.
Bjolang solves this problem at the compiler level using @em{colour twins}:
1. The polymorphic arrow @code{-?->}: When writing a higher-order function that accepts a callback, you can declare its parameter with @code{-?->} rather than a fixed arrow:
@codeblock[#:lang "bjolang"]|{
(: list-map (-> (List %a) (-?-> %a %b) (List %b)))
}|
2. Source-level twin generation: Before type checking occurs, the @code{ColourTwins} pass scans all declarations. When it detects a function with a @code{-?->} 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 @code{defun} that calls a dual-mode function (such as @code{defbjouble} primitives that provide both synchronous and fiber implementations) automatically receives a suspending twin as well. If an author explicitly wants a function to remain strictly synchronous, they can mark it with @code{#:sync}.
4. Automatic call-site selection: When a function is called, the compiler checks the colour of the caller. If an ordinary @code{defun} calls @code{list-map}, it calls the synchronous twin. If a bjoroutine (@code{defbjo}) calls @code{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.
@subsubsection{Monomorphisation and trait inlining}
Generic code bounded by traits (such as @code{(where (Eq %a))}) is initially desugared using dictionary passing:
@codeblock[#:lang "bjolang"]|{
(: least (-> %a %a %a) (where (Ord %a)))
(defun (least a b)
(if (<= a b) a b))
}|
Under standard dictionary passing, @code{<=} is compiled into an interface method call: @code{_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 @code{int} or structs) to be boxed.
To eliminate this cost, Bjolang employs @em{monomorphisation}:
@read-list{
- Call-site specialization: Before trait inlining runs, the compiler inspects every call to a constrained generic function. When a function like @code{least} is called with concrete types—for instance, @code{(least 3 5)}—the compiler generates a specialized copy of @code{least} tailored specifically for @code{int}.
- 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, @code{TraitInline} replaces the dictionary method call with the direct C# operation. For @code{int}, @code{_dict_Ord_int.le(a, b)} becomes a direct @code{a <= b} primitive comparison in the generated C# code.
- Cross-module monomorphisation: What if @code{least} is exported from a library and called from an application module? An exported constrained function embeds its untyped source AST directly into the compiled @code{.dll}'s metadata. When the importing module calls @code{least} with 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#.
@subsubsection{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 @code{<source>.bjobuild} beside the source file:
@codeblock[#:lang "text"]|{
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 @code{bjo run game.bjo} again:
@read-list{
- @code{bjo} reads @code{game.bjobuild} and 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 @code{-c} to force an unconditional rebuild, or @code{-d} to create an unoptimized debug build with AST and generated C# dumps.
@subsection{Appendix D — Editor support}
Editing Bjolang code is most pleasant in Emacs, where specialized major modes support both Bjolang source files and Samizdat documentation.
@subsubsection{Bjolang mode: bjomode.el}
@code{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 @code{load-path} and require the mode:
@codeblock[#:lang "elisp"]|{
(add-to-list 'load-path "/path/to/bjolang")
(require 'bjo-mode)
}|
@code{bjo-mode} associates automatically with @code{*.bjo} and @code{*.protobjo} files.
Key capabilities of the mode include:
@read-list{
- Comprehension brace pairing: Bjolang uses curly braces for list and vector comprehensions: @code{{for x in xs :yield (* x 2)}}. In @code{bjo-mode}, braces are configured in the syntax table as paired delimiters. Structural navigation commands like @code{C-M-f} (@code{forward-sexp}), @code{C-M-b} (@code{backward-sexp}), and @code{show-paren-mode} treat curly braces exactly like parentheses.
- Syntax propertizing for character literals: In Lisp syntax tables, a semicolon normally starts a line comment. The literal @code{#\\;} 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, @code{#\\(} is recognized as a character rather than an unmatched opening parenthesis.
- Multiline raw string fences: Triply-quoted raw strings (@code{"""..."""}) 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 (@code{defun}, @code{defbjo}, @code{let}, @code{match}, @code{type}, @code{when}, @code{unless}) indent their bodies by 2 spaces. Branching arms in @code{if} forms indent by 4 spaces. Standard function calls and comprehension clauses line their arguments up under the first expression.
}
@subsubsection{Samizdat mode: samizdat-mode.el}
All documentation for Bjolang—including this manual—is written in Samizdat (@code{.sz}). The major mode @code{samizdat-mode.el} provides syntax highlighting and editing commands for Samizdat documents.
To configure it:
@codeblock[#:lang "elisp"]|{
(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:
@read-list{
- Command highlighting: Samizdat directives like @code{@"@"section}, @code{@"@"subsection}, @code{@"@"codeblock}, and @code{@"@"include} are highlighted distinctly from regular prose text.
- Verbatim block handling: Verbatim code blocks enclosed in @code{|{ ... }|} and comments enclosed in @code{@"@";{ ... }} are syntax-propertized so that code symbols or @code{@"@"} characters inside code are not mistaken for Samizdat markup.
- @code{C-c C-s}: Insert a section or subsection header at point, prompting for the heading level and title.
- @code{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.
- @code{C-c C-b}: Wrap the selected region in a verbatim code block (@code{@"@"codeblock|{ ... }|}), prompting for an optional @code{#:lang} specifier.
- @code{C-c C-l}: Wrap the selected region in a link command (@code{@link[...]}).
- @code{C-c '}: Edit the verbatim code block at point in a dedicated indirect buffer using that block's native major mode (such as @code{bjo-mode}, @code{sh-mode}, or @code{emacs-lisp-mode}). Pressing @code{C-c '} again commits the changes back to the document.
- @code{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 @code{outline-minor-mode} and @code{imenu} allows folding sections and navigating complex documents through hierarchical headings.
}

View file

@ -1,8 +1,17 @@
(meta (title "Bjolang manual")
(date 2029-09-29))
(meta (title "Bjolang")
(listing? #f))
@toc[#:depth 3]
Bjolang is a Lisp for .NET: a language of its own, with Hindley–Milner
types, traits, hygienic macros and bjoroutines, that compiles to C#.
@include["_01.Language-basics.sz"]
@include["_02_.Writing-programs.sz"]
@include["_03.Standard-library.sz"]
@itemlist{
@item{@link["manual.html"]{@strong{The manual}}: the language, writing
programs, and a tour of the standard library.}
@item{@link["modules/"]{@strong{The modules}}: the reference for each
library module, made from the documentation the module was compiled with.
@itemlist{
@item{@link["modules/std/"]{@code{std}}: the standard library.}
@item{@link["modules/text/"]{@code{text}}: data formats: bjodat, JSON
and XML.}
}}
}

13
manual.sz Normal file
View file

@ -0,0 +1,13 @@
(meta (title "The Bjolang manual")
(date "2026-09-29"))
@toc[#:depth 3]
@include["_01.Language-basics.sz"]
@include["_02_.Writing-programs.sz"]
@include["_03.Standard-library.sz"]
@include["_04.Appendices.sz"]
@subsection{std/random}
@defmodule[(std random)]

6
modules/index.sz Normal file
View file

@ -0,0 +1,6 @@
(meta (title "Modules")
(sort title))
The reference for each library module. Every page is made from the
documentation the module was compiled with, so it says what the installed
module does.

3
modules/std/datetime.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(std datetime)"))
@generate-docs[#:module "std datetime"]

3
modules/std/fmt.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(std fmt)"))
@generate-docs[#:module "std fmt"]

3
modules/std/http.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(std http)"))
@generate-docs[#:module "std http"]

2
modules/std/index.sz Normal file
View file

@ -0,0 +1,2 @@
(meta (title "std: the standard library")
(sort title))

3
modules/std/random.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(std random)"))
@generate-docs[#:module "std random"]

3
modules/std/run.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(std run)"))
@generate-docs[#:module "std run"]

3
modules/std/rx.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(std rx)"))
@generate-docs[#:module "std rx"]

View file

@ -0,0 +1,3 @@
(meta (title "(text bjodat-core)"))
@generate-docs[#:module "text bjodat-core"]

3
modules/text/bjodat.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(text bjodat)"))
@generate-docs[#:module "text bjodat"]

2
modules/text/index.sz Normal file
View file

@ -0,0 +1,2 @@
(meta (title "text: data formats")
(sort title))

View file

@ -0,0 +1,3 @@
(meta (title "(text json-codec)"))
@generate-docs[#:module "text json-codec"]

3
modules/text/json.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(text json)"))
@generate-docs[#:module "text json"]

3
modules/text/xml.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(text xml)"))
@generate-docs[#:module "text xml"]

View file

@ -0,0 +1,2 @@
(meta (title "text xml: reading and writing")
(sort title))

3
modules/text/xml/read.sz Normal file
View file

@ -0,0 +1,3 @@
(meta (title "(text xml read)"))
@generate-docs[#:module "text xml read"]

View file

@ -0,0 +1,3 @@
(meta (title "(text xml write)"))
@generate-docs[#:module "text xml write"]