FOL Language Specification
FOL is intended as a general-purpose and systems-oriented programming language with a compact declaration syntax, a rich type system, and a strong preference for explicit structure.
This book is the specification draft for the language.
Core Shape
Much of the surface syntax follows the same high-level shape:
declaration[options] name: type = { body }
That pattern appears across:
- bindings
- routines
- type declarations
- module-like declarations
- standards
Main Declaration Families
The main declaration families are:
use // import declarations
def // named definitions such as modules, blocks, and tests
seg // segment/module-like declarations
var // bindings; use var[imu] for immutability
con // constants
lab // labels and label-like bindings
fun // functions
pro // procedures
log // logical routines
typ // named types
ali // aliases
std // standards: protocol, blueprint, extension
Expression And Control Surface
FOL combines block-oriented control flow with expression forms:
if (condition) { ... }
when (value) { ... }
while (condition) { ... }
loop (condition) { ... }
for (binder in iterable) { ... }
each (binder in iterable) { ... }
Expressions include:
- literals
- calls and method calls
- ranges and container literals
- access forms
- pipes
- rolling expressions
- anonymous routines and lambdas
How To Use This Book
The book is organized from language foundation to higher-level facilities:
- lexical structure
- statements and expressions
- metaprogramming
- types
- declarations and items
- modules and source layout
- errors
- sugar and convenience forms
- conversions
- memory model
- concurrency
Read Notation And Conventions before using chapter examples as a normative reference.
For the current user-facing tool workflow, read Frontend Workflow.
Example
This example uses the hosted std.io surface. Its artifact therefore selects
fol_model = "memo", and its build.fol explicitly declares:
build.add_dep({ alias = "std", source = "internal", target = "standard" });
The dependency is required by the source-level hosted calls, not because
main is executable.
use std: pkg = {"std"};
var[hid] prefix: str = "arith";
fun[] main(): int = {
std::io::echo_str(prefix);
std::io::echo_int(add(3, 5));
return 0;
}
fun[exp] add(a, b: int): int = {
return a + b;
}
Toolchain Management
FOL ships as two binaries with two jobs:
| binary | role | where it lives |
|---|---|---|
fol | the toolchain manager — the only binary on your PATH, the only thing a distribution packages | /usr/bin/fol (or anywhere on PATH) |
folc | the toolchain engine — compiler, build graph, package system, LSP | inside a versioned toolchain directory, managed by fol |
You always type fol. It owns the fol self … command family and forwards
everything else (fol code build, fol work init, fol tool lsp, …) to the
right folc for the project you are standing in. You never invoke folc
yourself — if you do, folc self even redirects you back to fol.
This split is what makes multiple language versions coexist on one machine:
fol changes rarely and is version-agnostic; every folc is an immutable,
self-contained unit that can be installed, pinned, and removed independently.
FOL_HOME
Everything the manager owns lives under a single directory:
$FOL_HOMEif the environment variable is set — the shared, machine-wide home (for exampleexport FOL_HOME="$HOME/.fol").- Otherwise,
<project>/.fol/toolchain— the manager walks up from the current directory to the nearestbuild.foland keeps toolchains next to the project’s build artifacts, inside the.fol/directory every project already has. - If neither exists (no variable, not inside a project),
folstops with an error telling you both ways out.
The layout inside the home:
$FOL_HOME/
├── toolchains/
│ ├── v0.2.1/ ← one immutable toolchain per version
│ │ ├── folc ← the engine binary
│ │ ├── std/ ← the standard library, exactly as released
│ │ └── runtime/ ← the runtime crate sources folc compiles against
│ └── dev.toml ← a *linked* toolchain (see below)
├── pkg/ ← third-party packages, shared by all versions
└── config ← the default toolchain (`default = 0.2.1`)
A toolchain directory is self-contained: folc resolves std/ and
runtime/ relative to its own binary, so nothing else on the machine — no
environment variables, no source checkout — is needed for it to compile and
run programs.
Pinning a version
A project declares which toolchain it wants on the first comment line of
build.fol:
//fol 0.2.1
pro[] build(): non = {
...
};
The manager scans only the leading comment block (it never needs to run the
build program to learn the version — that would be a chicken-and-egg problem),
matching //fol <version>. Checking this one line into the repository means
everyone who clones the project builds it with the same toolchain.
Selection order
For any forwarded command, the toolchain is chosen by the first match:
fol +0.1.4 code build— explicit one-shot override,+<version|name>FOL_TOOLCHAIN=dev— environment override- the
//fol <version>pin in the nearestbuild.fol - the default recorded in
$FOL_HOME/config(fol self default …) - if exactly one toolchain is installed, it is used; otherwise an error
Automatic fetching
If a pinned version is not installed, fol downloads it on the spot and
continues:
$ fol code run
toolchain 0.2.1 not installed, fetching...
fetched fol 0.2.1 -> ~/.fol/toolchains/v0.2.1
42
Releases carry exactly two kinds of assets per target
(FOL is Linux-only — x86_64-linux and aarch64-linux):
fol-compiler-and-lib-v<version>-<target>.tar.gz— the toolchain:folc+std/+runtime/, unpacked verbatim into$FOL_HOME/toolchains/v<version>/fol-v<version>-<target>— thefolbinary itself, ready to drop onPATH
The fol self commands
fol self install 0.2.1 # fetch a released toolchain
fol self install 0.2.1 --from . # copy folc + std + runtime from a built source tree
fol self link dev ~/code/fol # register a source checkout as toolchain "dev"
fol self default 0.2.1 # set the default toolchain
fol self list # show installed toolchains, default marked
fol self remove 0.2.1 # delete a toolchain (or a link)
fol self which # print the folc the current directory resolves to
See Installing & Developing for the day-to-day flows and Versions In Depth for the dispatch mechanics.
Installing & Developing
Getting fol
fol is the only binary you install by hand — everything else it fetches
itself. Take the fol-v<version>-<target> asset from a release, drop it on
your PATH, and set your home:
$ curl -fL -o ~/.local/bin/fol \
https://github.com/fol-lang/fol/releases/download/v0.2.1/fol-v0.2.1-x86_64-linux
$ chmod +x ~/.local/bin/fol
$ export FOL_HOME="$HOME/.fol" # add to your shell profile
From that moment a plain fol code run inside any pinned project fetches the
right toolchain automatically. To install one ahead of time:
$ fol self install 0.2.1
fetched fol 0.2.1 -> ~/.fol/toolchains/v0.2.1
$ fol self default 0.2.1
default toolchain is now 0.2.1
Prefer keeping everything project-local? Skip FOL_HOME entirely — inside a
project the manager uses <project>/.fol/toolchain as its home, so the
toolchain sits beside the project’s build outputs and nothing global exists.
Working on FOL itself
A source checkout becomes a first-class toolchain through a link — no copying, always fresh:
$ fol self link dev ~/code/fol
linked toolchain 'dev' -> /home/you/code/fol
$ fol self default dev
A linked toolchain resolves its folc from the checkout’s
target/release/folc or target/debug/folc — whichever was built most
recently — and its std and runtime straight from the live source tree.
Rebuild the compiler, and every fol invocation immediately uses it.
To freeze a source build into a normal, immutable toolchain instead (useful for testing the install flow offline):
$ cargo build --release --bin folc && cargo build --release -p fol-self
$ fol self install 0.2.2 --from .
installed fol 0.2.2 -> ~/.fol/toolchains/v0.2.2 (from /home/you/code/fol)
Releasing
Pushing a v* tag runs the release workflow, which builds folc and fol
on every supported Linux target, stages {folc, std/, runtime/}, and uploads:
fol-compiler-and-lib-v<version>-<target>.tar.gzfol-v<version>-<target>
The tarball layout is a contract: fol self install unpacks it verbatim into
$FOL_HOME/toolchains/v<version>/, and an integration test pins the workflow
to that layout.
Versions In Depth
Why versions coexist cleanly
Each $FOL_HOME/toolchains/v<version>/ directory is a closed world:
folc— the engine binary for that exact language version.std/— the standard library sources that version was released with.folcfinds them next to its own binary, so a v0.2.1 engine can never accidentally compile against a v0.4.0 standard library.runtime/— the runtime crate sources.folcemits Rust and compiles it against this crate; shipping it inside the toolchain is what makes the directory self-sufficient on a machine that has no FOL checkout.
Because nothing inside a toolchain references anything outside it, installing,
removing, or switching versions can never corrupt another version. The shared
parts of the home — pkg/ for third-party packages and config for the
default — are version-independent by design (packages are source; every
toolchain compiles them itself).
Dispatch mechanics
fol decides which engine runs before any engine code executes:
fol [+<toolchain>] <anything else> …
│
├─ `self …`? → handled by the manager itself, never forwarded
│
├─ resolve: +arg → FOL_TOOLCHAIN → //fol pin → config default
│ (a pinned version that is missing is fetched now)
│
└─ exec $FOL_HOME/toolchains/v<X>/folc <anything else> …
The manager passes your arguments through verbatim and replaces itself with
folc (exec), so there is no wrapper process at runtime. A recursion guard
(FOL_DISPATCHED) makes a mis-installed toolchain fail loudly instead of
looping.
Help and version flags are forwarded too: fol --help shows the engine’s
help for the toolchain your project resolves to, which is why the commands you
see always match the version you are actually running. Only when nothing is
installed yet does the manager print its own copy of the help, with a hint to
fol self install.
Per-project toolchains in practice
Two projects on one machine, different language versions, zero ceremony:
$ head -1 ~/work/legacy/build.fol
//fol 0.2.1
$ head -1 ~/work/greenfield/build.fol
//fol 0.4.0
$ cd ~/work/legacy && fol code build # runs the v0.2.1 engine
$ cd ~/work/greenfield && fol code build # runs the v0.4.0 engine
Editors get the same guarantee for free: the language server is started as
fol tool lsp, so it goes through the same resolution and always matches the
compiler that builds the project.
One-shot overrides
$ fol +0.4.0 code build # try a newer engine without touching the pin
$ FOL_TOOLCHAIN=dev fol code test # a whole shell session on the dev link
$ fol self which # show which folc the current directory gets
Overview
This opening section explains what FOL is, how this book is organized, and how syntax examples are written.
Use it before diving into the detailed lexical, type, item, and runtime chapters.
It also describes the user-facing workflow tool and the first editor-tooling surface before the language chapters begin.
Notation And Conventions
This book is the language specification for FOL.
It is written as a spec first:
- examples describe intended language behavior
- implementation status may lag behind the book
- when code and prose disagree, the disagreement should be resolved explicitly
Reading Syntax Examples
The examples in this book follow a few conventions.
- Keywords are written literally:
funprotypuse
- Punctuation is written literally:
()[]{}:=
- Placeholder names are descriptive:
nametypeexprbodysource
For example:
fun[options] name(params): return_type = { body }
means:
funis a literal keywordoptionsis a placeholder for zero or more routine optionsnameis the declared routine nameparamsis a parameter listreturn_typeis a type referencebodyis a routine body
Spec Vocabulary
The book uses the following terms consistently:
- declaration: a top-level or block-level form that introduces a named entity
- statement: an executable form that appears in a block
- expression: a form that produces a value
- type reference: a syntactic reference to a type
- type definition: a declaration form that creates a new named type
- source kind:
an import/source locator family; the current public kinds are
locandpkg
Runtime Capability Convention
Examples in this book do not repeat a complete build.fol beside every source
snippet. Read their runtime requirements using this mapping:
fol_model = "core"selects the no-heap, no-source-level-hosted-API modefol_model = "memo"adds heap-backed values such asstr,vec,seq,set, andmap, but still does not expose hosted APIs- a package whose
memoartifact needs shipped hosted APIs declaresbuild.add_dep({ alias = "std", source = "internal", target = "standard" })and imports that alias withuse std: pkg = {"std"};
std is not an accepted fol_model value, and it is not an import source kind.
Bundled std is an explicit dependency that widens the source-visible API tier
of a memo artifact; it is not a third artifact model.
An example that calls .echo(...), a std::io routine, or a V3 processor
construct therefore assumes memo plus that explicit bundled dependency.
Examples that only use core or memo facilities do not need std merely
because they produce an executable.
Execution is a separate tool concern. graph.add_run, graph.add_test,
fol code run, and fol code test do not grant hosted APIs and do not require
bundled std. They may execute a core or memo artifact when its selected
target is compatible with the build host. A foreign target is buildable, but
the current frontend rejects running or testing it because FOL does not yet
provide a cross-target runner configuration.
Examples vs Grammar
Examples are illustrative, not exhaustive.
When a chapter gives one or two examples, that does not imply the syntax is limited to only those exact spellings. The normative rule is the chapter text plus the grammar intent described there.
Terminology Preferences
This book prefers the following terms:
- routine:
umbrella term for
fun,pro, andlog - record: named field-based type
- entry: named variant-based type
- standard: protocol/blueprint/extension-style contract surface
- module:
namespace/package surface addressed through
use,def, orseg
Status Notes
Some older chapters still use inconsistent wording inherited from earlier drafts.
During cleanup, the following principles apply:
- keep examples unless they are contradictory
- prefer clarifying rewrites over removal
- keep the chapter tree stable where possible
- make chapter indexes explain scope before detail
Frontend Workflow
FOL now has a dedicated frontend layer above the compiler pipeline.
That layer is the fol tool itself.
The frontend is implemented in fol-frontend, and it is the user-facing
workflow shell for:
- project/workspace setup
- fetch/update flows
- build/run/test/emit flows
- editor tooling dispatch under
fol tool
The detailed reference has moved to the Tooling section:
Use this overview page only as the entrypoint pointer.
- workflow commands
- direct compile dispatch
- root help
- output rendering
- frontend diagnostics
So the root binary is no longer its own separate CLI implementation.
Current Boundary
The current frontend milestone is about local workflows and the first backend.
It already covers:
- project and workspace scaffolding
- documented
build.folpackage entry files for new projects - root discovery
- package preparation through
fol-package - git-backed dependency fetching and materialization
fol.lockwriting, locked fetches, offline warm-cache fetches, and update flows- workspace dependency/status reporting
- build/run/test orchestration for shipped
V1, the boundedV2subset, and the shippedV3surfaces - routed workspace
build/run/test/checkentry throughbuild.fol - emitted Rust and lowered IR output
- editor-tooling entrypoints for parse, highlight, symbols, and LSP startup
- shell completions
- safe cleanup of build/cache/git/package-store roots
- frontend-owned direct compile routing
Future work is still expected around:
- richer package-store policy beyond the first git/store workflow
- lockfile/version solving beyond the current pinned git contract
- additional backend targets
Editor Tooling
FOL now has a dedicated editor-tooling crate:
fol-editor
It owns:
- Tree-sitter assets for syntax/highlighting/query work
- the language server for compiler-backed editor services
- build-file affordances for
build.folthrough the same parse/highlight/symbol and LSP surfaces used for ordinary source files - one public editor command surface under
fol tool ...
The detailed operational reference now lives in the Tooling section:
Use this page as the overview pointer, not the full reference.
Tooling
FOL now has a real tooling layer above the compiler.
That layer is split into:
- the
folfrontend workflow tool - editor-facing tooling under
fol tool ...
Use this section for:
- creating and organizing projects
- fetching and updating packages
- building, running, testing, and emitting artifacts
- understanding the editor stack
- integrating FOL with Neovim
The overview chapter only introduces the existence of these pieces.
This section is the detailed operational reference.
Frontend Workflow
The public FOL entrypoint is the fol tool.
fol is implemented by the fol-frontend crate. It sits above:
fol-packagefol-resolverfol-typecheckfol-lowerfol-backend
Its job is orchestration, not semantic analysis.
What The Frontend Owns
The frontend owns:
- CLI parsing with
clap - grouped command structure
- package and workspace discovery
- project scaffolding
- package preparation and fetch/update workflows
- build, run, test, and emit orchestration
- editor command dispatch under
fol tool - shell completions
- user-facing summaries and workflow errors
Bundled std reminder:
- the current binary scaffold defaults to
fol_model = "memo"and bundledstdbecause its generated source uses hostedstd.io - a bare executable may use
coreormemoand does not need bundledstd - scaffolding writes the dependency automatically when its generated source
needs
std; users should not have to repair an incomplete scaffold manually - running and testing host-compatible artifacts is frontend/toolchain behavior, not a bundled-std capability
- cross-target
run/testremain rejected until the frontend has a runner configuration, independently of the artifact’s API tier
Compiler truth remains in the compiler crates.
Public Command Groups
The root command groups are:
fol workfol packfol codefol tool
Root aliases are single-letter only:
fol wfol pfol cfol t
Run:
fol --help
fol <group> --help
fol <group> <command> --help
for the relevant usage surface.
Root Discovery
For workflow commands, the frontend discovers roots upward from the current directory or from an explicit path.
It recognizes:
- package roots via
build.fol - workspace roots via
fol.work.yaml
A package root is one buildable package.
A workspace root is a parent root that owns multiple member packages and frontend-managed roots like build/cache/package-store locations.
Direct Package Use
The normal workflow shape is group-first:
fol work init --bin
fol pack fetch
fol code check
fol code build
fol code run
There is still a frontend-owned direct package path for compile-oriented use, but the grouped commands are the public workflow shape.
Output
Frontend summaries support:
humanplainjson
The frontend also owns workflow-level errors such as:
- missing workspace roots
- fetch/update/store failures
- clean/completion/workflow command failures
Compiler diagnostics stay separate and flow through compiler-owned diagnostic models.
Tool Commands
This chapter lists the current frontend surface by workflow area.
Work
Project and workspace commands:
fol work initfol work newfol work infofol work listfol work depsfol work status
Examples:
fol work init --bin
fol work init --workspace
fol work new demo --lib
fol work info
fol work deps
Use work for:
- creating package/workspace roots
- inspecting workspace structure
- seeing member and dependency state
Scaffold reminder:
fol work init --bincreates amemobinary whose generated source uses hostedstd.io- the generated
build.folexplicitly declares bundledstdthroughbuild.add_dep({ alias = "std", source = "internal", target = "standard" }) - source code that uses bundled-library names imports that declared alias with
use std: pkg = {"std"}; - this dependency follows from the generated source using hosted APIs, not from
the package being executable; std-free
coreandmemopackages may also usefol code runandfol code test
Pack
Package acquisition commands:
fol pack fetchfol pack update
Examples:
fol pack fetch
fol pack fetch --locked
fol pack fetch --offline
fol pack fetch --refresh
fol pack update
Use pack for:
- materializing dependencies
- writing or honoring
fol.lock - refreshing pinned git dependencies
Code
Build-oriented commands:
fol code checkfol code buildfol code runfol code testfol code emit rustfol code emit loweredfol code explain <CODE>
Examples:
fol code check
fol code build --release
fol code run -- --flag value
fol code emit rust
fol code emit lowered
fol code explain T1003
Use code for:
- driving the compile pipeline
- building binaries through the current Rust backend
- running produced binaries
- emitting backend/debug artifacts
- explaining a diagnostic code emitted by
fol code check
Capability vs execution
fol code run and fol code test evaluate build.fol, select the requested
artifact or step, compile it, and launch it when its target is compatible with
the host. That tool action is not a source capability:
coreandmemoartifacts can run and test without bundledstd- declaring bundled
stdonly makes hosted source APIs available to amemoartifact; it does not grant execution permission - the compiler still rejects heap-backed or hosted APIs that exceed the artifact’s evaluated capability contract
- a foreign selected target is rejected before launch because the frontend has no cross-target runner configuration yet
graph.add_run and graph.add_test choose graph actions. They do not change
the selected fol_model.
Explain
fol code explain <CODE> prints an extended, plain-language explanation for a
diagnostic code — the same code the pretty diagnostic footer points at
(run \fol code explain T1003` for more). It pairs with fol code check`,
which emits the diagnostics it explains.
fol code explain <CODE>
Codes are accepted case-insensitively (t1003 and T1003 are the same).
Examples:
fol code explain T1003
fol code explain t1003
fol code explain --output json R1003
Output modes:
human(default): a family chip, the code, a short title, and the bodyplain:code:/family:/title:lines followed by the bodyjson/--json:{ "code", "family", "known", "title", "explanation" }
Behavior:
- known codes print their explanation and exit
0 - unknown codes print an honest “no extended explanation for
<CODE>yet” message (pointing at the code’s family when the prefix is recognized) and exit nonzero
Only diagnostic codes the compiler and runtime actually emit have
explanations. The registry lives in fol-diagnostics (compiler truth) and is
kept honest by a completeness test, so explain never documents a code that
does not exist.
Tool
Tooling commands:
fol tool lspfol tool format <PATH>fol tool parse <PATH>fol tool highlight <PATH>fol tool symbols <PATH>fol tool references <PATH> --line <LINE> --character <CHARACTER>fol tool rename <PATH> --line <LINE> --character <CHARACTER> <NEW_NAME>fol tool semantic-tokens <PATH>fol tool tree generate <PATH>fol tool cleanfol tool completion
Examples:
fol tool parse src/main.fol
fol tool format src/main.fol
fol tool highlight src/main.fol
fol tool symbols src/main.fol
fol tool references src/main.fol --line 12 --character 8
fol tool rename src/main.fol --line 12 --character 8 total
fol tool semantic-tokens src/main.fol
fol tool tree generate /tmp/fol
fol tool lsp
fol tool completion bash
Use tool for:
- editor integration
- Tree-sitter debugging
- LSP serving
- generated tool assets
Parse And Query Results
parse, highlight, and symbols execute the checked-in generated FOL
Tree-sitter parser in-process. They do not estimate results from source text or
report the contents of query files as if those were matches.
fol tool parse <PATH> reports:
parse_status=okfor a tree with noERRORor missing nodes, otherwiseparse_status=ERROR- root kind plus total and named node counts
- exact
error_countandmissing_countvalues - one zero-based source range and escaped source excerpt for every error or missing node
- the real Tree-sitter S-expression as
syntax_tree=...
The command is error-tolerant: a source file with invalid syntax still produces
its recovered tree and exits through the normal command-result path. For
example, the removed select(channel as value) { ... } form reports an
ERROR node rather than being accepted as a second select grammar.
fol tool highlight <PATH> runs queries/fol/highlights.scm against that tree
and reports the actual capture count, capture kinds, and every capture as:
capture=<name>@<start-row>:<start-column>-<end-row>:<end-column>:<text>
fol tool symbols <PATH> runs queries/fol/symbols.scm, reports the actual
symbol and scope counts, and reports each non-scope capture in the equivalent
symbol=<name>@...:<text> form. Rows and columns are zero-based. Backslashes,
tabs, carriage returns, and newlines in excerpts are escaped.
These three normal commands need no external tree-sitter executable. The
external CLI is required only by fol tool tree generate, which regenerates an
exportable parser bundle.
The public editor surface stays under fol tool ....
There is no parallel fol editor ... command group.
Future editor features are not exposed as placeholder commands.
Only the shipped fol tool subcommands above are public.
Artifact Reporting
Frontend commands report explicit artifact roots when applicable, including:
- emitted Rust crate roots
- lowered snapshot roots
- final binary paths
- fetch/store/cache roots where relevant
Editor Tooling
FOL editor support lives in one crate:
fol-editor
That crate owns two related subsystems:
- Tree-sitter assets for syntax-oriented editor work
- the language server for compiler-backed editor services
Public Entry
The public entrypoints are exposed through fol tool:
fol tool lspfol tool format <PATH>fol tool parse <PATH>fol tool highlight <PATH>fol tool symbols <PATH>fol tool references <PATH> --line <LINE> --character <CHARACTER>fol tool rename <PATH> --line <LINE> --character <CHARACTER> <NEW_NAME>fol tool semantic-tokens <PATH>fol tool tree generate <PATH>fol tool cleanfol tool completion [bash|zsh|fish]
This keeps editor workflows under the same fol binary rather than introducing
a second public tool.
Split Of Responsibilities
Tree-sitter is the editor syntax layer.
It is responsible for:
- syntax trees while typing
- query-driven highlighting
- locals and symbol-style structure captures
- editor-facing structural parsing
The language server is the semantic editor layer.
It is responsible for:
- JSON-RPC/LSP transport
- open-document state
- compiler-backed diagnostics
- hover
- go-to-definition, go-to-type-definition, and go-to-implementation
- current-document symbol highlights
- whole-document formatting
- code actions for exact compiler suggestions The current shipped inventory is intentionally one diagnostic family: unresolved-name replacements where the compiler attached an exact replacement.
- signature help for plain and qualified routine calls
- references
- prepare-rename and rename for same-file local bindings, routine parameters, and current-package top-level symbols
- semantic tokens
- inferred-type inlay hints
- brace-block folding ranges and word/block/file selection ranges
- document symbols
- workspace symbols across discovered
.folfiles under the mapped workspace root - completion and completion-item resolve
These general editor services cover the implemented V1 contract and the checked-in shipped V2 and V3 example matrices. The exact capability list and version-bounded semantic expectations live in Language Server.
The server keeps diagnostics and semantic snapshots separately.
Formatting is intentionally whole-document only right now.
textDocument/rangeFormatting stays unsupported until there is a safe
structure-preserving boundary instead of a partial line-rewriter.
The current formatter contract is intentionally narrow but explicit:
- indentation is four spaces per brace depth
- ordinary code lines are trimmed before indentation is re-applied
- braces inside cooked/raw quotes or any compiler-recognized comment form do not affect indentation depth
- multiline quote/comment payload lines retain their original content
- outside protected multiline payloads, leading blank lines are removed, repeated blank lines collapse to one, and trailing blank lines are removed
- output always ends with one final newline when the document is non-empty
- line endings are normalized to
\n build.folfollows the same formatter entrypoint and indentation rules as ordinary source files
Diagnostics refresh on didOpen and didChange.
Semantic requests keep one semantic snapshot per open document version and reuse it for hover, definition, type definition, implementation, document highlight, signature help, references, prepare rename, rename, semantic tokens, document/workspace symbols, completion, inlay hints, folding ranges, and selection ranges until the document changes or closes.
Compiler Truth
fol-editor does not create a second semantic engine.
Semantic truth still comes from:
fol-packagefol-resolverfol-typecheckfol-diagnostics
So the model is:
- Tree-sitter answers “what does this text structurally look like?”
- compiler crates answer “what does this code mean?”
For the current maintenance contract between compiler crates, LSP behavior, and Tree-sitter assets, see:
Current Practical Workflow
Use:
fol tool lsp
fol tool format path/to/file.fol
as the language server entrypoint.
Launch it from inside a discovered package or workspace root. The frontend
looks upward for build.fol or fol.work.yaml before starting the server.
Use:
fol tool parse path/to/file.fol
fol tool highlight path/to/file.fol
fol tool symbols path/to/file.fol
fol tool format path/to/file.fol
fol tool references path/to/file.fol --line 12 --character 8
fol tool rename path/to/file.fol --line 12 --character 8 total
fol tool semantic-tokens path/to/file.fol
for parser/query debugging and validation.
The three Tree-sitter inspection commands use the checked-in generated parser and execute queries in-process:
parsereturns the real S-expression, node counts, and recoveredERROR/missing-node rangeshighlightexecutes the shipped highlight query and returns every actual capture with its zero-based range and source textsymbolsexecutes the shipped symbol query and returns every actual symbol capture plus the number of captured scopes
All three include parse_status=ok or parse_status=ERROR. Syntax errors do
not prevent inspection of the recovered tree, so dead syntax examples can be
used to verify that a removed form still produces an ERROR node. The normal
inspection path does not shell out to the Tree-sitter CLI; only bundle
generation does.
Compiler Integration
This chapter explains how the compiler and editor tooling are connected today, and what should remain the source of truth as FOL grows.
The Three Layers
FOL editor tooling is split into three layers:
- compiler truth
- semantic editor services
- syntax editor services
Compiler truth lives in crates such as:
fol-lexerfol-parserfol-packagefol-resolverfol-typecheckfol-intrinsicsfol-diagnostics
Semantic editor services live in:
fol-editorLSP analysis and semantic code
Syntax editor services live in:
- the Tree-sitter grammar
- Tree-sitter query files
- editor bundle generation
That split matters because not every editor feature should be solved in the same way.
What The LSP Should Trust
For semantic meaning, the LSP should trust the compiler pipeline.
That means:
- diagnostics come from compiler diagnostics
- hover should be derived from resolved or typed compiler state
- definition should be derived from resolved symbol data
- document symbols should prefer compiler symbol ownership where practical
- completion should prefer compiler facts over text heuristics
- effective capability comes from the evaluated artifact
fol_modelplus any active bundled-standard dependency, not from whether a run/test command was requested
The LSP should not invent a parallel semantic model.
If a feature needs semantic meaning, the first question should be:
- can this come from
fol-parser/fol-resolver/fol-typecheckinstead of editor-only code?
What Tree-sitter Should Trust
Tree-sitter is not the compiler parser.
It exists for:
- syntax trees while typing
- highlighting
- locals queries
- symbol-style structural captures
- future textobjects and editor movement
So Tree-sitter should stay editor-oriented.
That means:
- the grammar can remain handwritten
- query files can remain handwritten
- but duplicated language facts should not be copied manually forever
Examples of facts that should come from compiler-owned truth:
- builtin type spellings
- implemented intrinsic names
- import source kinds
- keyword families used by syntax tooling
How Diagnostics Flow
Today the intended direction is:
- compiler stage creates a structured
fol_diagnostics::Diagnostic - editor tooling adapts that diagnostic for the active editor protocol
- the editor displays the adapted result
The important rule is:
- the compiler diagnostic object is canonical
So when diagnostic wording, labels, helps, or codes change, the editor should adapt that same structure. It should not create its own free-form diagnosis logic.
What Can Be Generated
Generation is useful when the same language fact appears in multiple places.
Good generation targets:
- keyword manifests
- builtin type manifests
- intrinsic name/surface manifests
- source-kind manifests
- small generated query fragments or validation snapshots
Bad generation targets:
- the entire Tree-sitter grammar
- Tree-sitter precedence/conflict structure
- most highlight policy
- LSP transport behavior
The rule of thumb is:
- generate shared facts
- handwrite editor behavior
What To Update For A New Feature
When you add a new language feature, ask these questions in order:
- Does the lexer need new tokens or token families?
- Does the parser need new syntax or AST nodes?
- Does resolver or typechecker logic need new meaning?
- Do lowering, runtime, or backend transfer/execution rules change?
- Does frontend artifact routing or capability evaluation change?
- Does the feature introduce or change diagnostics and explanations?
- Does the formatter or a public
fol toolinspection command need an update? - Does the LSP need hover, completion, definition, symbols, tokens, positions, or artifact-scope updates?
- Does Tree-sitter need grammar, query, or executable corpus updates?
- Can any new editor-visible facts be generated from compiler-owned sources?
- Do canonical positive/failure inventories, docs, or book claims change?
If the answer to any of those is yes, update that layer in the same change set.
End-To-End Feature Completeness
Compiler-backed editor analysis prevents semantic duplication, but it does not make the other mirrors automatic. A shipped feature is complete only when its meaning survives:
- lowering and runtime/backend execution
- frontend artifact selection and capability routing
- structured CLI diagnostics, explanations, and LSP adaptation
- whole-document formatting and public parse/highlight/symbol commands
- explicit LSP UX such as completion, tokens, navigation, and position mapping
- Tree-sitter grammar, highlight/locals/symbol queries, and executable corpus
- canonical positive/failure examples, integration tests, docs, and the book
For V3 specifically, this rule covers every ownership, borrow, pointer, dfr /
edf, spawn, channel, select, mux[T], async, and await boundary. Reusing the
typechecker is necessary, but it is not permission to skip an explicit syntax,
tooling, inventory, or documentation audit.
Runtime-model synchronization must preserve two independent questions across all of those layers:
- does this source stay within its evaluated
core,memo, or hosted (memoplus bundledstd) API tier? - can the frontend execute the selected target on this host (or through a future configured runner)?
The first is compiler-owned capability truth. The second is frontend execution
routing. Neither the LSP nor Tree-sitter should infer hosted source permission
from graph.add_run, graph.add_test, fol code run, or fol code test.
Current Practical Rule
In FOL today:
- compiler crates own meaning
fol-editorowns editor protocol behavior- Tree-sitter owns syntax-oriented editor structure
The safer future direction is:
- move shared contracts closer to compiler-owned crates
- keep protocol/UI code in tooling crates
- generate duplicated facts instead of hand-copying them
Tree-sitter Integration
The Tree-sitter side of FOL is the editor-facing syntax layer.
It is not the compiler parser.
What Is In The Repo
The editor crate carries:
- the grammar source
- executable corpus cases with expected syntax trees
- a raw showcase fixture kept outside the corpus directory
- query files on disk
Canonical query assets live as real files, not just embedded Rust strings:
queries/fol/highlights.scmqueries/fol/locals.scmqueries/fol/symbols.scm
This is intentional because editors such as Neovim expect query files on disk in the standard Tree-sitter layout.
The checked-in generated C parser is also compiled into fol-editor. Therefore
fol tool parse, fol tool highlight, and fol tool symbols use the same
parser and query assets directly in-process. Their reported trees, captures,
symbols, scopes, and ERROR nodes are runtime results, not grammar/query byte
counts or source-text approximations. These inspection commands do not require
an external Tree-sitter installation.
Generated Bundle
To generate a Neovim-consumable bundle, run:
fol tool tree generate /tmp/fol
That writes a bundle containing the grammar, query assets, executable corpus
cases, and test/fixtures/showcase.fol under the target directory. The
showcase is intentionally a raw .fol fixture rather than a corpus case.
Generated file ownership is recorded in .fol-tree-generated: later runs
remove retired manifest-owned assets while preserving files the user added to
the destination. Unsafe absolute or parent-traversing manifest entries are
rejected rather than removed. The generator also rejects a symlink at the
destination root or anywhere along a managed asset path, so generated writes
and retired-file cleanup cannot escape the selected bundle root.
Generation and parser validation happen in a temporary staging directory. Only a complete bundle is committed to the destination; a generator or commit failure leaves an existing bundle unchanged, including its ownership manifest and retired assets.
Bundle generation is the one public Tree-sitter command that invokes the
external tree-sitter CLI. The checked-in parser used by ordinary inspection
commands remains available without that executable.
The intended consumer path is:
- generate bundle
- point the editor’s Tree-sitter parser configuration at that bundle
- let the editor compile/use the parser from there
File Ownership
Intentionally Handwritten
These files are human-authored and should remain so:
| File | Purpose | Owner |
|---|---|---|
tree-sitter/grammar.js | Grammar rules, precedence, conflicts | Editor/syntax maintainer |
queries/fol/highlights.scm | Highlight capture groups and query patterns | Editor/syntax maintainer |
queries/fol/locals.scm | Scope and definition tracking | Editor/syntax maintainer |
queries/fol/symbols.scm | Symbol navigation captures | Editor/syntax maintainer |
test/corpus/*.txt | Corpus programs plus their expected node trees | Editor/syntax maintainer |
Do not attempt to generate these from the compiler parser. The parsing models are different and auto-generation creates more fragility than value.
Validated Against Compiler Constants
These facts appear in handwritten files but are validated by integration tests to stay in sync with compiler-owned constants:
| Fact | Handwritten Location | Compiler Source |
|---|---|---|
| Builtin type names | highlights.scm regex ^(int|bol|...)$ | BuiltinType::ALL_NAMES in fol-typecheck |
| Dot-call intrinsic names | highlights.scm regex ^(len|echo|...)$ | Implemented DotRootCall entries in fol-intrinsics |
| Container type names | highlights.scm node labels + grammar.js choice | CONTAINER_TYPE_NAMES in fol-parser |
| Shell type names | highlights.scm node labels + grammar.js choice | SHELL_TYPE_NAMES in fol-parser |
| Source kind names | highlights.scm node labels + grammar.js choice | SOURCE_KIND_NAMES in fol-parser |
The sync tests live in test/run_tests.rs under treesitter_sync. If you add
a new builtin type, intrinsic, container, shell, or source kind to the compiler,
these tests fail until the tree-sitter files are updated to match.
When To Update Tree-sitter Files
grammar.js
Update when:
- A new declaration form is added (e.g. a new
segorlabdeclaration) - A new expression or statement form is added
- A new type syntax is added (e.g. a new container or shell family)
- A new source kind is added
- Operator precedence or conflict rules change
Do not update for:
- New diagnostic codes or error messages
- New resolver/typecheck rules that don’t change syntax
- New intrinsics (these use existing
dot_intrinsicgrammar rule)
highlights.scm
Update when:
- A new keyword needs highlighting
- A new builtin type is added to the compiler
- A new implemented dot-call intrinsic is added
- A new container or shell type family is added
- A new source kind is added
- Highlight group policy changes (e.g. moving a keyword from
@keywordto@keyword.function)
locals.scm
Update when:
- Scope rules change (e.g. new block forms that introduce scopes)
- Definition capture patterns change
symbols.scm
Update when:
- New declaration forms should appear in document symbol navigation
Corpus fixtures
Update when:
- Any grammar rule is added or modified
- A new syntax family needs parse-tree validation
Corpus Coverage Expectations
Corpus fixtures live in tree-sitter/test/corpus/ and cover syntax families:
| Corpus File | Covers |
|---|---|
declarations.txt | use, ali, typ, fun, log, var declarations |
expressions.txt | Intrinsic calls, when/loop control flow, break/return |
recoverable.txt | Error propagation (/), report, pipe-or (||) |
v3_ownership.txt | owned allocation, borrow options, ~var, and give-back |
v3_pointers.txt | nested ptr[...] / chn[...] types, @ types, address-of, and dereference |
v3_deferred.txt | dfr and error-only edf blocks |
v3_channels_select_mutex.txt | spawn, channel endpoints, multi-arm select, and mux[T] parameters |
v3_eventuals.txt | spawn plus | async and | await stages |
v3_lexical_boundaries.txt | multiline backtick/slash-block comments, exact [doc], and raw single-quoted character/string boundaries |
Every file in this directory is a real Tree-sitter corpus case: the FOL source
appears before ---, and the exact expected node tree appears after it. A raw
fixture without an expected tree belongs under tree-sitter/test/fixtures/,
not under test/corpus/.
Regenerate the checked-in bundle and execute the external corpus runner with:
make tree-test
This lane fails when zero corpus cases execute, when any expected tree drifts, or when any case fails. Ordinary Rust editor tests also export the bundle to a temporary root and require the external runner to execute every registered case successfully.
When a new syntax family is added, it should have corpus coverage. The expected families that should each have at least one corpus example:
- Import declarations (
use) - Type declarations (
typ,ali) - Routine declarations (
fun,log) - Variable declarations (
var) - Control flow (
when,loop,case,break,return) - Expressions (binary, call, field access, dot intrinsic)
- Container and shell types
- Record and entry types
- Error handling (
report, pipe-or,check) - V3 ownership and pointer sigils in both expression and nested type positions
- V3 deferred blocks (
dfr/edf) - V3 processor syntax (
[>],chn[...], channel endpoints,select,mux[T],\| async, and\| await) - compiler-recognized lexical protection boundaries, especially multiline comments and raw single-quoted literals containing V3 sigils or braces
Tree-sitter keeps the compiler lexer’s four comment classes visible: ordinary
backtick, exact `[doc]...` documentation comments, //, and non-nested
/* ... */. Backtick, documentation, and slash-block comments may span lines.
Single quotes remain raw: backslashes do not introduce escapes, one Unicode
scalar is a character, and empty or multi-scalar payloads are raw strings.
The removed single-header select(channel as value) { ... } and
double-parenthesis mutex-parameter forms are not alternate current syntax.
Their fail_proc_* examples guard rejection; if either spelling appears in a
tree-sitter corpus, it must be an explicit parse-error expectation rather than
a second accepted grammar path.
What Tree-sitter Is For
Use the Tree-sitter layer for:
- highlighting
- locals and capture queries
- symbol-style structural views
- editor textobjects and movement later
Do not use it as a substitute for typechecking or resolution.
Those remain compiler tasks.
In particular, tree-sitter does not decide whether a unique-pointer field can
be dereferenced, whether a moved owner can be reinitialized in deferred work,
whether deferred cleanup may initiate report, whether a call is a legal
direct spawn/async target, whether a nested routine implicitly captures an
outer local, whether a recoverable eventual remains live across fallthrough or
an exiting break/return/report, whether await is used inside edf, or
whether mutex effects can occur inside dfr/edf. The LSP reports compiler
diagnostics for those semantic boundaries. Tree-sitter is responsible only for
the corresponding syntax shape, highlighting/query captures, and corpus
coverage. The exact positive and negative processor package matrix lives in the
shipped processor inventory.
When a language feature changes syntax, use the Feature Update Checklist to decide whether the grammar, queries, corpus, or generated language facts also need updates.
Feature Update Checklist
Use this checklist whenever a new language feature, syntax form, intrinsic, or error surface is added.
This chapter is about maintenance discipline, not just implementation order.
Quick Reference
When adding a feature, touch these layers in order:
- Lexer — new keywords, operators, tokens
- Parser — new AST nodes, syntax rules
- Semantics — resolver, typecheck, lowering, intrinsics
- Runtime/backend — representation, transfer, cleanup, execution
- Frontend/build routing — artifact model, active dependencies, command legality
- Diagnostics — codes, explanations, labels, related sites
- Formatter/tool commands — protected text, parse/highlight/symbol output
- LSP — hover, completion, definition, symbols, positions
- Tree-sitter — grammar, queries, corpus
- Generated facts and inventories — compiler-owned constants, exact examples
- Docs and book — language chapters, tooling pages, boundary claims
- Tests and examples — unit, integration, editor, corpus, positive/failure packages
Automated guards exist for some of these. The treesitter_sync integration
tests verify that highlights.scm matches compiler-owned constants for builtin
types, intrinsic names, container/shell types, and source kinds. Adding a new
constant to the compiler without updating Tree-sitter will fail those tests.
1. Lexical Surface
Check:
- new keywords
- new operators or punctuation
- new literal/token families
- comment or whitespace effects
Update:
fol-lexer- lexical docs under
100_lexical - any generated keyword/facts manifest if one exists
2. Parser Surface
Check:
- new declarations
- new expressions
- new statement forms
- new type forms
- new precedence or ambiguity rules
Update:
fol-parser- parser tests
- Tree-sitter grammar if the syntax is editor-visible
- Tree-sitter corpus fixtures for the new syntax family
3. Semantic Surface
Check:
- name resolution
- type checking
- lowering
- intrinsic availability
- runtime/backend impact
Update:
fol-resolverfol-typecheckfol-lowerfol-intrinsics- runtime/backend crates if needed
4. Runtime And Backend Surface
Check:
- lowered representation and ownership-transfer mode
- runtime type or helper requirements
- cleanup/drop behavior on normal, error, branch, loop, and task paths
- capability-tier imports and backend emission
- executable behavior for every supported positive form
Update:
fol-lowerfol-runtimefol-backend- emitted-code and end-to-end execution tests
5. Frontend And Build Routing
Check:
- artifact source scope and selected
fol_model - evaluated conditional dependencies and bundled-standard aliases
- direct, workspace, and named-step routes
- language API legality versus host target compatibility for
run/test - no accidental bundled-std requirement merely because an artifact executes
- ambiguous mixed-model files and packages
Update:
fol-build/fol-packagewhen graph truth changesfol-frontendrouting and command behavior- editor workspace mapping when analysis scope changes
- direct, routed, mixed-model, and conditional-build integration tests
Important rule:
- use the evaluated artifact contract as the authority
- do not reconstruct capability truth from static
build.foldeclarations - do not fall back from an ambiguous mixed-model file to a wider model
6. Diagnostics Surface
Check:
- new error cases
- new warning/info cases
- changed wording for existing rules
- changed labels, notes, helps, or suggestions
Update:
- compiler producer diagnostics
fol-diagnosticscontract tests- editor/LSP diagnostic adapter tests if the visible shape changes
- docs under
650_errorsif behavior changed materially
7. Formatter And Tool Commands
Check:
- whether new syntax changes indentation or structural scanning
- whether comments, cooked strings, raw strings, or character literals may contain the new syntax without affecting formatting
- whether
fol tool parse,highlight, orsymbolsexpose the new shape - whether command output, exit status, and parse-error recovery remain honest
Update:
- the shared compiler-aware source scanner before adding command-specific textual heuristics
- whole-document formatter tests
- in-process parser/query command tests
- public tool-command documentation
8. LSP Surface
Check:
- hover content
- go-to-definition behavior
- document symbols
- completion
- open-document analysis behavior under broken code
- semantic tokens, signature/type information, and rename eligibility
- UTF-16 position conversion for incoming and outgoing ranges
- related diagnostic URIs and multi-file locations
- active artifact scope and capability-aware suggestions
Update:
fol-editorsemantic analysisfol-editorsemantic display helpers or compiler-owned helpers they consume- LSP tests
Important rule:
- prefer compiler-backed meaning
- use fallback heuristics only when the compiler cannot supply semantic data yet
9. Tree-sitter Surface
Check:
- syntax shape visible while typing
- highlight groups
- locals captures
- symbols captures
- corpus examples
Update:
tree-sitter/grammar.jsqueries/fol/highlights.scmqueries/fol/locals.scmqueries/fol/symbols.scmtree-sitter/test/corpus/*.txt
Important rule:
- Tree-sitter is for syntax-facing editor behavior
- it does not replace compiler semantics
10. Generated Facts And Inventories
Check whether the feature adds a new fact that should be exported once instead of copied by hand.
Examples:
- intrinsic names
- builtin type names
- source kinds
- keyword groups
- shell/container family names
- positive and negative example package directories
If yes:
- update the compiler-owned source
- regenerate editor-facing artifacts from that source
- do not patch multiple copies manually
- update the canonical shared example inventory and its published book matrix together; do not create a private LSP-only list
11. Documentation
Update:
- the language chapter for the feature
- tooling docs if editor behavior changes
- diagnostics docs if compiler reporting changes
- examples/fixtures that demonstrate the preferred form
12. Tests And Examples
Add or update:
- compiler unit tests
- integration tests
- editor/LSP tests if semantic editor behavior changes
- Tree-sitter tests/corpus if syntax-facing behavior changes
- a runnable positive package for shipped behavior
- a
fail_*package for each deliberate boundary or removed form - the canonical inventory guard when an example directory is added, removed, or renamed
Keep the feature test in the same change as the feature.
13. Final Review Questions
Before considering the feature complete, answer:
- Is compiler meaning implemented?
- Does lowering/runtime/backend preserve that meaning on every control-flow path?
- Does frontend routing select the exact evaluated artifact capability?
- Are diagnostics coded, explained, structured, and location-correct?
- Do formatter and public tool commands understand the new syntax boundaries?
- Does the LSP reflect the meaning, model, ranges, and UX where needed?
- Does Tree-sitter reflect the syntax in grammar, queries, and executable corpus?
- Did we generate shared facts and reuse one canonical example inventory?
- Do positive examples run and deliberate boundaries fail with the expected code?
- Do docs and the book state the shipped behavior without preserving dead syntax?
Language Server
The FOL language server is started with:
fol tool lsp
It runs over stdio and is meant to be launched by editors.
Current Feature Set
The current working surface includes:
- initialize / shutdown / exit
- document open / change / close
- diagnostics
- hover
- go-to-definition
- go-to-type-definition (a value’s declared type, unwrapping optional, container, ownership/borrow, pointer, channel, and eventual shells)
- go-to-implementation (a protocol standard’s conforming types)
- document highlight (occurrences of the symbol under the cursor, current file)
- whole-document formatting
- code actions for compiler-suggested unresolved-name replacements
- signature help for plain and qualified routine calls
- references
- rename for same-file local bindings, routine parameters, and current-package top-level symbols, with prepare-rename validation
- semantic tokens
- inlay hints (inferred type for
var/lab/conbindings without an explicit annotation) - folding ranges (brace blocks)
- selection ranges (word -> enclosing block -> file)
- document symbols
- workspace symbols across discovered
.folfiles under the mapped workspace root - completion, including language-keyword suggestions and completion-item resolve
- the same compiler-backed behavior for ordinary source files and
build.fol
The general editor UX above is shared across language versions. Semantic coverage claims are deliberately bounded to the implemented V1 contract, the checked-in V2 generic-routine, generic-type, constrained-generic, and protocol-standard example subset, and the checked-in V3 memory and processor inventories. The server does not claim broad rename outside the documented safe classes, range formatting, or editor-owned semantics beyond what compiler-backed analysis supplies.
The server also does not advertise workspace/executeCommand, so it does not
emit a dead run code lens. Package execution remains the explicit
fol code run CLI path. That path still enforces compiler-owned API legality,
but bundled std is not an execution permission: host-compatible core and
memo artifacts can run without it.
That means build files should not need a separate editor mode.
build.fol goes through the same language server entrypoint as the rest of the
package.
textDocument/didChange accepts both whole-document replacements and ranged
change events. The server applies ranged changes in order against the current
in-memory document version.
The server advertises incremental text sync by default. Full-document sync remains an explicit opt-in for clients/configs that need it.
Formatting is intentionally limited to textDocument/formatting.
textDocument/rangeFormatting remains unsupported until the formatter can
preserve surrounding structure safely instead of guessing at partial blocks.
Current whole-document formatting also normalizes blank-line runs outside multiline quote/comment payloads: leading blank lines are removed and repeated blank lines collapse to one. Payload lines are preserved, and braces inside quotes or compiler-recognized comments do not affect indentation.
Analysis Model
The server works like this:
- receive editor request/notification
- map the open document to a package/workspace context
- materialize an analysis overlay for the in-memory document state
- run parse/package/resolve/typecheck as needed
- convert compiler results into LSP responses
Package/workspace root discovery is cached for the current session once a document directory has been mapped.
When evaluated build.fol state activates a bundled internal standard
dependency, the disposable analysis overlay materializes that dependency under
its active declared alias. Conditional declarations that are inactive for the
selected target/options do not become resolvable merely because their text is
present in build.fol. Hosted examples therefore resolve in a fresh checkout;
editor tests and users do not need to pre-populate .fol/pkg first.
The same evaluated state supplies the artifact’s core or memo contract to
compiler-backed analysis. The LSP never widens that contract because an
artifact has a run or test step: execution registration is not semantic input.
That means diagnostics and navigation are compiler-backed, not guessed from Tree-sitter alone.
For open documents, diagnostics and semantic snapshots are cached separately.
didOpen and didChange refresh diagnostics for the current in-memory text.
Hover, definition, type definition, implementation, document highlight,
signature help, references, prepare rename, rename, semantic tokens, document
symbols, workspace symbols, completion, inlay hints, folding ranges, and
selection ranges use a semantic snapshot keyed by document version
and reuse it until didChange or didClose invalidates it.
Diagnostics
Compiler diagnostics remain canonical.
The server adapts them into LSP diagnostics for the currently open document.
LSP diagnostics include the diagnostic code in the message (e.g.
[R1003] could not resolve name 'answer') so editors display the code inline.
The server removes only exact wire-identical diagnostics before publishing. Diagnostics that share a line and code but differ in range, message, severity, or related information remain visible. This catches true cross-stage duplicates without hiding distinct failures that happen to occupy one line.
Expected Behavior
If the LSP client is attached correctly, you should expect:
- source-file diagnostics in the open file
- hover on resolved symbols
- go-to-definition across current-package and imported symbols where supported
- go-to-type-definition for values with a compiler-known declared type
- go-to-implementation from protocol standards to conforming types
- current-document highlights for a resolved symbol’s declaration and uses
- whole-document formatting edits from
textDocument/formatting - code actions only when the compiler attached an exact replacement suggestion The current shipped code-action inventory is intentionally narrow: unresolved-name replacements only.
- signature help for supported routine call sites
- references for the supported symbol classes
- rename for same-file local bindings, routine parameters, and current-package
top-level symbols only, with
prepareRenameenforcing the same eligibility - semantic tokens for semantic identifier categories
- inferred-type inlay hints for unannotated
var/lab/conbindings - brace-block folding ranges and word/block/file selection ranges
- document symbols for the current file
- workspace symbols across discovered
.folfiles under the mapped workspace root - completion in ordinary source files and
build.fol, including language keyword suggestions (declaration, control-flow, literal, and diagnostic keywords from the lexer’s keyword tables) alongside resolver-backed symbols in plain positions - shipped V2 example diagnostics/navigation for:
- generic routines
- generic types
- constrained generics
- protocol standards
- current positive executable V2 example roots covered directly in editor tests:
examples/generic_type_exec_m1m2examples/generic_standard_constraint_m1m2examples/standards_protocol_m2
- compiler-backed V3 diagnostics and navigation for the checked-in ownership,
borrowing, pointer,
dfr/edf, spawn, channel,select,mux[T], async, and await examples - positive V3 roots in LSP/open/navigation inventory sweeps and
fail_mem_*/fail_proc_*roots in the guarded diagnostic inventory; the exact processor list is maintained in the shipped processor inventory - model-aware completion and semantic-token coverage for the shipped V3 type,
declaration-sigil, channel, mutex, and processor syntax, without suggesting
hosted processor surfaces in
coreor unhostedmemo
The LSP does not independently decide ownership, thread safety, channel
lifecycle, or delayed mutex effects. Those rules come from compiler analysis.
Current negative coverage includes unique-pointer field dereference without
place-aware projection IR, moved-owner reinitialization inside dfr/edf,
terminating report inside deferred cleanup, indirect stored/parameter routine
targets for spawn and async, implicit nested-routine capture of outer locals,
recoverable eventuals left live at fallthrough or an exiting
break/return/report, awaiting an eventual inside edf, and mutex field,
guard, or mux[T] forwarding effects inside deferred bodies.
If the client is attached but you see no diagnostics at all, the likely issue is not Tree-sitter. It is the LSP request/attach path.
For the compiler-owned contracts behind diagnostics and semantic editor behavior, see Compiler Integration.
Neovim Integration
Neovim integration has two separate pieces:
- Tree-sitter for syntax/highlighting/queries
- LSP for diagnostics and semantic editor features
They should be configured together, but they do different jobs.
LSP Setup
The language server command is:
vim.lsp.config("fol", {
cmd = { "fol", "tool", "lsp" },
filetypes = { "fol" },
root_markers = { "fol.work.yaml", "build.fol", ".git" },
})
vim.lsp.enable("fol")
Also ensure Neovim recognizes .fol files:
vim.filetype.add({
extension = { fol = "fol" },
})
Tree-sitter Setup
First generate a bundle:
fol tool tree generate /tmp/fol
Then point Neovim’s Tree-sitter parser config at that bundle:
local parser_config = require("nvim-treesitter.parsers").get_parser_configs()
parser_config.fol = {
install_info = {
url = "/tmp/fol",
files = { "src/parser.c" },
requires_generate_from_grammar = false,
},
filetype = "fol",
}
The query files are expected at:
/tmp/fol/queries/fol/highlights.scm/tmp/fol/queries/fol/locals.scm/tmp/fol/queries/fol/symbols.scm
Neovim also needs that bundle on runtimepath so it can find the queries.
Practical Model
Tree-sitter provides:
- highlight captures
- locals captures
- symbol-style structure queries
LSP provides:
- diagnostics
- hover
- definitions
- references
- rename for same-file local and current-package top-level symbols
- semantic tokens
- document symbols
So the normal editor shape is:
- Neovim opens a
.folfile - Tree-sitter handles syntax/highlighting
- Neovim launches
fol tool lsp - the server provides semantic editor features
Debugging A Setup
Useful checks:
:echo &filetype
:lua print(vim.inspect(vim.lsp.get_clients({ bufnr = 0 })))
And from the shell:
fol tool tree generate /tmp/fol
fol tool lsp
If fol tool lsp prints nothing and waits, that is correct.
It is a stdio server, not an interactive shell command.
If the server refuses to start, check that Neovim opened the file inside a
directory tree that contains build.fol or fol.work.yaml.
Build System
FOL uses a Zig-style build model. The build specification is a normal FOL
program called build.fol. It goes through the full compiler pipeline and
is executed against a build graph IR instead of emitting backend code.
The build system lives in lang/execution/fol-build. It handles:
- graph IR construction for artifacts, steps, options, modules, and generated files
- full control flow in build programs (when, loop, helper routines)
-DCLI option passing into the build program- named step selection at the command line
Entry Point
Every buildable package must have a build.fol at its root with exactly one
canonical entry:
pro[] build(): non = {
var build = .build();
build.meta({ name = "app", version = "0.1.0" });
var graph = build.graph();
...
}
The active build context is accessed explicitly through the build-only ambient accessor:
.build()
There is no injected graph parameter anymore. .build() returns an opaque
build-only handle. Users do not name its type explicitly. Package metadata and
direct dependencies are configured through that handle, and graph work is
reached through build.graph().
Minimal Example
pro[] build(): non = {
var build = .build();
build.meta({ name = "app", version = "0.1.0" });
var graph = build.graph();
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
fol_model = "core",
});
graph.install(app);
graph.add_run(app);
}
This registers package metadata, adds an executable, marks it for installation,
and binds a default run step. The artifact uses core, so its source has no
heap-backed or hosted APIs. It can nevertheless be launched with fol code run
on a compatible host; no bundled std dependency is needed just to execute it.
fol_model is an artifact capability choice. graph.add_run and
graph.add_test only register tool actions; they neither widen that capability
nor require bundled std.
What fol-build Owns
graph.rs— build graph IR (steps, artifacts, modules, options, generated files)api.rs— Rust-level graph mutation interfacesemantic.rs— method signatures and type info for the resolver and typecheckerstdlib.rs—BuildStdlibScope: the ambient scope injected intobuild.folexecutor.rs— executes the lowered FOL IR against the build grapheval.rs— evaluate abuild.folfrom source; entry point forfol-packageoption.rs— build option kinds, target triples, optimize modesruntime.rs— runtime representation of artifacts, generated files, step bindingsstep.rs— step planning, ordering, cache keys, execution reportscodegen.rs— system tool and codegen request typesartifact.rs— artifact pipeline definitions and output typesdependency.rs— inter-package dependency surfaces
Use this section for:
- understanding the shape of
build.fol - the full graph API reference
- control flow available inside
build.fol - build options and
-Dflags - artifact types, modules, and generated files
- dependency handles and unified output handles
Near-Term Architecture
The next build round is about extending the existing explicit surface, not replacing it.
The intended layering is:
build.add_dep({...})declares a direct dependency and returns a dependency handlebuild.export_*({...})declares the build-facing surface a package chooses to exposegraph.file_from_root(...)andgraph.dir_from_root(...)remain the typed source-path producers- broader path-oriented exports and dependency path queries sit on top of those producers instead of collapsing back into raw string paths
- dependency modes, install reporting, and system integration should become more
concrete without changing the top-level
.build()structure
This means the near-term additions should look like richer values and richer
queries on top of the current build graph, not a new manifest format and not a
public Graph or Build type.
Standalone Examples
These checked-in example packages exercise the current public build surface:
examples/build_dep_exportsexamples/build_source_pathsexamples/build_dep_modesexamples/build_described_stepsexamples/build_generated_dirsexamples/build_dep_handlesexamples/build_output_handlesexamples/build_install_prefixexamples/build_system_libexamples/build_system_tool
Runtime-model reminder:
- examples that rely on hosted language APIs such as
.echo(...)or V3 processor facilities should spellfol_model = "memo"and declare bundledstd coreandmemoexamples may be executable and use routedrun/test; they should stay free of source-level hosted API assumptions- frontend host-tool and artifact launching is separate from language capability tiering
- the current frontend has no cross-target runner configuration, so routed
run/testreject foreign targets even though those targets may be built
Bundled std reminder:
stdships with FOL underlang/library/std- packages using hosted APIs add bundled std explicitly through:
.build().add_dep({ alias = "std", source = "internal", target = "standard" }) - normal hosted-API packages should rely on the bundled shipped
std, not an external replacement package
build.fol
build.fol is a file-bound FOL compilation unit. It is the build specification
for a package.
File-Bound vs Folder-Bound
Normal FOL packages are folder-bound: every .fol file in the package folder
shares one namespace. build.fol is the one exception.
Rules for build.fol:
- It is its own compilation unit — it does not see sibling
.folfiles - It has one implicit build stdlib scope, exposing
.build()and build-only handle methods - It can define local helper
fun[],pro[], andtypdeclarations - Those local declarations are not exported to the package
- It must declare exactly one
pro[] build(): nonentry
The implicit build stdlib is the restricted API used to evaluate build.fol.
It is not the bundled source package named std, does not change an artifact’s
fol_model, and does not expose hosted language APIs to ordinary package
sources.
Compilation Pipeline
build.fol goes through the full FOL compiler pipeline:
build.fol
│
▼ stream → lexer → parser
│
▼ fol-resolver (build stdlib injected as ambient scope)
│
▼ fol-typecheck (handle types and method signatures validated)
│
▼ fol-lower (lowered IR produced)
│
▼ fol-build executor (IR executed against BuildGraph)
│
▼ BuildGraph
The compiler rejects build.fol files that reference sibling source files,
use filesystem or network APIs, or contain more than one canonical entry.
Canonical Entry
The entry must match exactly:
pro[] build(): non = {
var build = .build();
var graph = build.graph();
...
}
pro[]— procedure with no receivers- no parameters
- return type
non
The active build context is accessed explicitly through the ambient build-only accessor:
.build()
.build() returns an opaque build-only handle. The handle type is not public
language surface and should not be named explicitly in source code. Graph access
is reached through build.graph().
Missing entry, wrong signature, duplicate entries, the old injected graph
parameter form, or explicit Graph type syntax are compile errors.
Ambient Build API
The canonical build shape is:
pro[] build(): non = {
var build = .build();
build.meta({
name = "app",
version = "0.1.0",
kind = "exe",
});
build.add_dep({
alias = "json",
source = "pkg",
target = "json",
});
var graph = build.graph();
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
fol_model = "memo",
});
graph.install(app);
graph.add_run(app);
}
The public layering is:
.build()for package-level build contextbuild.meta({...})for package metadatabuild.add_dep({...})for one direct dependencybuild.export_module({...}),build.export_artifact({...}),build.export_step({...}), andbuild.export_output({...})for the dependency-facing build surface this package exposesbuild.graph()for artifact and step graph work
Capability selection and dependency declaration are separate:
- each artifact selects
fol_model = "core"orfol_model = "memo"; omitting the field currently selects the defaultmemomodel - a
memosource scope that uses hosted names declares bundledstdwithbuild.add_dep({ alias = "std", source = "internal", target = "standard" }) stdis not a validfol_model, and the dependency is not needed merely because an artifact is executable or registered withgraph.add_runfol code runandfol code testlaunch host-compatible artifacts using the evaluated graph; their target check is independent of source API capability
The public surface includes:
- dependency handles returned from
build.add_dep({...}) - unified output handles for generated and copied files
- explicit dependency build arguments
- a cleaner install-prefix model
Build/cache internals and installed outputs are separate:
- build root for emitted and intermediate build artifacts
- cache roots for reusable local state
- install prefix for user-visible installed outputs
Do not put package metadata directly on the graph handle.
Local Helpers
Current boundary:
- defining
fun/prohelpers inbuild.foland calling them frombuild()is not part of the current build surface; the evaluator cannot execute them today - this section describes later design work, not current behavior
when,loop, andcaseinbuild.folare the current build surface
build.fol can define helper functions visible only within itself:
fun[] make_lib(name: str, root: str): Artifact = {
return .build().graph().add_static_lib({ name = name, root = root });
}
pro[] build(): non = {
var build = .build();
var graph = build.graph();
var core = make_lib("core", "src/core/lib.fol");
var io = make_lib("io", "src/io/lib.fol");
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.link(core);
app.link(io);
graph.install(app);
}
Helpers may call .build() ambiently, but they do not name a public build or
graph type in source.
Package Control
build.fol is the only package control file.
Package metadata and direct dependencies are configured from inside
pro[] build(): non through the ambient build context.
build.meta({...})
build.meta({...}) configures package metadata for the current package.
Typical fields belong here:
nameversionkinddescriptionlicense
This is package identity and package description data. It is not graph mutation.
build.add_dep({...})
build.add_dep({...}) registers one direct dependency of the current package.
Typical fields belong here:
aliassourcetargetversionhashmodeargs
For example:
build.add_dep({
alias = "shared",
source = "loc",
target = "../shared",
});
build.add_dep({
alias = "json",
source = "pkg",
target = "json",
mode = "lazy",
});
build.add_dep({
alias = "logtiny",
source = "git",
target = "git+https://github.com/bresilla/logtiny.git",
version = "tag:v0.1.3",
hash = "b242d319644a",
});
For git dependencies:
targetis only the repository locatorversionchoosesbranch:<name>,tag:<name>, orcommit:<sha>hashis optional commit-prefix verification- selector query params in
targetare not supported
See examples/build_git_dep_versions for a standalone package that shows branch, tag, commit, and hash-pinned git dependencies together.
Supported dependency modes:
eagerlazyon-demand
Current semantics:
eagerdirect package-store dependencies are prepared immediately during package loadinglazydependency metadata is kept, but package-store preparation is deferred until a later build/import path needs iton-demandsame deferred loading rule aslazy, but user-facing summaries keep the stronger intent visible
fol code fetch still walks declared dependencies so it can materialize and pin
the workspace graph. The mode is surfaced in fetch/build summaries instead of
being dropped.
Forwarded dependency args stay explicit:
var graph = build.graph();
var target = graph.standard_target();
var optimize = graph.standard_optimize();
var fast = graph.option({ name = "use_fast_parser", kind = "bool", default = true });
build.add_dep({
alias = "json",
source = "pkg",
target = "json",
mode = "lazy",
args = {
target = target,
optimize = optimize,
use_fast_parser = fast,
jobs = 4,
flavor = "strict",
},
});
This declares direct dependencies only.
Transitive dependencies stay declared in each dependency package’s own
build.fol.
Nothing is forwarded implicitly from the parent build. If a dependency should
see target, optimize, or a package-specific option, pass it explicitly in
args.
Explicit Dependency Exports
Dependency handles only see build-facing names that the dependency package
exports from its own build.fol.
var graph = build.graph();
var codec = graph.add_module({ name = "codec", root = "src/codec.fol" });
var lib = graph.add_static_lib({ name = "json", root = "src/main.fol" });
var docs = graph.step("docs", "Validate the package");
build.export_module({ name = "api", module = codec });
build.export_artifact({ name = "runtime", artifact = lib });
build.export_step({ name = "check", step = docs });
Ordinary source imports still resolve by dependency alias under .fol/pkg.
Build-handle queries stay separate from source imports.
build.graph()
build.graph() returns the opaque graph handle used for artifact and step
construction.
Graph-only work belongs here:
add_exeadd_static_libinstalladd_runstandard_target
Named steps may also carry an optional description:
var docs = graph.step("docs", "Generate documentation");
standard_optimizewrite_file
That split keeps the build surface clear:
- package metadata through
build.meta - direct dependencies through
build.add_dep - artifact graph mutation through
build.graph()
Capability Restrictions
The build executor enforces a capability model. Allowed operations:
- graph mutation (adding artifacts, steps, options)
- option reads (
.build().graph().standard_target(),.build().graph().standard_optimize(), etc.) - source-path handles (
.build().graph().file_from_root(...),.build().graph().dir_from_root(...)) - basic string and container operations
- controlled file generation (
.build().graph().write_file(...),.build().graph().copy_file(...)) - controlled process execution (
.build().graph().add_system_tool(...))
Forbidden operations (produce a compile or runtime error):
- arbitrary filesystem reads or writes
- network access
- wall clock access
- ambient environment variable access
- uncontrolled process execution
These restrictions ensure build graphs are deterministic and portable.
Graph API
build.graph() is the public access point to the build graph in build.fol.
All graph construction goes through method calls on the returned handle.
This layer is intentionally narrower than the whole build surface:
- package metadata belongs on
build.meta({...}) - direct dependencies belong on
build.add_dep({...}) - future dependency queries and output handles sit above raw graph mutation and should not collapse back into ad hoc string-only graph APIs
The canonical shape is:
pro[] build(): non = {
var build = .build();
build.meta({ name = "app", version = "0.1.0" });
var graph = build.graph();
...
}
Artifacts
graph.add_exe
Adds an executable artifact.
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
fol_model = "memo", // optional; memo is the default
target = target, // optional
optimize = optimize, // optional
});
Returns an Artifact handle.
Required fields: name, root.
Optional fields: fol_model, target, optimize.
An omitted fol_model currently defaults to memo. Spell core explicitly;
an explicit memo remains useful when showing mixed-model intent, but omission
and fol_model = "memo" select the same capability.
root is the path to the entry-point .fol source file relative to the
package root.
The resolved root must be a real source file contained by that package. The
frontend canonicalizes the path before checking containment, so neither ..
nor a symlink may make an artifact root escape the package.
For an artifact-targeted build, the directory containing root is the source
scope: FOL recursively parses that directory and checks every source in it
under the artifact’s fol_model. A package may declare both core and memo
artifacts only when their source scopes are disjoint. The scopes overlap, and
the graph is rejected, when they are the same directory or one contains the
other. Put different-model artifacts in sibling directories or separate
package members; do not put core.fol and memo.fol beside each other in one
recursively parsed source directory.
Package-wide commands that require one capability model reject mixed-model packages. Use an exact artifact or named build step when targeting one of the disjoint scopes.
fol_model selects one of the two runtime capability modes for the artifact:
coreno heap and no source-level hosted OS/runtime APIsmemoalloc-like heap-backed facilities, still no source-level hosted OS/runtime APIs
The division deliberately follows Rust’s capability shape:
- FOL
coreis analogous to#![no_std]with Rustcore - FOL
memois analogous to#![no_std]withcoreplusalloc - FOL
memoplus the bundledstandarddependency is analogous to hosted Ruststd
This is a capability analogy. It is independent from the build tool’s ability
to launch a resulting host executable. The current Rust backend is still
hosted, so FOL core is a source/runtime contract and is not yet a claim of
finished freestanding backend support.
Bundled std is not a third fol_model. A memo artifact reaches the hosted
API tier only when the package explicitly declares the bundled internal
dependency:
build.add_dep({ alias = "std", source = "internal", target = "standard" });
Source code reaches that dependency through its declared alias, for example
use std: pkg = {"std"};.
The important boundary is semantic and runtime-facing:
coreartifacts must not use heap-backedstr,vec,seq,set, ormapcoreartifacts may still use arrays, records, routines, control flow, anddfrmemoartifacts may use heap-backed runtime types but not hosted language APIs- bundled
stdwrappers require an explicit internalstandarddependency - hosted wrappers and processor features require
fol_model = "memo"plus that explicit bundledstddependency - executable
coreandmemoartifacts may usefol code runandfol code testwithout bundledstd
| Source requirement | fol_model | Bundled standard dependency |
|---|---|---|
| no heap-backed or hosted language APIs | core | no |
| heap-backed values, no hosted APIs | memo | no |
| shipped hosted or processor APIs | memo | yes |
The last row is still a memo artifact. std is not an accepted fol_model
value.
The frontend invoking a host-compatible artifact, compiler, linker, or system tool is build-host behavior. It is not a source-visible hosted capability and does not promote an artifact to the bundled-std tier.
Current implementation note:
corealready means “no heap and no source-level hosted OS/runtime APIs” at the language and runtime-contract levelcoreartifacts are still emitted through the current Rust backend pipelinecoreartifacts can execute through that pipeline without bundledstd- that means
coreis ready for semantic/runtime restriction work now, but it should not yet be read as “finished embedded backend support”
Mixed-model example:
pro[] build(): non = {
var build = .build();
build.meta({ name = "workspace_tools", version = "0.1.0" });
build.add_dep({ alias = "std", source = "internal", target = "standard" });
var graph = build.graph();
var corelib = graph.add_static_lib({
name = "corelib",
root = "core/lib.fol",
fol_model = "core",
});
var memolib = graph.add_static_lib({
name = "memolib",
root = "memo/lib.fol",
fol_model = "memo",
});
var tool = graph.add_exe({
name = "tool",
root = "app/main.fol",
fol_model = "memo",
});
graph.install(tool);
graph.add_run(tool);
};
The separate core/, memo/, and app/ directories above are semantic, not
just organizational: they keep the recursively parsed artifact scopes
disjoint. Moving those roots into the same directory would make the core and
memo contracts overlap and the frontend would reject the targeted build.
Only a memo source scope may import the declared std alias. Its presence in
the package does not make corelib a hosted artifact.
graph.add_static_lib
Adds a static library artifact.
var core = graph.add_static_lib({ name = "core", root = "src/core/lib.fol" });
Returns an Artifact handle.
Library and test artifact config records follow the same optional fields:
fol_modeltargetoptimize
graph.add_shared_lib
Adds a shared (dynamic) library artifact.
var sdk = graph.add_shared_lib({ name = "sdk", root = "src/sdk/lib.fol" });
Returns an Artifact handle.
graph.add_test
Adds a test artifact.
var tests = graph.add_test({
name = "tests",
root = "src/tests.fol",
fol_model = "core",
});
Returns an Artifact handle.
The test artifact follows the same capability rules as every other artifact.
fol code test may build and launch this core test on a compatible host
without bundled std. Tests that call hosted APIs instead select memo and
declare the bundled dependency for those API calls, not for the test harness.
graph.add_module
Adds a standalone module that can be imported by other artifacts.
var utils = graph.add_module({ name = "utils", root = "src/utils.fol" });
Returns a Module handle.
Installation and Runs
graph.install
Marks an artifact for installation.
graph.install(app);
Returns an Install handle.
Install projections use the selected install prefix. Artifact installs resolve under the prefix by kind:
- executables under
bin/ - libraries under
lib/
graph.install_file
Installs either a source-file handle or a generated output handle.
var defaults = graph.file_from_root("config/defaults.toml");
graph.install_file({ name = "defaults", source = defaults });
var cfg = graph.write_file({
name = "cfg",
path = "config/generated.toml",
contents = "ok",
});
graph.install_file({ name = "generated-cfg", source = cfg });
Returns an Install handle.
graph.install_dir
Installs a source directory handle.
var assets = graph.dir_from_root("assets");
graph.install_dir({ name = "assets", source = assets });
Returns an Install handle.
The install prefix is selected by frontend/workspace configuration, not by the
package itself. Changing the prefix should move projected install destinations
without changing build.fol.
graph.add_run
Registers an artifact as a run target. Binds the default run step when only
one executable exists and no explicit run step has been registered.
var run = graph.add_run(app);
Returns a Run handle. See Handle API for Run methods.
Registration does not alter the artifact’s fol_model and does not imply a
bundled std dependency. When the step is selected, the frontend verifies that
the evaluated artifact target is host-compatible and then launches it. A
foreign target is rejected because there is no configured runner, regardless
of whether the artifact uses core, memo, or bundled std APIs.
Steps
graph.step
Creates a named custom step.
var docs = graph.step("docs");
var docs = graph.step("docs", "Generate documentation");
Returns a Step handle. See Handle API for Step methods.
Named steps are selectable on the command line:
fol code build docs
fol code build --step docs
When a description is present, frontend step planning and unknown-step diagnostics surface it as part of the available step catalog.
Native System Libraries
graph.add_system_lib
Declares a typed system-library request that can be linked into an artifact.
var ssl = graph.add_system_lib({
name = "ssl",
mode = "dynamic",
search_path = "/usr/lib",
});
app.link(ssl);
Supported config fields:
namerequired library or framework namemodeoptional, defaults todynamic, also acceptsstaticframeworkoptional bool, defaults tofalsesearch_pathoptional system library search root
Current scope is intentionally narrow:
- library names are typed, not raw linker fragments
- frameworks are expressed through
framework = true - frameworks must use
dynamic - the build graph records native link intent without exposing a linker-script DSL
Dependency Config Notes
Forwarded dependency args are already best suited for stable build config:
targetoptimize- named user options
The current build story intentionally does not treat dependency config as a general environment-selection surface. Keep forwarded dependency args explicit and typed rather than mixing them with ad hoc environment shaping.
Generated Directories
graph.add_system_tool_dir
Declares a typed system-tool step that produces a directory handle.
var assets = graph.add_system_tool_dir({
tool = "assetpack",
output_dir = "gen/assets",
});
graph.install_dir({ name = "assets", source = assets });
graph.add_codegen_dir
Declares a codegen step that produces a directory handle.
var docs = graph.add_codegen_dir({
kind = "asset",
input = "assets/raw",
output_dir = "gen/packed",
});
Generated directory handles are valid in directory-oriented consumers:
graph.install_dirbuild.export_dir
Current Execution Semantics
Step execution is still serial today. The build graph keeps deterministic step ordering and explicit dependency edges, but it does not claim parallel execution yet.
Current reporting distinguishes:
- requested
- executed
- skipped-from-cache
- skipped-by-foreign-run-policy
- produced outputs
That reporting is intended for frontend summaries and tests. Produced outputs now participate in step cache-key semantics, so generated-file changes can invalidate dependent steps predictably.
Options
graph.standard_target
Reads the -Dtarget option. Returns a Target handle.
var target = graph.standard_target();
The value is provided at build time via -Dtarget=x86_64-linux-gnu. If no
value is provided, target resolves to the host target.
An optional config record is accepted:
var target = graph.standard_target({ default = "x86_64-linux-gnu" });
graph.standard_optimize
Reads the -Doptimize option. Returns an Optimize handle.
var optimize = graph.standard_optimize();
The value is provided via -Doptimize=release-fast. Defaults to debug if
not set.
Valid values: debug, release-safe, release-fast, release-small.
graph.option
Declares a named user option readable via -D.
var root_opt = graph.option({
name = "root",
kind = "path",
default = "src/main.fol",
});
Returns a UserOption handle.
Required fields: name, kind.
Optional field: default.
Option kinds:
| Kind | Description | CLI Example |
|---|---|---|
bool | Boolean flag | -Dstrip=true |
int | Integer value | -Djobs=4 |
str | Arbitrary string | -Dprefix=/usr/local |
enum | One of a fixed set of strings | -Dbackend=llvm |
path | File or directory path | -Droot=src/main.fol |
target | Target triple | -Dtarget=x86_64-linux-gnu |
optimize | Optimization mode | -Doptimize=release-fast |
Generated Files
graph.write_file
Declares a file to be written with static contents at build time.
var header = graph.write_file({
name = "version.h",
path = "gen/version.h",
contents = "#define VERSION 1\n",
});
Returns a GeneratedFile handle.
graph.copy_file
Declares a file to be copied from a source-file handle.
var template = graph.file_from_root("config/template.toml");
var cfg = graph.copy_file({
name = "config",
source = template,
dest = "gen/config.toml",
});
Returns a GeneratedFile handle.
graph.add_system_tool
Declares a system tool invocation that produces a file.
var packed = graph.add_system_tool({
tool = "flatc",
args = { "--fol" },
file_args = { schema, defaults },
env = { MODE = "strict" },
output = "gen/schema.fol",
});
Returns a GeneratedFile handle.
The generated file is keyed by the output path. Use this handle with
step.attach(...) or artifact.add_generated(...).
Typed fields:
toolrequired tool nameargsoptional string argumentsfile_argsoptional source-file or generated-output handles passed as file argumentsenvoptional string-to-string environment mapoutputrequired generated output path
graph.add_codegen
Declares a FOL codegen step.
var schema = graph.add_codegen({
kind = "schema",
input = "schema/api.yaml",
output = "gen/api.fol",
});
Returns a GeneratedFile handle.
Codegen kinds: fol-to-fol, schema, asset-preprocess.
Path Utilities
graph.file_from_root
Returns a source-file handle rooted under the package root.
var cfg = graph.file_from_root("config/default.toml");
Useful when passing source files into copy_file, install_file, or
run.add_file_arg.
graph.dir_from_root
Returns a source-dir handle rooted under the package root.
var assets = graph.dir_from_root("assets");
graph.build_root
Returns the package root directory as an absolute path string.
var root = graph.build_root();
graph.install_prefix
Returns the installation prefix. Defaults to the workspace install directory.
var prefix = graph.install_prefix();
Dependencies
Direct dependencies are declared on build.add_dep({...}), not on graph.
See Handle API for querying modules, artifacts, steps,
and generated outputs from a Dependency handle.
Handle API
Graph methods return typed handles. Each handle type exposes its own set of methods for configuring relationships and behavior.
Path Handle Audit
Before the broader path convergence work, the public build surface still splits path-like values across multiple families:
SourceFileSourceDirGeneratedFile- dependency-generated outputs returned by
dep.generated(...)
Those values already compose in several places, but they do not yet read like one obvious path capability. This chapter keeps the current concrete names while the underlying path model converges.
Path Convergence Direction
The intended direction is one broader path capability with:
- a path class:
- file
- dir
- a provenance:
- source-root path
- local generated output
- dependency-exported path/output
The producer names can stay concrete:
graph.file_from_root(...)graph.dir_from_root(...)graph.write_file(...)graph.copy_file(...)dep.file(...)dep.dir(...)dep.path(...)dep.generated(...)
But path consumers should read those values through one common model instead of hand-written special cases for every producer. In practice the public rule is:
- producers stay specific
- consumers validate the incoming path by:
- path class: file or dir
- provenance: source, local generated, or dependency-exported
So install_file, run.add_file_arg, and artifact.add_generated now reject
wrong path provenance with exact diagnostics instead of falling through to
generic handle errors.
Artifact
The Artifact handle is returned by add_exe, add_static_lib,
add_shared_lib, and add_test.
artifact.link
Links another artifact as a dependency. The linker will include it.
var core = graph.add_static_lib({ name = "core", root = "src/core/lib.fol" });
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.link(core);
Equivalent to Zig’s artifact.linkLibrary(dep).
artifact.import
Imports a module into this artifact’s compilation scope.
var utils = graph.add_module({ name = "utils", root = "src/utils.fol" });
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.import(utils);
Equivalent to Zig’s artifact.root_module.addImport(name, module).
artifact.add_generated
Declares that this artifact depends on a generated file being produced before it can compile. The value stays a generated-output handle; it does not need to be converted back into a string path.
var schema = graph.add_codegen({
kind = "schema",
input = "schema/api.yaml",
output = "gen/api.fol",
});
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.add_generated(schema);
Run
The Run handle is returned by graph.add_run. All Run methods are
chainable and return Run.
A Run handle describes a build-host action. Creating it does not expose
hosted source APIs and does not require bundled std; the referenced artifact
keeps its declared core or memo capability contract. Execution still
requires a host-compatible target.
run.add_arg
Appends a literal string argument to the run command.
var run = graph.add_run(app);
run.add_arg("--config").add_arg("config/default.toml");
run.add_file_arg
Appends any file-class path handle that the run surface can consume:
- source-file handles
- local generated outputs
- dependency-exported file/path handles
var defaults = graph.file_from_root("config/defaults.toml");
var cfg = graph.copy_file({
name = "config",
source = defaults,
dest = "gen/config.toml",
});
var run = graph.add_run(app);
run.add_file_arg(cfg);
run.add_file_arg(defaults);
Equivalent to Zig’s run.addFileArg(file).
Generated-output handles compose across the graph surface:
fun[] emit_cfg() = {
return .build().graph().write_file({
name = "cfg",
path = "config/generated.toml",
contents = "ok",
});
}
var cfg = emit_cfg();
app.add_generated(cfg);
run.add_file_arg(cfg);
graph.install_file({ name = "install-cfg", source = cfg });
Dependency-exported file/path handles compose the same way on file consumers:
var dep = build.add_dep({
alias = "shared",
source = "loc",
target = "deps/shared",
});
var shared_schema = dep.path("schema");
run.add_file_arg(shared_schema);
graph.install_file({ name = "schema", source = shared_schema });
run.add_dir_arg
Appends a directory path as an argument.
var run = graph.add_run(app);
run.add_dir_arg("assets/");
run.capture_stdout
Captures the standard output of the run command as a generated file.
var run = graph.add_run(generator_tool);
var output = run.capture_stdout();
app.add_generated(output);
Returns a GeneratedFile handle. Equivalent to Zig’s run.captureStdOut().
run.set_env
Sets an environment variable for the run command.
var run = graph.add_run(app);
run.set_env("LOG_LEVEL", "debug");
run.depend_on
Makes this run step depend on another step completing first.
var codegen = graph.step("codegen");
var run = graph.add_run(app);
run.depend_on(codegen);
Step
The Step handle is returned by graph.step. All Step methods are
chainable and return Step.
step.depend_on
Makes this step depend on another step.
var compile = graph.step("compile");
var bundle = graph.step("bundle");
bundle.depend_on(compile);
step.attach
Attaches a generated file production to this step. When the step runs, the attached generated file is produced first.
var strip_tool = graph.add_system_tool({
tool = "strip",
output = "gen/app.stripped",
});
var strip_step = graph.step("strip");
strip_step.attach(strip_tool);
Install
The Install handle is returned by graph.install, graph.install_file,
and graph.install_dir.
install.depend_on
Makes this install step depend on another step.
var check = graph.step("check");
var install = graph.install(app);
install.depend_on(check);
Dependency
The Dependency handle is returned by build.add_dep({...}). It exposes the
public surface of another package.
var build = .build();
var dep = build.add_dep({
alias = "logtiny",
source = "git",
target = "git+https://github.com/bresilla/logtiny.git",
version = "tag:v0.1.3",
hash = "b242d319644a",
mode = "lazy",
});
Dependency handles query already-declared package dependencies. They do not add new graph mutations themselves.
Declared dependency modes:
eagerlazyon-demand
Current behavior:
eagerprepares package-store dependencies up front during formal package loadinglazykeeps the declaration and defers package-store preparation until a later import/build path needs iton-demandpreserves the strongest deferred intent and stays visible in summaries and fetch results
Dependency-handle queries resolve only names that the dependency explicitly
exports from its own build.fol:
var build = .build();
var graph = build.graph();
var codec = graph.add_module({ name = "codec", root = "src/codec.fol" });
var lib = graph.add_static_lib({ name = "json", root = "src/main.fol" });
build.export_module({ name = "api", module = codec });
build.export_artifact({ name = "runtime", artifact = lib });
If a dependency does not export a build-facing module, artifact, step, or generated output, dependency handles do not see it.
The currently implemented explicit export kinds are:
- module
- artifact
- step
- generated output
The next missing export kinds are:
- source file
- source dir
- broader path
- generated dir
The intended next public shape is:
var cfg = graph.file_from_root("config/default.toml");
var assets = graph.dir_from_root("assets");
var schema = graph.write_file({
name = "schema",
path = "gen/schema.fol",
contents = "ok",
});
build.export_file({ name = "config", file = cfg });
build.export_dir({ name = "assets", dir = assets });
build.export_path({ name = "schema", path = schema });
That direction keeps path exports explicit and typed instead of collapsing back into ad hoc string registries.
Source import roots remain separate. A dependency can still be imported in ordinary package source through its alias even when it exports no build-facing handles at all.
Import resolution still follows the current alias-projection model under
.fol/pkg/<alias>. Dependency handles do not replace ordinary package imports;
they expose the build-facing surface of the already-declared dependency.
dependency.module
Resolves a named module from the dependency.
var module = dep.module("logtiny");
app.import(module);
dependency.artifact
Resolves a named artifact from the dependency.
var lib = dep.artifact("logtiny");
app.link(lib);
dependency.step
Resolves a named step from the dependency.
var step = dep.step("check");
app_step.depend_on(step);
dependency.generated
Resolves a named generated output from the dependency.
var types = dep.generated("bindings");
app.add_generated(types);
Build Options
Build options are named values passed from the command line into build.fol.
They follow Zig’s -D convention.
Syntax
fol code build -Dname=value
Multiple options can be passed in one command:
fol code build -Dtarget=x86_64-linux-gnu -Doptimize=release-fast -Dstrip=true
Standard Options
Two options are pre-defined by the build system: target and optimize.
They are read via dedicated graph methods.
Target
var target = graph.standard_target();
CLI: -Dtarget=arch-os-env
Format: arch-os-env triple. Examples:
| Triple | Meaning |
|---|---|
x86_64-linux-gnu | x86-64 Linux with glibc |
x86_64-linux-musl | x86-64 Linux with musl libc |
aarch64-linux-gnu | ARM64 Linux with glibc |
aarch64-linux-musl | ARM64 Linux with musl libc |
x86_64-macos-gnu | x86-64 macOS |
aarch64-macos-gnu | ARM64 macOS |
x86_64-windows-gnu | x86-64 Windows with MinGW |
x86_64-windows-msvc | x86-64 Windows with MSVC |
aarch64-windows-msvc | ARM64 Windows with MSVC |
Supported architectures: x86_64, aarch64.
Supported operating systems: linux, macos, windows.
Supported environments: gnu, musl, msvc.
If not set, the host target is used.
standard_target() always exposes the compact FOL spelling shown above, so
when and case comparisons are stable even when the CLI accepted a canonical
Rust triple. Artifact identity and the backend separately retain the matching
canonical Rust triple (for example, x86_64-unknown-linux-gnu).
To pass the resolved value to an artifact:
var target = graph.standard_target();
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
target = target,
});
Optimize
var optimize = graph.standard_optimize();
CLI: -Doptimize=mode
Valid modes:
| Mode | Meaning |
|---|---|
debug | No optimization, full debug info |
release-safe | Optimized with safety checks |
release-fast | Maximum speed, no safety checks |
release-small | Minimize binary size |
Default: debug. A fol code ... --release request supplies
release-safe when no explicit optimize value is present. An explicit
-Doptimize=... or --optimize ... value takes precedence and also determines
whether the artifact uses the debug or release output/rustc profile, so the
directory identity cannot disagree with the compiler profile.
To pass the resolved value to an artifact:
var optimize = graph.standard_optimize();
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
optimize = optimize,
});
User Options
graph.option(...) declares a named option specific to the package.
var strip = graph.option({
name = "strip",
kind = "bool",
default = false,
});
CLI: -Dstrip=true
Option Kinds
| Kind | Example CLI | Default type |
|---|---|---|
bool | -Dverbose=true | false |
int | -Djobs=8 | 0 |
str | -Dprefix=/usr | "" |
enum | -Dbackend=llvm | first value |
path | -Droot=src/main.fol | "" |
target | -Dtarget=x86_64-linux-gnu | host target |
optimize | -Doptimize=release-fast | debug |
Using Option Values
Option handle values can be interpolated into strings or compared with ==:
var root_opt = graph.option({ name = "root", kind = "path", default = "src/main.fol" });
var app = graph.add_exe({ name = "app", root = root_opt });
Comparing in a when condition:
var strip = graph.option({ name = "strip", kind = "bool", default = false });
when(strip == true) {
{
var strip_step = graph.step("strip");
}
};
Shorthand vs Long Form
Both of these are equivalent:
fol code build -Dtarget=x86_64-linux-gnu
fol code build --build-option target=x86_64-linux-gnu
-D is the shorthand. -Dtarget= and -Doptimize= route to the dedicated
standard option slots. All other -Dname=value pairs route to user-declared
options.
Control Flow in build.fol
build.fol is a real FOL program. It supports when, loop, and user-defined
helper routines.
when
when conditionally executes build operations based on a boolean expression.
The canonical form for a conditional block with no case arms uses a double-brace default body:
when(optimize == "release-fast") {
{
var strip_step = graph.step("strip");
var packed = graph.add_system_tool({
tool = "strip",
output = "gen/app.stripped",
});
strip_step.attach(packed);
}
};
With explicit case arms:
when(target) {
case("x86_64-linux-gnu") {
graph.step("asan", "Enable address sanitizer");
}
case("aarch64-linux-gnu") {
graph.step("tsan", "Enable thread sanitizer");
}
* {
graph.step("default-check");
}
};
The inner double-brace { { ... } } is required for the default arm when no
case clauses are present. This is how the FOL parser distinguishes the
default body from raw statements.
Conditional Expressions
Any boolean or option value comparison is valid:
when(optimize == "release-fast") { { ... } };
when(strip == true) { { ... } };
when(target == "x86_64-linux-gnu") { { ... } };
The comparison is resolved at build evaluation time using the values passed
via -D flags. If no value was provided, the declared default is used.
loop
loop iterates over a list and runs the body for each element.
loop(name in {"core", "io", "utils"}) {
graph.add_static_lib({ name = name, root = name });
};
The loop variable (name) is bound in scope for each iteration. It can be
used anywhere inside the body as a string value.
Loop over a list with multiple fields:
loop(name in {"core", "io"}) {
var lib = graph.add_static_lib({ name = name, root = name });
graph.install(lib);
};
The iterable is a container literal { elem1, elem2, ... }. Currently only
string lists are supported as loop iterables in build.fol.
Helper Routines
Current boundary:
- defining helper
fun[]/pro[]routines inbuild.foland calling them frombuild()is not part of the current build surface; the evaluator cannot execute them today - this section describes later design work, not current behavior
- the
when,loop, andcasebuild control flow above is the current build surface
build.fol can define helper fun[] and pro[] routines. They are visible
only within the file — they are not exported to the package.
Helper Function
fun[] make_lib(name: str, root: str): Artifact = {
return .build().graph().add_static_lib({ name = name, root = root });
}
pro[] build(): non = {
var build = .build();
build.meta({ name = "app", version = "0.1.0" });
var graph = build.graph();
var core = make_lib("core", "src/core/lib.fol");
var io = make_lib("io", "src/io/lib.fol");
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.link(core);
app.link(io);
graph.install(app);
graph.add_run(app);
}
The helper make_lib accesses the graph through .build().graph(). The graph
handle is not a public type name and is not passed as a user-declared
parameter.
Helpers Calling Helpers
Helpers can call other helpers:
fun[] lib_root(name: str): str = {
return "src/" + name + "/lib.fol";
}
fun[] add_lib(name: str): Artifact = {
return .build().graph().add_static_lib({ name = name, root = lib_root(name) });
}
pro[] build(): non = {
var build = .build();
build.meta({ name = "app", version = "0.1.0" });
var graph = build.graph();
var core = add_lib("core");
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.link(core);
graph.install(app);
}
Combined Example
fun[] make_lib(name: str): Artifact = {
return .build().graph().add_static_lib({ name = name, root = name });
}
pro[] build(): non = {
var build = .build();
build.meta({ name = "app", version = "0.1.0" });
var graph = build.graph();
var target = graph.standard_target();
var optimize = graph.standard_optimize();
var strip = graph.option({ name = "strip", kind = "bool", default = false });
loop(name in {"core", "io", "net"}) {
make_lib(name);
};
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
target = target,
optimize = optimize,
});
graph.install(app);
graph.add_run(app);
when(strip == true) {
{
var strip_step = graph.step("strip");
var packed = graph.add_system_tool({
tool = "strip",
output = "gen/app.stripped",
});
strip_step.attach(packed);
}
};
}
Artifacts, Modules, and Generated Files
The build graph tracks three kinds of compilable or producible outputs: artifacts, modules, and generated files.
Artifacts
Artifacts are the primary compiled outputs of a package.
| Kind | Method | Output |
|---|---|---|
| Executable | graph.add_exe | Binary |
| Static library | graph.add_static_lib | .a / .lib |
| Shared library | graph.add_shared_lib | .so / .dylib |
| Test bundle | graph.add_test | Runnable test binary |
All artifact constructors accept the same base config record:
var app = graph.add_exe({
name = "app", // required: output name
root = "src/main.fol", // required: entry-point source file
fol_model = "core", // optional: core or memo (memo if omitted)
target = target, // optional: Target handle
optimize = optimize, // optional: Optimize handle
});
fol_model governs source-visible capabilities, not whether the artifact is a
binary. Both core and memo can produce runnable executables and test
bundles. A memo artifact declares bundled std separately only when its
source needs hosted APIs.
name must be lowercase. Allowed characters: a-z, 0-9, -, _, ..
Artifact Name Validation
The build system validates artifact names at evaluation time. Invalid names (uppercase, spaces, special characters) produce a build error.
Linking
Static and shared libraries can be linked into executables using
artifact.link(dep):
var core = graph.add_static_lib({ name = "core", root = "src/core/lib.fol" });
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.link(core);
Typed system-library requests can be linked the same way:
var ssl = graph.add_system_lib({ name = "ssl", mode = "dynamic" });
app.link(ssl);
Framework-style requests use the same surface:
var metal = graph.add_system_lib({
name = "Metal",
framework = true,
});
app.link(metal);
Linking is transitive through the graph. If core itself links utils, app
will also see utils.
Installation
Any artifact can be marked for installation:
graph.install(app);
graph.install(core);
Files and directories can also be installed directly:
var defaults = graph.file_from_root("config/defaults.toml");
var assets = graph.dir_from_root("assets");
graph.install_file({ name = "defaults", source = defaults });
graph.install_dir({ name = "assets", source = assets });
Modules
Modules are named source units that can be shared across artifacts without being standalone binaries.
var utils = graph.add_module({ name = "utils", root = "src/utils.fol" });
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.import(utils);
artifact.import(module) makes the module visible in the importing artifact’s
source scope. Equivalent to Zig’s artifact.root_module.addImport(name, dep).
Modules from dependencies are accessed via dep.module(name):
var build = .build();
var dep = build.add_dep({ alias = "mylib", source = "loc", target = "../mylib" });
var logger = dep.module("logger");
app.import(logger);
dep.module(name) resolves only explicitly exported build modules. It does not
change the ordinary package import rules used in source files.
Generated Files
Generated files are outputs produced before compilation. They must be declared in the graph so the build system knows to produce them and what depends on them.
Kinds
| Kind | Method | Description |
|---|---|---|
| Write | graph.write_file | Written with literal string contents |
| Copy | graph.copy_file | Copied from a source path |
| Tool output | graph.add_system_tool | Produced by an external tool |
| Tool dir | graph.add_system_tool_dir | Produced as a generated directory |
| Codegen | graph.add_codegen | Produced by the FOL codegen pipeline |
| Codegen dir | graph.add_codegen_dir | Produced as a generated directory |
| Captured run | run.capture_stdout() | Stdout of a run step |
Connecting Generated Files
A generated file must be connected to the graph entity that depends on it.
Attach to a step (the step triggers its production):
var schema = graph.file_from_root("schema/api.yaml");
var defaults = graph.write_file({
name = "defaults",
path = "gen/defaults.txt",
contents = "strict",
});
var gen = graph.add_system_tool({
tool = "flatc",
args = { "--fol" },
file_args = { schema, defaults },
env = { MODE = "strict" },
output = "gen/types.fol",
});
var step = graph.step("proto");
step.attach(gen);
Add to an artifact (artifact cannot compile without it):
var schema = graph.add_codegen({
kind = "schema",
input = "schema/api.yaml",
output = "gen/api.fol",
});
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.add_generated(schema);
Capture stdout and feed it as an arg to another run:
var gen_tool = graph.add_exe({ name = "gen", root = "tools/gen.fol" });
var gen_run = graph.add_run(gen_tool);
var gen_output = gen_run.capture_stdout();
var app_run = graph.add_run(app);
app_run.add_file_arg(gen_output);
Generated directories can be installed or exported as dirs:
var assets = graph.add_system_tool_dir({
tool = "assetpack",
output_dir = "gen/assets",
});
build.export_dir({ name = "assets", dir = assets });
graph.install_dir({ name = "assets", source = assets });
Steps
Steps are named build phases. The build system has several implicit steps
(build, run, test, install). Custom steps are declared with
graph.step.
var docs = graph.step("docs", "Generate documentation");
Steps are executed in dependency order. A step depends on another step via
step.depend_on:
var compile = graph.step("compile");
var docs = graph.step("docs");
docs.depend_on(compile);
Default Step Bindings
When only one executable exists in a package, the build system automatically
binds it to the default build and run steps. When multiple executables
exist, explicit step bindings are required via graph.add_run(artifact).
These bindings select what the frontend launches; they do not upgrade the
artifact to hosted std. fol code run and fol code test accept std-free
core and memo artifacts when the evaluated target matches the host.
Selecting Steps at the Command Line
fol code build # run the install steps
fol code build docs # run the "docs" step
fol code run # run the default run step
fol code run --step serve # run the "serve" step
fol code test # run test steps
Cross-target artifacts can still be built. The current frontend has no runner
configuration, so run and test reject a foreign selected target before
launch instead of treating bundled std as execution permission.
Graph Validation
After the build program executes, the graph is validated:
- No step dependency cycles
- All artifact inputs (modules, generated files) are resolvable
- Install targets point to declared artifacts
Validation errors are reported as build evaluation errors before any compilation begins.
Cross Compilation
FOL package builds now stay on one backend path:
FOL source
-> lowered FOL IR
-> generated Rust crate
-> rustc
-> native binary
Normal artifact builds do not call Cargo anymore. Cargo is still useful for
fol code emit rust, but fol code build and fol code run compile the
generated crate directly with rustc.
Selecting A Target
Use either:
fol code build --target aarch64-unknown-linux-gnu
fol code build --target x86_64-pc-windows-gnu
or inside build.fol:
pro[] build(): non = {
var build = .build();
build.meta({ name = "app", version = "0.1.0" });
var graph = build.graph();
var target = graph.standard_target();
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
target = target,
});
graph.install(app);
graph.add_run(app);
};
Target precedence is:
--target- artifact target declared in
build.fol - host default
Accepted Target Spellings
The backend accepts both canonical Rust triples and the shorter FOL spellings already used in build code.
Examples:
x86_64-linux-gnu->x86_64-unknown-linux-gnux86_64-linux-musl->x86_64-unknown-linux-muslaarch64-linux-gnu->aarch64-unknown-linux-gnuaarch64-linux-musl->aarch64-unknown-linux-muslx86_64-windows-gnu->x86_64-pc-windows-gnux86_64-windows-msvc->x86_64-pc-windows-msvcaarch64-windows-msvc->aarch64-pc-windows-msvcx86_64-macos-gnu->x86_64-apple-darwinaarch64-macos-gnu->aarch64-apple-darwin
Unknown spellings are rejected before the backend tries to build.
Build vs Run
Cross-building and cross-running are different operations.
fol code buildsupports host and non-host targetsfol code emit ruststays available for source inspectionfol code runis host-onlyfol code testis host-only
If the selected target does not match the current machine, run and test
fail early with a diagnostic instead of trying to execute the foreign binary.
“Host-only” here is a target-compatibility rule. It is independent of the
source-language capability tier: host-compatible core and memo artifacts
can execute without bundled std.
There is no public cross-target runner configuration yet. When such a runner is
added, it will be an execution-tool setting, not a reason to expose bundled
std APIs to the target program.
Output Layout
Compiled binaries are target-scoped so host and cross builds do not overwrite each other.
Typical layout:
.fol/build/<profile>/bin/<target>/<artifact>
.fol/build/<profile>/fol-backend/runtime/<target>/<profile>/...
That means:
- host builds and cross builds can coexist
- runtime artifacts are compiled per target
- the generated entry crate links against the matching target runtime
emit rust
fol code emit rust still writes a Cargo-compatible crate for debugging and
inspection. That command is source emission, not the product binary build path.
The binary build path remains direct rustc.
Build Direction
This note records the near-term direction of the FOL build system.
It is not a promise to copy another build system exactly. It is the current
shape we intend to grow into from the existing build.fol surface.
Current Shape
Today the public layering is:
.build()for the package build contextbuild.meta({...})for package metadatabuild.add_dep({...})for direct dependenciesbuild.graph()for artifact and step graph mutation
This already covers the core FOL build contract:
- one control file
- one canonical
pro[] build(): non - package metadata and dependencies in the build language
- graph-based artifact construction
What Is Still Missing
The next useful pieces are not more random graph methods. They are missing capability classes.
Round 3 Gap Audit
The current public build surface is materially stronger than it was before:
- dependency handles are real values
- explicit build exports exist
- source file and source dir handles exist
- generated outputs use one composable handle family
- dependency modes are public
- install-prefix projection is real
- step descriptions are real
- typed system tools exist
The remaining gaps that still matter are:
- no named dependency exports for source files, source dirs, or broader path values
- no dependency-handle queries for exported files, dirs, or general paths
- path capability is still split across multiple public handle families
- dependency modes are public and preserved end to end, but only package-store eager preloading is fully distinct today; deeper deferred preparation can still become more aggressive later
- CLI/help/reporting still under-exposes step, install, and output information
- there is still no typed system-library surface
- generated-directory workflows are still thin compared to generated-file flows
Path Handle Split Today
The current path-like values are still exposed through separate concrete families:
SourceFileSourceDirGeneratedFile- dependency-generated output handles
That split is honest today, but it is not the final intended feel. The next path round should preserve the strong producer distinctions while making path consumers behave as though they are working against one broader path capability.
The intended public feel is:
- producers remain explicit
- consumers validate against one common path model
- diagnostics name both:
- the required path class
- the actual path provenance when that matters
That means this round does not need to delete SourceFile, SourceDir, or
GeneratedFile immediately. It does need to stop treating every consumer as a
fresh handwritten branch over those concrete producers.
Dependency Handles
Direct dependencies are now real build values instead of only metadata declarations.
The current shape is:
var logtiny = build.add_dep({
alias = "logtiny",
source = "git",
target = "git+https://github.com/bresilla/logtiny.git",
version = "tag:v0.1.3",
hash = "b242d319644a",
});
and then:
var logtiny_mod = logtiny.module("logtiny");
var logtiny_lib = logtiny.artifact("logtiny");
var logtiny_gen = logtiny.generated("bindings");
The current dependency import model stays explicit:
- ordinary source imports still resolve by alias projection under
.fol/pkg/<alias> - dependency handles query the build-facing surface of that package
- dependency handles only see explicit exports from the dependency package
That public contract is:
build.export_module({ name = "api", module = codec });
build.export_artifact({ name = "runtime", artifact = lib });
build.export_step({ name = "check", step = docs });
build.export_output({ name = "schema", output = bindings });
This keeps source imports and build-surface queries separate instead of collapsing them into one implicit registry.
Output Handles
Generated and copied files now use one output-handle family that can represent:
- files written by
graph.write_file(...) - files copied by
graph.copy_file(...) - captured stdout from run/system-tool/codegen steps
- generated files exported by dependency packages
This behaves like one composable build value instead of a pile of unrelated string paths. The same handle family now covers local and dependency-generated outputs alike.
Explicit Dependency Arguments
Dependencies now accept explicit forwarded build arguments:
var dep = build.add_dep({
alias = "json",
source = "pkg",
target = "json",
args = {
target = graph.standard_target(),
optimize = graph.standard_optimize(),
use_fast_parser = true,
},
});
The important rule is explicitness:
- no ambient forwarding
- no hidden inheritance
- the package author chooses what to pass
Install Prefix
The build graph now describes what gets installed, while the output prefix stays a user/tool choice rather than something hardcoded into the package.
That means FOL should continue to separate:
- build/cache internals
- fetched dependency storage
- final installed outputs
Step Execution
Step execution is still serial today. The current work is about making cache boundaries and reporting honest and explicit, not pretending the executor is already parallel.
The current reporting direction is:
- requested
- executed
- skipped-from-cache
- skipped-by-foreign-run-policy
- produced outputs
System Integration
System integration now exists as a narrow typed surface through
graph.add_system_tool({...}).
The current typed inputs are:
toolargsfile_argsenvoutput
That is intentionally smaller than a full native-toolchain DSL. It is enough to model external build tools without collapsing back into vague stringly build helpers.
What is still missing here:
- first-class system-library requests
- provider selection
- richer native linking policy
- parallel command execution
What This Does Not Mean
This direction does not mean:
- reintroducing public
GraphorBuildtypes - collapsing package metadata back into YAML
- making dependency behavior implicit
- turning the graph API into a stringly catch-all
- introducing a separate build manifest file
- pretending step execution is parallel before it is
- adding compatibility string-path fallbacks beside typed path handles
- growing a broad shell-script DSL inside
build.fol - reintroducing public build type names just to expose more features
The current design constraint remains:
- package metadata through
build.meta - direct dependencies through
build.add_dep - graph mutation through
build.graph
The next round should add richer values on top of that layering, not replace it.
Lexical Structure
This section defines the smallest syntactic building blocks of FOL source code.
All FOL source is interpreted as Unicode text encoded in UTF-8. The lexer groups raw characters into tokens such as:
- keywords
- identifiers
- literals
- symbols
- comments
- whitespace/newline boundaries
The lexical chapters answer questions such as:
- which words are reserved
- how identifiers are formed
- which literal forms exist
- how comments and spacing affect parsing
The detailed chapters are:
- keywords
- identifiers
- comments
- whitespace
- strings, characters, and booleans
- numbers
- symbols
Keywords
Fol has a number of restricted groups of keywords:
BK (build-in keywords)
BK_OR `or`
BK_XOR `xor`
BK_AND `and`
BK_IF `if`
BK_FOR `for`
BK_WHEN `when`
BK_EACH `each`
BK_LOOP `loop`
BK_IS `is`
BK_HAS `has`
BK_IN `in`
BK_THIS `this`
BK_SELF `self`
BK_BREAK `break`
BK_RETURN `return`
BK_YEILD `yield`
BK_DFR `dfr`
BK_PANIC `panic`
BK_REPORT `report`
BK_CHECK `check`
BK_ASSERT `assert`
BK_WHERE `where`
BK_TRUE `true`
BK_FALSE `false`
BK_AS `as`
BK_CAST `cast`
BK_DO `do`
BUILD-IN KEYWORDS - BK:
`(BK_AS|BK_IN|...)`
AK (assignment keywords)
AK_USE `use`
AK_DEF `def`
AK_VAR `var`
AK_FUN `fun`
AK_PRO `pro`
AK_LOG `log`
AK_TYP `typ`
AK_STD `std`
ASSIGNMENT KEYWORDS - AK:
`(AK_USE|AK_DEF|...)`
TK (type keywords)
TK_INT `int`
TK_FLT `flt`
TK_CHR `chr`
TK_BOL `bol`
TK_ARR `arr`
TTKVEC `vec`
TK_SEQ `seq`
TK_MAT `mat`
TK_SET `set`
TK_MAP `map`
TK_STR `str`
TK_NUM `num`
TK_OPT `opt`
TK_MUL `mul`
TK_ANY `any`
TK_PTR `ptr`
TK_ERR `err`
TK_NON `non`
TK_REC `rec`
TK_LST `lst`
TK_ENM `enm`
TK_UNI `uni`
TK_CLS `cls`
TK_STD `std`
TK_MOD `mod`
TK_BLK `blk`
TYPE KEYWORDS - TK:
`(TK_INT|TK_FLT|...)`
Note that all of the type keywords are of three characters long. It is recomanded that new identifiers not to be of the same number of characters, as one day in the future that same identifier can be used s a keyword in FOL compiler.
OK (option keywords)
OK_EXP `exp`
OK_HID `hid`
Older draft material sometimes used pub. The current compiler/book contract
uses exp for exported visibility and hid for file-local visibility.
OPTION KEYWORDS - OK:
`((OK_EXP|OK_HID|...),?)*`
Assigning
`(`*WS*`)*(\W)?(`*AK*`)(\[(`*OK*`)?\])?`
`(`*WS*`)*(`*AK*`)`
| `(`*WS*`)*\W(`*AK*`)`
| `(`*WS*`)*(`*AK*`)(\[\])`
| `(`*WS*`)*\W(`*AK*`)(\[\])`
| `(`*WS*`)*(`*AK*`)(\[(`*OK*`)\])`
| `(`*WS*`)*\W(`*AK*`)(\[(`*OK*`)\])`
Identifiers
Identifiers in the current front-end are ASCII names built from letters, digits, and
underscores, but they may not start with a digit. Repeated underscore runs __ are
not allowed.
IDENTIFIER:
[a-z A-Z _] [a-z A-Z 0-9 _]*
The hardened front-end currently accepts:
- leading underscores
- internal underscores
- non-leading digits
The hardened front-end currently rejects:
- leading digits
- repeated underscore runs
- non-ASCII identifier spellings
_ by itself is still accepted by the current lexer/parser boundary as a dedicated
placeholder or binder surface. It should not be treated as an ordinary named identifier
for later-phase semantic work.
Identifier equality
Parser-owned duplicate checks currently treat two identifiers as equal if the following algorithm returns true:
pro sameIdentifier(a, b: string): bol = {
result = a.replace("_", "").toLowerAscii == b.replace("_", "").toLowerAscii
}
That means ASCII letters are compared case-insensitively and underscores are ignored for those parser-owned duplicate checks. The lexer and stream still preserve original identifier spelling; they do not canonicalize token or namespace text up front.
Comments
Backtick-delimited comments are the authoritative comment syntax in FOL.
Normal comments
Single-line and multiline comments use the same backtick-delimited form.
SINGLE_LINE_COMMENT:
`this is a single line comment`
MULTI_LINE_COMMENT:
`this is a
multi
line
comment`
Doc comments
Documentation comments use the [doc] prefix inside the same backtick-delimited
comment family.
DOC_COMMENT:
`[doc] this is a documentation comment`
Current front-end compatibility
The hardened front-end still accepts // and /* ... */ comments as frozen
compatibility syntax. They are not the authoritative book spelling, but they remain
intentionally supported by the current lexer and parser.
The current front-end also preserves comment kind and raw spelling past lexing. In the
parser today, standalone root comments and standalone routine-body comments lower to
explicit AST comment nodes, and many inline expression-owned comments now survive
through AstNode::Commented wrappers around the parsed node they belong to. That
gives later doc-comment tooling retained comment content to build on instead of
re-scanning raw source text.
Whitespaces
Whitespace is any non-empty string containing only characters that have the below Unicode properties:
U+0009(horizontal tab, ‘\t’)U+000B(vertical tab)U+000C(form feed)U+0020(space, ’ ’)U+0085(next line)U+200E(left-to-right mark)U+200F(right-to-left mark)U+2028(line separator)U+2029(paragraph separator)
New lines
New line are used as end-of-line separators:
U+000A(line feed, ‘\n’)U+000D(carriage return, ‘\r’)
Strings
Characters
A character is a single Unicode element enclosed within quotes U+0022 (") with the exception of U+0022 itself, which must be escaped by a preceding U+005C character (\).
var aCharacter: chr = "\n"
var anotherOne: str = "語\n"
Raw characters
Raw character literals do not process any escapes. They are enclosed within single-quotes U+0027 (') with the exception of U+0027 itself:
var aCharacter: chr = 'z'
Strings
A string is a single or a sequence of Unicode elements enclosed within quotes U+0022 (") with the exception of U+0022 itself, which must be escaped by a preceding U+005C character (\).
var hiInEnglish: str = "Hello, world!\n"
var hInCantonese: str = "日本語"
Line-breaks are allowed in strings. A line-break is either a newline (U+000A) or a pair of carriage return and newline (U+000D, U+000A). Both byte sequences are normally translated to U+000A, but as a special exception, when an unescaped U+005C character (\ occurs immediately before the line-break, the U+005C character, the line-break, and all whitespace at the beginning of the next line are ignored. Thus a and b are equal:
var a: str = "foobar";
var b: str = "foo\
bar";
assert(a,b);
Escape sequences
Some additional escapes are available in either character or non-raw string literals.
| code | description |
|---|---|
| \p | platform specific newline: CRLF on Windows, LF on Unix |
| \r, \c | carriage return |
| \n, \l | line feed (often called newline) |
| \f | form feed |
| \t | tabulator |
| \v | vertical tabulator |
| \\ | backslash |
| \“ | quotation mark |
| \’ | apostrophe |
| \ ‘0’..‘9’+ | character with decimal value d; all decimal digits directly following are used for the character |
| \a | alert |
| \b | backspace |
| \e | escape [ESC] |
| \x HH | character with hex value HH; exactly two hex digits are allowed |
| \u HHHH | unicode codepoint with hex value HHHH; exactly four hex digits are allowed |
| \u {H+} | unicode codepoint; all hex digits enclosed in {} are used for the codepoint |
Raw strings
Just like raw characters, raw string literals do not process any escapes either. They are enclosed within single-quotes U+0027 (') with the exception of U+0027 itself:
var hiInEnglish: str = 'Hello, world!'
Booleans
The two values of the boolean type are written true and false:
var isPresent: bol = false;
Numbers
A number in the current front-end is either an integer or a floating-point literal. Imaginary suffix forms are intentionally outside the hardened lexer/parser contract for this phase.
Intigers
An integer has one of four forms:
- A decimal literal starts with a decimal digit and continues with decimal digits and optional separating underscores.
- A hex literal starts with
0xor0Xand then uses hex digits with optional separating underscores. - An octal literal starts with
0oor0Oand then uses octal digits with optional separating underscores. - A binary literal starts with
0bor0Band then uses binary digits with optional separating underscores.
var decimal: int = 45;
var hexadec: int = 0x6AF53BD5;
var octal: int = 0o722371;
var binary: int = 0b010010010;
Underscore
Underscore character U+005F (_) is a special character, that does not represent anything withing the number laterals. An integer lateral containing this character is the same as the one without. It is used only as a syntastc sugar:
var aNumber: int = 540_467;
var bNumber: int = 540467;
assert(aNumber, bNumber)
Floating points
A floating-point has one of two forms:
- A decimal literal followed by a period character
U+002E(.). This is optionally followed by another decimal literal. - A decimal literal that follows a period character
U+002E(.). - A decimal literal followed by a period with no fractional digits is also accepted by the current front-end.
var aFloat: flt = 3.4;
var bFloat: flt = .4;
var cFloat: flt = 1.;
Current front-end note
Imaginary literals such as 5i remain a language-design topic in the book, but they
are not tokenized or lowered by the current hardened stream/lexer/parser pipeline.
Symbols
Operators
Fol allows user defined operators. An operator is any combination of the following characters:
= + - * / > .
@ $ ~ & % < :
! ? ^ # ` \ _
The grammar uses the terminal OP to refer to operator symbols as defined here.
Brackets
Bracket punctuation is used in various parts of the grammar. An open bracket must always be paired with a close bracket. Here are type of brackets used in FOL:
| bracket | type | purpose |
|---|---|---|
{ } | Curly brackets | Code blocks, Namespaces, Containers |
[ ] | Square brackets | Type options, Container acces, Multithreading |
( ) | Round brackets | Calculations, Comparisons, Argument passing |
< > | Angle brackets |
The grammar uses the terminal BR to refer to operator symbols as defined here.
Statements And Expressions
This section covers executable syntax.
FOL separates executable forms into two broad groups:
- statements: forms executed for control flow, side effects, or declaration within a block
- expressions: forms that compute a value
Statements
Statements include:
- local declarations
- assignments
- routine calls used for side effects
- branching
- looping
- nested blocks
Examples:
var x: int = 0;
x = 5;
if (x > 0) { .echo(x) }
for (item in items) { .echo(item) }
Expressions
Expressions include:
- operator expressions
- literal expressions
- range expressions
- access expressions
- call expressions
Expressions can be nested freely and may appear in declarations, assignments, return statements, control-flow headers, and other expressions.
Statements
Statements are executable forms that primarily control behavior, perform side effects, or introduce local declarations inside a block.
This chapter family focuses on:
- declaration statements
- assignment-like statements
- control flow
- block structure
Examples:
var x: int = 0;
x = 1;
if (x > 0) { .echo(x) }
for (item in items) { .echo(item) }
Control
At least two linguistic mechanisms are necessary to make the computations in programs flexible and powerful: some means of selecting among alternative control flow paths (of statement execution) and some means of causing the repeated execution of statements or sequences of statements. Statements that provide these kinds of capabilities are called control statements. A control structure is a control statement and the collection of statements whose execution it controls. This set of statements is in turn generally structured as a block, which in addition to grouping, also defines a lexical scope.
There are two types of control flow mechanisms:
- choice -
when - loop -
loop(withwhile,for, andeachas loop-header spellings of the same mechanism)
Choice type
when(condition){ case(condition){} case(condition){} * {} };
when(variable){ is (value){}; is (value){}; * {}; };
when(variable){ in (iterator){}; in (iterator){}; * {}; };
when(iterable){ has (member){}; has (member){}; * {}; };
when(generic){ of (type){}; of (type){}; * {}; };
Current boundary:
caseandisarms (plus the required*default) are the currentV1surface, in both statement bodies and the arrow expression form (is 3 -> 7;)- a
whenwith nocase/isarms is a statement-only boolean gate: its default body runs only when the selector istrue; arbitrary values are not coerced to truth values in(range/set matching),has(membership),of(type matching), andonare declared matching syntax whose semantics are later-milestone work; the compiler rejects them with explicit boundary diagnostics- channel multiplexing is not an
on-arm variant of ordinarywhen; the shipped V3 processor form is the separateselect { when channel as value { ... } }statement shown below
Condition
when(true) {
case (x == 6){ // implementation }
case (y.set()){ // implementation }
* { // default implementation }
}
Valueation
when(x) {
is (6){ // implementation }
is (>7){ // implementation }
* { // default implementation }
}
Iteration
when(2*x) {
in ({0..4}){ // implementation }
in ({ 5, 6, 7, 8, }){ // implementation }
* { // default implementation }
}
Contains
when({4,5,6,7,8,9,0,2,3,1}) {
has (5){ // implementation }
has (10){ // implementation }
* { // default implementation }
}
Generics
when(T) {
of (int){ // implementation }
of (str){ // implementation }
* { // default implementation }
}
Channel multiplexing (V3)
select waits on direct channel bindings. Each when arm names one channel
and binds the payload received from it:
select {
when first as value {
consume(value);
}
when second as value {
consume(value);
}
* {
handle_not_ready();
}
};
The optional * arm runs immediately when no receiver is ready. Without it,
the statement polls until one source-order arm receives a value or every arm
has closed. The processor surface is hosted std-only. See
Tasks, Channels, and Mutexes for the
endpoint lifecycle and fairness contract.
Loop type
loop(condition){};
loop(iterable){};
Condition
loop( x == 5 ){
// implementation
};
Enumeration
loop( x in {..100}){
// implementation
}
loop( x in {..100}) if ( x % 2 == 0 )){
// implementation
}
loop( x in {..100} if ( x in somearra ) and ( x in anotherarray )){
// implementation
}
Iteration
loop( x in array ){
// implementation
}
Expressions
Expressions are value-producing forms.
An expression may be:
- a literal
- a reference
- an operator application
- a call
- an access form
- a range or container form
The detailed chapters in this section focus on:
- calculations and operators
- literals
- ranges
- access expressions
Calculations
Current boundary:
- the two-argument
assert(a, b)form used in this chapter’s examples is illustrative;assertis registry-owned but deferred, and the intrinsic is not yet dispatchable in the current compiler (see the intrinsics chapter)
In fol, every calcultaion, needs to be enclosed in rounded brackets ( //to evaluate ) - except in one line evaluating, the curly brackets are allowed too { // to evaluate }:
fun adder(a, b: int): int = {
retun a + b // this will throw an error
}
fun adder(a, b: int): int = {
retun (a + b) // this is the right way to enclose
}
Order of evaluation is strictly left-to-right, inside-out as it is typical for most others imperative programming languages:
.echo((12 / 4 / 8)) // 0.375 (12 / 4 = 3.0, then 3 / 8 = 0.375)
.echo((12 / (4 / 8))) // 24 (4 / 8 = 0.5, then 12 / 0.5 = 24)
Calculation expressions include:
- arithmetics
- comparison
- logical
- compounds
Arithmetics
The behavior of arithmetic operators is only on intiger and floating point primitive types. For other types, there need to be operator overloading implemented.
| symbol | description |
|---|---|
| - | substraction |
| * | multiplication |
| + | addition |
| / | division |
| % | reminder |
| ^ | exponent |
assert((3 + 6), 9);
assert((5.5 - 1.25), 4.25);
assert((-5 * 14), -70);
assert((14 / 3), 4);
assert((100 % 7), 2);
Current integer semantics:
- division truncates toward zero (
-7 / 2is-3) - remainder takes the sign of the dividend (
-7 % 2is-1) - integers are 64-bit two’s-complement; overflow panics at runtime (debug) rather than wrapping, and division/modulo by zero panics
- declared widths such as
int[8]currently share the 64-bit runtime representation and are not range-enforced yet - floats follow IEEE-754:
1.0 / 0.0is infinity,0.0 / 0.0is NaN (no panic)
Comparisons
Comparison operators are also defined both for primitive types and many type in the standard library. Parentheses are required when chaining comparison operators. For example, the expression a == b == c is invalid and may be written as ((a == b) == c).
| Symbol | Meaning |
|---|---|
| == | equal |
| != | not equal |
| > | greater than |
| < | Less than |
| >= | greater than or equal to |
| <= | Less than or equal to |
assert((123 == 123));
assert((23 != -12));
assert((12.5 > 12.2));
assert((1 < 3));
assert(('A' <= 'B'));
assert(("World" >= "Hello"));
Logical
A branch of algebra in which all operations are either true or false, thus operates only on booleans, and all relationships between the operations can be expressed with logical operators such as:
and(conjunction), denoted(x and y), satisfies(x and y) = 1ifx = y = 1, and(x and y) = 0otherwise.or(disjunction), denoted(x or y), satisfies(x or y) = 0ifx = y = 0, and(x or) = 1otherwise.not(negation), denoted(not x), satisfies(not x) = 0ifx = 1and (not x) = 1ifx = 0`.
assert((true and false), (false and true));
assert((true or false), true)
assert((not true), false)
Current evaluation note:
and/orevaluate both operands eagerly; they do NOT short-circuit, so the right operand runs even when the left already decides the result ((false) and (1/0 > 0)still evaluates1/0). Guard side-effecting or fallible right operands explicitly.
Compounds
There are further assignment operators that can be used to modify the value of an existing variable. These are the compounds or aka compound assignments. A compound assignment operator is used to simplify the coding of some expressions. For example, using the operators described earlier we can increase a variable’s value by ten using the following code:
value = value + 10;
This statement has an equivalent using the compound assignment operator for addition (+=).
value += 10;
There are compound assignment operators for each of the six binary arithmetic operators: +, -, *, /, % and ^. Each is constructed using the arithmetic operator followed by the assignment operator. The following code gives examples for addition +=, subtraction -=, multiplication *=, division /= and modulus %=:
var value: int = 10;
(value += 10); // value = 20
(value -= 5); // value = 15
(value *= 10); // value = 150
(value /= 3); // value = 50
(value %= 8); // value = 2
Compound assignment operators provide two benefits. Firstly, they produce more compact code; they are often called shorthand operators for this reason. Secondly, the variable being operated upon, or operand, will only be evaluated once in the compiled application. This can make the code more efficient.
Literals
A literal expression consists of one or more of the numerical/letter forms described earlier. It directly describes a numbers, characters, booleans, containers and constructs.
There are two type of literals:
- values
- calls
Value literals
Value literals are the simpliest expressions. They are direct values assigned to variables and are divided into two types:
- singletons
- clusters
Singelton literals
Singleton literals represent one sigle values:
4 // intiger literal
0xA8 // hex-intiger literal
4.6 // floating-point literal
"c" // character literal
"one" // string literal
true // boolean literal
The current hardened front-end does not yet implement imaginary literal lowering, so imaginary examples are intentionally omitted from the active literal surface here.
Cluster literals
Cluster literals represent both container types and construct types. Cluster literals are always enclosed within curly brackets { }. The difference between scopes and cluster literals is that cluster literals shoud always have comma , within the initializaion and assignment brackets, e.g { 5, }.
Containers
Some simple container expressions
{ 5, 6, 7, 8, } // array, vector, sequences
{ "one":1, "two":2, } // maps
{ 6, } // single element container
A 3x3x3 matrix
{{{1,2,3},{4,5,6},{7,8,9}},{{1,2,3},{4,5,6},{7,8,9}},{{1,2,3},{4,5,6},{7,8,9}}}
Constructs
// constructs
{ email = "someone@example.com", username = "someusername123", active = true, sign_in_count = 1 }
// nested constructs
{
FirstName = "Mark",
LastName = "Jones",
Email = "mark@gmail.com",
Age = 25,
MonthlySalary = {
Basic = 15000.00,
Bonus = {
HTA = 2100.00,
RA = 5000.00,
},
},
}
Call literals
Call literals are function calls that resolve to values:
var seven: int = add(2, 5); // assigning variables "seven" to function call "add"
`typ Vector: rec = { var x: flt var y: flt }
typ Rect: rec = { var pos: Vector var size: Vecotr }
fun make_rect(min, max: Vector): Rect { return [Rect]{{min.x, min.y}, {max.x - max.y, max.y - max.y}} return [Rect]{pos = {min.x, min.y}, size = {max.x - max.y, max.y - max.y}} }
`
Ranges
Current boundary:
- range expressions are not part of the current compiler surface; the compiler rejects them with a “not yet supported” diagnostic (T1002)
- range-based loop headers and range matching described in other chapters inherit this boundary and are not executable today
- everything below describes intended design, not current behavior
- this is later design work, not part of the current V1 compiler surface
There are two range expressions:
- Defined ranges
- Undefined ranges
Defined ranges
Defined ranges represent a group of values that are generated as a sequence based on some predefined rules. Ranges are represented with two dots .. operator.
{ 1..8 } // a range from 1 to 8
{ 1,2,3,4,5,6,7,8 }
{ 8..1 } // a range from 8 to 1
{ 8,7,6,5,4,3,2,1 }
{ 1..8..2 } // a range from 1 to 8 jumping by 2
{ 1,3,5,7 }
{ 3..-3 } // a range from 4 to -4
{ 3,2,1,0,-1,-2,-3 }
{ -3..3 } // a range from -3 to 3
{ -3,-2,-1,0,1,2,3 }
{ ..5 } // a range form 0 to 5
{ 0,1,2,3,4,5 }
{ ..-5 } // a range from 0 to -5
{ 0,-1,-2,-3,-4,-5 }
{ 5.. } // a range from 5 to 0
{ 5,4,3,2,1,0 }
{ -5.. } // a range from -5 to 0
{ -5,-4,-3,-2,-1,0 }
| syntax | meaning |
|---|---|
start..end | from start to end |
..end | from zero to end |
start.. | from start to zero |
Undefined ranges
Undefined ranges represent values that have only one side defined at the definition time, and the compiler defines the other side at compile time. They are represented with three dots ...
{ 2... } // from 2 to infinity
| syntax | meaning |
|---|---|
start... | from start to infinite |
In most of the cases, they are used for variadic parameters passing:
fun calc(number: ...int): int = { return number[0] + number[1] + number[2] * number[3]}
Access
Current boundary:
- namespace access, receiver-qualified routine access, single-element container
indexing (
c[i]), map key access, and record field access are the current compiler surface - bounded forward slicing (
c[1:3],c[:3], andc[1:]) is implemented forvec[...]andseq[...]; fixed-size array slices still need an explicit runtime-sized result type - reverse slicing (
c[::]), availability checks (v:[1]), in-place assignment (c[x => Y]), and the whole axiom (axi) access family are later design work, not part of the current compiler surface - the sections below are marked where they cross that line
There are four access expressions:
- namespace member access
- receiver-qualified routine access
- container member access
- field member access
Receiver-Qualified Routine Access
One access form is the receiver-style routine call. In FOL this remains procedural syntax: the receiver value is the first routine input, and the dot form is only call-site sugar.
A receiver-qualified call consists of an expression (the receiver), followed by
a single dot ., a routine name, and a parenthesized expression list:
"3.14".cast(float).pow(2);
Read:
value.method(arg1, arg2)
as:
method(value, arg1, arg2)
This spelling does not create classes, objects, inheritance, or runtime method ownership. It is just a shorter way to call a receiver-qualified routine.
Namespace Access
Accessing namespaces is done through the double-colon operator :::
use std: pkg = {"std"};
var shown: int = std::fmt::math::answer();
This particular namespace is part of bundled std, so the package must use a
memo artifact and declare the internal standard dependency in build.fol.
The pkg target is the declared alias ("std"); nested namespaces are reached
with ::, not by embedding std/... in the import target.
Container Access
Array, Vectors, Sequences, Sets
Containers can be indexed by writing a square-bracket-enclosed expression of
type int[arch] after them.
var collection: int = { 5, 4, 8, 3, 9, 0, 1, 2, 7, 6 }
collection[5]
collection[-2]
Containers can be accessed with a specified range too, by using colon within a square-bracket-enclosed:
The current executable subset applies these forward forms to vec[...] and
seq[...]. It creates a new container, so V3 rejects slices whose elements are
move-only. Fixed-size arr[...] slicing and the reverse forms below remain
design material.
| syntax | meaning |
|---|---|
: | the whole container |
elA:elB | from element elA to element elB |
:elA | from beginning to element elA |
elA: | from element elA to end |
collection[-0] // last item in the array
{ 6 }
collection[-1:] // last two items in the array
{ 7, 6 }
collection[:-2] // everything except the last two items
{ 5, 4, 8, 3, 9, 0, 1, 2 }
If we use double colon within a square-bracket-enclosed then the collection is inversed:
| syntax | meaning |
|---|---|
:: | the whole container in reverse |
elA::elB | from element elA to element elB in reverse |
::elA | from beginning to element elA in reverse |
elA:: | from element elA to end in reverse |
collection[::] // all items in the array, reversed
{ 6, 7, 2, 1, 0, 9, 3, 8, 4, 5 }
collection[2::] // the first two items, reversed
{ 4, 5 }
collection[-2::] // the last two items, reversed
{ 6, 7 }
collection[::-3] // everything except the last three items, reversed
{ 2, 1, 0, 9, 3, 8, 4, 5 }
Matrices
Matrices are 2D+ arrays, so they use nested index access:
var aMat = mat[int, int] = { {1,2,3}, {4,5,6}, {7,8,9} };
nMat[[1][0]]
All other access forms behave like arrays.
Maps
Accessing maps is done by using the key inside square brackets:
var someMap: map[str, int] = { {"prolog", 1}, {"lisp", 2}, {"c", 3} }
someMap["lisp"]
Axioms
Accessing axioms is similar to accessing maps, but matching is broader and the result is always a vector of matches:
var parent: axi[str, str] = { {"albert","bob"}, {"alice","bob"}, {"bob","carl"}, {"bob","tom"} };
parent["albert",*]
parent["bob",*]
parent[*,_]
Matching can be with a vector too:
var parent: axi[str, str] = { {"albert","bob"}, {"alice","bob"}, {"bob","carl"}, {"bob","tom"}, {"maggie","bill"} };
var aVec: vec[str] = { "tom", "bob" };
parent[*,aVec]
A more complex matching example:
var class: axi;
class.add({"cs340","spring",{"tue","thur"},{12,13},"john","coor_5"})
class.add({"cs340","winter",{"tue","fri"},{12,13},"mike","coor_5"})
class.add({"cs340",winter,{"wed","fri"},{15,16},"bruce","coor_3"})
class.add({"cs101",winter,{"mon","wed"},{10,12},"james","coor_1"})
class.add({"cs101",spring,{"tue","tue"},{16,18},"tom","coor_1"})
var aClass = "cs340"
class[aClass,_,[_,"fri"],_,*,_]
Availability
To check whether an element exists, add : before []. The result is true
when the element exists.
var val: vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
val:[5]
val:[15]
var likes: axi[str, str] = { {"bob","alice"} , {"alice","bob"}, {"dan","sally"} };
likes["bob","alice"]:
likes["sally","dan"]:
In-Place Assignment
Some access forms can bind a value while matching:
var val: vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
var even: vec = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20}
val[even => Y]
.echo(Y)
This is more useful with axioms:
var parent: axi[str, str] = { {"albert","bob"}, {"alice","bob"}, {"bob","carl"}, {"bob","tom"}, {"maggie","bill"} };
parent:[* => Y,"bob"]
Field Access
Field access expressions access stored data inside record-like values. Here is
an example record user:
var user1: user = {
email = "someone@example.com",
username = "someusername123",
active = true,
sign_in_count = 1
};
fun (user)getName(): str = { result = self.username; };
There are two things you may access on such a value:
- receiver-qualified routines
- data fields
Receiver-Qualified Routines
The same dot spelling is used for receiver-qualified routines, but they remain ordinary routines rather than object-owned behavior:
user1.getName()
Data
There are multiple ways to access stored data. The simplest is the dot
operator .:
user1.email
Another way is by using square bracket enclosed by name:
user1[email]
And lastly, by square bracket enclosed by index:
user1[0]
Metaprogramming
This section describes the compile-time extension surface of FOL.
Metaprogramming facilities allow source to be generated, transformed, or specialized before normal execution semantics apply.
The chapter family currently covers:
- built-ins
- macros
- alternatives
- defaults
- templates
These features are powerful but high-risk for readability. They should be used to remove repetitive structure, not to obscure ordinary program logic.
Intrinsics
Intrinsics are compiler-owned language operations.
They are not ordinary library functions, and they are not imported through
use.
FOL currently keeps compiler intrinsics and three API tiers separate:
- intrinsics:
compiler-owned operations such as
.eq(...),.len(...),check(...), andpanic(...) core: the minimal runtime model with no heap and no source-level hosted OS/runtime APIsmemo: alloc-like heap-backed library/runtime support without source-level hosted OS/runtime APIsstd: the hosted API tier layered onmemoby an explicit bundled internal dependency, with shipped services such as console I/O
This split is not a source-level import trick and not an object-system feature.
core and memo are artifact capability models selected through fol_model
in build.fol. Bundled std is not a third model; it is declared separately
with build.add_dep({ alias = "std", source = "internal", target = "standard" })
for a memo artifact that needs hosted source APIs.
Whether an artifact can be launched is orthogonal. Host-compatible core and
memo programs can use fol code run or fol code test without bundled
std; the frontend launching them does not expose extra intrinsics.
If an operation can live as an ordinary library API, that is usually the better home for it. Intrinsics are reserved for surfaces the compiler must understand directly.
Surfaces
The current compiler recognizes three intrinsic surfaces.
Dot-root calls
These are written with a leading dot:
.eq(a, b)
.not(flag)
.len(items)
.echo(value)
Dot-root intrinsics are the main current intrinsic family.
Keyword calls
These look like language keywords rather than dot calls:
check(read_code(path))
panic("unreachable state")
The current V1 compiler treats check and panic as intrinsics too, even
though they are not written with ..
Operator aliases
Some future intrinsic surfaces are written like operators:
value as target_type
value cast target_type
These are registry-owned now, but they are not implemented in the current V1
compiler.
Current V1 implemented intrinsics
The current compiler implements this subset end to end through type checking and lowering.
For current V1, backend execution of the implemented intrinsic set still goes
through the current runtime layer where policy matters. The runtime contract is
split by the artifact’s fol_model and active bundled dependency, so the rule
is:
coreartifacts must not rely on heap-backed or source-level hosted APIsmemoartifacts may use heap-backed facilities but not source-level hosted APIs- bundled
stdwrappers require amemoartifact plus explicit internalstandarddependency
All three API tiers may back executable artifacts. Process entry and recoverable outcome adaptation are backend-only support, not bundled-std intrinsics.
In the current implementation that means:
.len(...)uses the runtime length helper.echo(...)uses the runtime echo hook and formatting contractcheck(...)uses the runtime recoverable-result inspection contract- scalar comparisons and
.not(...)may lower to native target operations
Comparison
.eq(left, right)
.nq(left, right)
.lt(left, right)
.gt(left, right)
.ge(left, right)
.le(left, right)
Current V1 rule:
.eq(...)and.nq(...)work on comparable scalar pairs.lt(...),.gt(...),.ge(...), and.le(...)work on ordered scalar pairs
If you call them with the wrong number of arguments or with unsupported type families, the compiler reports an intrinsic-specific type error.
Boolean
.not(flag)
Current V1 rule:
.not(...)accepts exactly onebol
Query
.len(items)
Current V1 rule:
.len(...)accepts exactly one operand- the operand must currently be one of:
strarr[...]vec[...]seq[...]set[...]map[...]
In the current compiler, .len(...) is the only implemented query intrinsic.
Under the runtime model split, array .len(...) belongs to core, while
string and dynamic-container .len(...) belongs to memo. It remains
available when a memo artifact also declares bundled std because the hosted
tier layers on top of memo; .len(...) does not itself require std.
Diagnostic
.echo(value)
Current V1 rule:
.echo(...)accepts exactly one argument- it requires a
memoartifact with the explicit bundledstddependency - it emits the value through the hosted runtime hook
- it then forwards the same value unchanged
.echo(...) belongs to std, not core or memo.
Terminal and OS hooks
The primitive layer for interactive terminal programs shares .echo(...)’s
build contract (a memo artifact with the explicit bundled std
dependency):
.write(text)— write a string to stdout without a trailing newline and flush it; forwards the string unchanged.read_key()— block for one byte of standard input; yields-1at end of input.raw_mode(enable)— enable or disable terminal raw mode; forwards the requested state.sleep_ms(ms)— sleep the current thread; forwards the duration.now_ms()— milliseconds since the unix epoch.term_cols()/.term_rows()— terminal size (80×24 when it cannot be determined).int_to_str(value)— render an integer as its decimal string
The bundled std package wraps them as std::io::write, std::io::read_key,
std::term::raw_mode, std::term::cols, std::term::rows,
std::time::sleep_ms, std::time::now_ms, and std::fmt::int_to_str.
With that build contract, this is valid:
fun[] main(flag: bol): bol = {
return .echo(flag)
}
Recoverable and control intrinsics
check(read_code(path))
panic("fatal")
Current V1 rule:
check(expr)asks whether a recoverable/ ErrorTypeexpression failed and returnsbol; that expression may be a direct routine call or, in V3, an awaited recoverable eventualpanic(...)aborts control flow immediately
These are described in more detail in the recoverable-error chapter.
Current V1 deferred intrinsics
The registry already reserves more names than the compiler implements.
That does not mean they work today.
Reserved but deferred for likely V1.x
ascastassert.cap(...).is_empty(...).low(...).high(...)
These are recognized as registry-owned language surfaces, but the current compiler rejects them with explicit milestone-boundary diagnostics.
Reserved for later V2
- bitwise helpers such as
.bit_and(...),.bit_or(...),.shl(...),.shr(...),.rotl(...),.rotr(...),.pop_count(...),.clz(...),.ctz(...),.byte_swap(...),.bit_reverse(...) - overflow-mode helpers such as
.checked_add(...),.wrapping_add(...),.saturating_add(...),.overflowing_add(...), and their subtraction forms
These are intentionally reserved now so the language can grow without
accidental user-space name collisions, but they are not part of the current
V1 compiler.
Reserved for V4 interop
.de_alloc(...)
Explicit deallocation is not part of the V3 memory model. Unique heap values
drop implicitly, borrowing uses [bor]owner and [end]borrow, and typed pointers use
[ref]value and [drf]pointer. The old dot-root memory spellings are not reserved or
supported aliases.
Library-preferred surfaces
Some names are kept in the registry roadmap only as placeholders while the language decides whether they should really stay compiler-owned.
Current examples:
.add(...).sub(...).mul(...).div(...).abs(...).min(...).max(...).clamp(...).floor(...).ceil(...).round(...).trunc(...).pow(...).sqrt(...)
The current direction is that many of these may fit better in core or std
instead of becoming permanent compiler intrinsics.
Intrinsics are not shell operations
Do not confuse intrinsics with shell syntax such as nil and unwrap
!.
For example:
ali MaybeText: opt[str]
ali Failure: err[str]
fun[] unwrap_optional(value: MaybeText): str = {
return [uwp]value
}
fun[] unwrap_failure(value: Failure): str = {
return [uwp]value
}
That ! surface is part of shell typing, not the intrinsic registry.
Likewise, recoverable routine calls such as:
fun[] read_code(path: str): int / str = { ... }
and V3 recoverable results produced by eventual | await are handled with:
check(expr)expr || fallback
not with shell unwrap.
Current compiler truth
The current compiler has one shared intrinsic registry crate:
fol-intrinsics
That registry is the source of truth for:
- canonical intrinsic names and aliases
- milestone availability (
V1/V2/V3) - type-checking selection rules
- lowering mode
- backend/runtime role classification
The current runtime companion for implemented V1 intrinsics is:
fol-runtime
- intrinsic names
- aliases
- categories
- current milestone availability
- deferred-roadmap classification
- lowering mode
- backend-facing role
So the short rule is:
- parser recognizes intrinsic syntax
fol-intrinsicsowns intrinsic identity- type checking validates intrinsic calls
- lowering maps them to explicit IR shapes
This page should describe only the subset that is actually implemented, plus clearly marked deferred surfaces.
Macros
Current boundary:
- the
def-based meta family (macros, alternatives, defaults, templates) is planned for a future release; none of it is part of the current compiler surface - everything below describes intended design, not current behavior
Macros are intended to support source-level replacement. They will not be able
to redefine the compiler-owned memory sigils @, #, !, &, or *; those
spellings already have fixed ownership, borrowing, and pointer semantics.
def '$'(a: any): mac = '.to_string'
Alternatives
Current boundary:
- the
def-based meta family (macros, alternatives, defaults, templates) is planned for a future release; none of it is part of the current compiler surface - everything below describes intended design, not current behavior
Alternatives are intended to provide shorter source spellings. They will not alias or override the compiler-owned memory sigils. The exact future grammar and supported replacement targets remain open until the meta family is implemented.
Defaults
Current boundary:
- the
def-based meta family (macros, alternatives, defaults, templates) is planned for a future release; none of it is part of the current compiler surface - everything below describes intended design, not current behavior
Defaults are intended to change option defaults within an already legal
capability tier. They cannot turn a heap-backed type into a core type or grant
hosted APIs. In particular, str remains a memo type regardless of a future
mutability/default declaration. A possible future spelling for changing its
option defaults is:
def 'str': def[] = 'str[new,mut,nor]'
Templates
Current boundary:
- the
def-based meta family (macros, alternatives, defaults, templates) is planned for a future release; none of it is part of the current compiler surface - everything below describes intended design, not current behavior
Templates are supposed to be mostly used for operator overloading. They are glorified functions, hence used with pro or fun instead of def.
For example here is how the != is defined:
fun '!='(a, b: int): bol = { return .not(.eq(a, b)) }
.assert( 5 != 4 )
or define $ to return the string version of an object (careful, it is object$ and not $object, the latest is a macro, not a template):
pro (file)'$': str = { return "somestring" }
.echo( file$ )
Type System
This section defines the built-in type families used throughout the language.
Every expression has a type, and every declaration that introduces a value or callable surface interacts with the type system.
The built-in type families are grouped as:
- ordinal types: integers, floats, booleans, characters
- container types: arrays, vectors, sequences, matrices, maps, sets
- complex types: strings, numeric abstractions, pointers, errors
- special types: optional, union-like/sum-style surfaces, any-like and none-like forms
User-defined type construction is described later in the declarations section under typ, ali, records, entries, and standards.
Ordinal
Ordinal types
Ordinal types have the following characteristics:
- Ordinal types are countable and ordered. This property allows the operation of functions as inc, ord, dec on ordinal types to be defined.
- Ordinal values have a smallest possible value. Trying to count further down than the smallest value gives a checked runtime or static error.
- Ordinal values have a largest possible value. Trying to count further than the largest value gives a checked runtime or static error.
Ordinal types are the most primitive type of data:
- Intigers:
int[options] - Floating:
flt[options] - Characters:
chr[options] - Booleans:
bol
Intiger type
An integer is a number without a fractional component. We used one integer of the u32 type, the type declaration indicates that the value it’s associated with should be an unsigned integer (signed integer types start with i, instead of u) that takes up 32 bits of space:
var aVar: int[u32] = 45;
Each variant can be either signed or unsigned and has an explicit size. Signed and unsigned refer to whether it’s possible for the number to be negative or positive—in other words, whether the number needs to have a sign with it (signed) or whether it will only ever be positive and can therefore be represented without a sign (unsigned). It’s like writing numbers on paper: when the sign matters, a number is shown with a plus sign or a minus sign; however, when it’s safe to assume the number is positive, it’s shown with no sign.
Length | Signed | Unsigned |
-----------------------------------
8-bit | 8 | u8 |
16-bit | 16 | u16 |
32-bit | 32 | u32 |
64-bit | 64 | u64 |
128-bit | 128 | u128 |
arch | arch | uarch |
Float type
Fol also has two primitive types for floating-point numbers, which are numbers with decimal points. Fol’s floating-point types are flt[32] and flt[64], which are 32 bits and 64 bits in size, respectively. The default type is flt[64] because on modern CPUs it’s roughly the same speed as flt[32] but is capable of more precision.
Length | Type |
--------------------
32-bit | 32 |
64-bit | 64 |
arch | arch |
Floating-point numbers are represented according to the IEEE-754 standard. The flt[32] type is a single-precision float, and flt[f64] has double precision.
pro[] main: int = {
var aVar: flt = 2.; // float 64 bit
var bVar: flt[64] = .3; // float 64 bit
.echo(.eq(aVar, bVar)) // compare values with the V1 intrinsic surface
var bVar: flt[32] = .54; // float 32 bit
}
Character type
In The Unicode Standard 8.0, Section 4.5 “General Category” defines a set of character categories. Fol treats all characters in any of the letter as Unicode letters, and those in the Number category as Unicode digits.
chr[utf8,utf16,utf32]
def testChars: tst["some testing on chars"] = {
var bytes = "hello";
.echo(.len(bytes));
.echo(bytes[1]);
.echo(.eq("e", "\x65"));
}
Current `V1` intrinsic note:
- `.eq(...)` is implemented for scalar equality
- `.len(...)` is implemented for strings and supported containers
- string `.len(...)` counts UTF-8 BYTES, not characters (`.len("héllo")` is 6)
- older helper surfaces such as `.assert(...)`, `.typeof(...)`, and related
compile-time queries are not part of the current implemented intrinsic subset
Boolean type
The boolean type is named bol in Fol and can be one of the two pre-defined values true and false.
bol
Container
Model reminder:
- examples here that call
.echo(...)assume amemoartifact with bundledstdsupport available arr[...]is valid incorevec[...],seq[...],set[...], andmap[...]requirememo
Current boundary:
- static
arr[...],vec[...],seq[...], andmap[...]are the current compiler surface - the
axi[...]axiom container and.add(...)mutation shown later are not implemented; they are later design work, not current behavior - the
set[...]tuple-member form typechecks, but only single-member sets are executable today; those homogeneous runtime sets support.len(...), deterministic positional lookup, and ordinary iteration
Containers are of compound types. They contain other primitive or constructed types. To access the types in container those brackets are used: [], so:
var container: type = { element, element, element } // declaring a container
var varable: type = container[2] // accessing the last element
{{% notice note %}}
Containers are always zero indexed
{{% /notice %}}
Static Arrays
Arrays
arr[type,size]
Arrays are the most simple type of container. They contain homogeneous type, meaning that each element in the array has the same type. Arrays always have a fixed length specified as a constant expression arr[type, size]. They can be indexed by any ordinal type to acces its members.
pro[] main: int = {
var anArray: arr[int, 5] = { 0, 1, 2, 3, 4 }; // declare an array of intigers of five elements
var element = anArray[3]; // accessing the element
.echo(element) // prints: 3
}
To give a value unique heap ownership, use var[new] in a memo artifact. See
Ownership:
pro[] main: int = {
var[new] values: arr[int, 3] = { 1, 2, 3 }; // uniquely owned heap array
}
Dynamic arrays
Dynamic are similar to arrays but of dynamic length which may change during runtime (like strings). A dynamic array s is always indexed by integers from 0 to .len(s)-1 and its bounds are checked.
Current V1 intrinsic note:
.len(...)is implemented.low(...),.high(...),.cap(...), and.is_empty(...)are registry-owned deferred surfaces, not activeV1intrinsics
So the safe current query surface for containers is .len(...).
Current V1 runtime note:
arr[...]remains the fixed-size container familyarr[...]itself is available incore, provided its element type is also legal therevec[...],seq[...],set[...], andmap[...]are heap-backedmemofamilies and remain available when bundledstdlayers hosted APIs on top- runtime-backed
set[...]andmap[...]preserve deterministic ordering for rendering and backend-visible behavior in the current compiler
{{% notice tip %}}
Dynamic containers remain heap-backed. FOL does not silently convert a
vec[...] or seq[...] into arr[...] to make it legal in core; choose an
explicit fixed-size array when the program needs the core model.
{{% /notice %}}
There are two implementations of dynamic arrays:
- vectors
vec[] - sequences
seq[]
Vecotors
Vectors are dynamic arrays, that resizes itself up or down depending on the number of content.
Advantage:
- accessing and assignment by index is very fast O(1) process, since internally index access is just [address of first member] + [offset].
- appending object (inserting at the end of array) is relatively fast amortized O(1). Same performance characteristic as removing objects at the end of the array. Note: appending and removing objects near the end of array is also known as push and pop.
Disadvantage:
- inserting or removing objects in a random position in a dynamic array is very slow O(n/2), as it must shift (on average) half of the array every time. Especially poor is insertion and removal near the start of the array, as it must copy the whole array.
- Unpredictable performance when insertion or removal requires resizing
- There is a bit of unused space, since dynamic array implementation usually allocates more memory than necessary (since resize is a very slow operation)
In FOL vecotrs are represented like this:
vec[type]
Example:
pro[] main: int = {
var[new] aSequence: seq[str] = { "get", "over", "it" }; // declare an array of intigers of five elements
var element = aSequence[3]; // accessing the element
}
Sequences
Sequences are linked list, that have a general structure of [head, [tail]], head is the data, and tail is another Linked List. There are many versions of linked list: singular, double, circular etc…
Advantage:
- fast O(1) insertion and removal at any position in the list, as insertion in linked list is only breaking the list, inserting, and repairing it back together (no need to copy the tails)
- linked list is a persistent data structure, rather hard to explain in short sentence, see: wiki-link . This advantage allow tail sharing between two linked list. Tail sharing makes it easy to use linked list as copy-on-write data structure.
Disadvantage:
- Slow O(n) index access (random access), since accessing linked list by index means you have to recursively loop over the list.
- poor locality, the memory used for linked list is scattered around in a mess. In contrast with, arrays which uses a contiguous addresses in memory. Arrays (slightly) benefits from processor caching since they are all near each other
In FOL sequneces are represented like this:
seq[type]
Example:
pro[] main: int = {
var[new] aSequence: seq[str] = { "get", "over", "it" }; // declare an array of intigers of five elements
var element = aSequence[3]; // accessing the element
}
SIMD
Matrixes are of type SIMD (single instruction, multiple data )
Matrix
mat[sizex]
mat[sizex,sizey]
mat[sizex,sizey,sizez]
Sets
set[type,type,type..]
A set is a general way of grouping together a number of values with a variety of types into one compound type. Sets have a fixed length: once declared, they cannot grow or shrink in size. In other programming languages they usually are referenced as tuples.
pro[] main: int = {
var aSet: set[str, flt, arr[int, 2]] = { "go", .3, { 0, 5, 3 } };
var element = aSet[2][1]; // accessing the [1] element of the `arr` in the set
.echo(element) // prints: 5
}
Maps
map[key,value]
A map is an unordered group of elements of one type, called the element type, indexed by a set of unique keys of another type, called the key type.
pro[] main: int = {
var aMap: map[str, int] = { {"US",45}, {"DE",82}, {"AL",54} };
var element = aMap["US"]; // accessing the "US" key
.echo(element) // prints: 45
}
The number of map elements is called its length. For a map aMap, it can be discovered using the intrinsic .len(...) and may change during execution. To add a new element, we use name+[element] or addfunction:
.echo(.len(aMap)) // prints: 3
aMap.add( {"IT",55} )
aMap+[{"RU",24}]
.echo(.len(aMap)) // prints: 4
In current V1, backend execution should treat .len(...) and runtime-visible
container rendering as part of the fol-runtime contract rather than
re-deriving container policy per backend.
The comparison operators == and != must be fully defined for operands of the key type; thus the key type must not be a function, map, or sequence.
{{% notice tip %}}
Maps are heap-backed memo containers. They are not silently converted into a
static core container based on their initializer.
{{% /notice %}}
Axiom
axi[typ, typ]
A axiom is a list of facts. A fact is a predicate expression that makes a declarative statement about the problem domain. And whenever a variable occurs in a expression, it is assumed to be universally quantified as silent.
var likes: axi[str, str] = { {"bob","alice"} , {"alice","bob"}, {"dan","sally"} };
{{% notice info %}}
Accesing any container always returns the value, but if we put an : before the access symbol so :[], then it will return true or false if there is data or not on the specified access.
{{% /notice %}}
likes["bob","alice"] // will return {"bob","alice"}
likes:["bob","alice"] // will return true
likes["sally","dan"] // will return {}
likes:["sally","dan"] // will return false
Axioms are a data types that are meant to be used with logic programming. There are containers where facts are stated, and when we want to acces the data, they are always served as containers.
var parent: axi[str, str] = { {"albert","bob"}, {"alice","bob"}, {"bob","carl"}, {"bob","tom"} };
parent["bob",*] // this gets all elements that "bob" relates to
{"carl", "tom"}
parent[*,"bob"] // this gets all elements that "bob" relates from
{"albert", "alice"}
Adding new element can be done like in other containers:
var parent: axi[str, str] = { {"albert","bob"}, {"alice","bob"}, {"bob","carl"}, {"bob","tom"} };
parent.add({"albert","betty"})
parent.add({"albert","bill"})
parent.add({"alice","betty"})
parent.add({"alice","bill"})
And they can be nesetd too:
var line: axi[axi[int, int], axi[int, int]] = {{{4,5},{4,8}},{{8,5},{4,5}}}
And we can use the simplified form too, just axi instead of all the type. We let the compiler fill in the for us:
var line: axi = {{{4,5},{4,8}},{{8,5},{4,5}}}
Complex
Strings
str is the heap-backed UTF-8 string type. It requires the memo capability
model; a memo artifact with bundled std remains heap-capable. It is not
available in core.
var label: str = "fol";
Number
num is a planned abstraction over integer and floating-point types. It is not
part of the current compiler surface. Imaginary-number support is likewise
outside the active stream, lexer, parser, and lowering contract.
Pointers
V3 ships typed unique and shared pointers:
var[mut] unique: ptr[int] = [ref]value;
var shared: ptr[shared, int] = [ref]value;
ptr[T] is uniquely owned and writable through a mutable pointer binding.
ptr[shared, T] is reference-counted and read-only. Pointer types can be
analyzed in core, but [ref]value constructs an allocation and therefore
requires memo. A memo artifact may additionally declare bundled std, but
the pointer operation itself does not require hosted APIs. Raw ptr[raw, T]
remains a V4 interop boundary.
See Pointers for transfer, dereference, shared recursion, and current place-projection rules.
Error shells
err[T] is the storable error shell:
var failure: err[str] = "not found";
It is distinct from a routine’s recoverable : Result / Error contract. See
Recoverable Errors for that boundary.
Special
Current boundary:
- the
opt[...]optional shell, theerr[...]error shell, and thenevnever type are the current compiler surface uni[...](union) andanyare not implemented; the compiler rejects them with a “not yet supported” diagnostic- those sections describe later design work, not current behavior
Optional
Either are empty or have a value
opt[]
Never
nev[]
The never type is a type with no values, representing the result of computations that never complete.
Union
Union is a data type that allows different data types to be stored in the same memory locations. Union provides an efficient way of reusing the memory location, as only one of its members can be accessed at a time. It uses a single memory location to hold more than one variables. However, only one of its members can be accessed at a time and all other members will contain garbage values. The memory required to store a union variable is the memory required for the largest element of the union.
We can use the unions in the following locations.
- Share a single memory location for a variable and use the same location for another variable of different data type.
- Use it if you want to use, for example, a long variable as two short type variables.
- We don’t know what type of data is to be passed to a function, and you pass union which contains all the possible data types.
var aUnion: uni[int[8], int, flt];
Any
any[]
Null
nil
In the current V1 compiler milestone, nil is accepted only when type
checking already knows it is flowing into an opt[...] or err[...] shell.
Plain inference from nil alone is not supported.
Current V1 shell note:
opt[...]anderr[...]are shell values.- Unwrap
[uwp]valueapplies to those shell values. - routine calls declared with
ResultType / ErrorTypeare noterr[...]shells. - use
check(...)orexpr || fallbackfor those routine calls instead of!. - use
err[...]when you need a storable error value.
Declarations And Items
This section covers the main named program elements of FOL.
The declaration families include:
- bindings:
var,con,lab - routines:
fun,pro,log - type and construct declarations:
typ,ali - contract-like declarations:
std
Module-like forms such as use, def, and seg are covered in the modules chapter because they primarily define source layout, namespace, and composition boundaries.
Variables
Current boundary:
- ordinary
var,con, andlabdeclarations are the current compiler surface @varandvar[new]are the shipped V3 unique-heap forms and requirefol_model = "memo"; amemoartifact with bundledstdremains heap-capable too- the pipe-ternary and many-to-many assignment forms shown below are later design work, not current behavior
Here are some of the ways that variables can be defined:
var[mut] counter: int = 98
var[exp] label: str = "this is a string"
~var ratio = 192.56
+var short_flag = true
var names: arr[str, 3] = { "one", "two", "three" }
var scores: seq[int] = { 20, 25, 45, 68, 73, 98 }
var pair: set[int, str] = { 12, "word" }
var picked = names[1]
Assignments
Following the general rule of FOL:
declaration[options] name: type[options] = { implementation; };
then declaring a new variable is like this:
var[exp] aVar: int = 64
however, the short version can be used too, and the compiler figures out at compute time the type:
var shortVar = 24; // compiler gives this value of `int[arch]`
When new variable is created, and uses an old variable to assign, the resulting binding is a new value binding rather than an alias to the old name:
pro[] main: int = {
var aVar: int = 55;
var newVar: int = aVar;
return newVar;
}
V3 adds explicit unique-heap ownership, lexical borrowing, and typed pointers. See the memory chapters for their transfer and aliasing rules.
Variables can be assigned to an output of a function:
pro[] main: int = {
fun addFunc(x, y: int): int = {
return x + y;
}
var aVar: int = addFunc(4, 5);
}
Piping / Ternary
Piping can be used as ternary operator. More about piping can be found here. Here is an example, the code below basically says: if the function internally had an error, don’t exit the program, but assign another value (or default value) to the variable:
pro[] main: int = {
fun addFunc(x, y: int): int = {
return x + y;
}
var aVar: int = addFunc(4, 5) | result > 8 | return 6;
}
Borrowing
var[bor] creates a read-only lexical borrow. The owner is inaccessible while
the borrow is active and becomes accessible again when the borrow’s scope ends.
The !borrow prefix may give it back earlier.
fun[] main(): int = {
var value: int = 55;
{
var[bor] view: int = [bor]value;
var seen: int = view;
};
return value;
};
See Ownership for the full borrowing rules.
Options
As with all other blocks, var have their options: var[opt]:
Options can be of two types:
- flags eg.
var[mut] - values eg.
var[pri=2]
Some binding options have prefix alternatives. Mutable var[mut] may be
written as ~var, but ~ is never accepted inside the option brackets.
| opt | s | type | description | control |
----------------------------------------------------------------------------------------------
| mut | ~ | flag | making a variable mutable | mutability |
| imu | | flag | making a variable imutable (default) | |
| sta | ! | flag | making a variable a static | |
| new | @ | flag | allocating a uniquely owned heap value | ownership |
| bor | | flag | declaring a lexical borrow binding | ownership |
| rac | ? | flag | making a variable reactive | |
----------------------------------------------------------------------------------------------
| exp | + | flag | making a global variable exported | visibility |
| nor | | flag | making a global variable normal (default) | |
| hid | - | flag | making a global variable file-local | |
Alternatives
There is a shorter way for variables using alternatives, for example, instead of using var[+], a leaner +var can be used instead.
+var aVar: int = 55
fun[] main(): int = {
.echo(aVar)
return aVar
}
When combining options, one may use a prefix alternative. For example,
var[mut,exp] may be written as +var[mut] or ~var[exp]:
+var[mut] aVar: int = 55
fun[] main(): int = {
.echo(aVar)
return aVar
}
Types
Mutable variables
By default a variable declared without options is mutable:
pro[] main: int = {
var aNumber: int = 5;
aNumber = 54;
}
The explicit var[mut] option and its ~var prefix alternative select the
same mutable behavior:
pro[] main: int = {
var[mut] aNumber: int = 5
~var anotherNumber: int = 24
aNumber, anotherNumber = 6 // this is completely fine, we assign two wariables new values
}
Immutable variables
Use var[imu] when a local binding must not be reassigned:
pro[] main: int = {
var[imu] aNumber: int = 5;
aNumber = 54; // typecheck error: immutable binding
}
Reactive types
Current milestone note: reactive variables are part of a later milestone, not
the current V1 compiler contract. The syntax may appear in design examples,
but present-day V1 typechecking rejects reactive semantics explicitly.
Reactive types is a types that flows and propagates changes.
For example, in an normal variable setting, var a = b + c would mean that a is being assigned the result of b + c in the instant the expression is evaluated, and later, the values of b and c can be changed with no effect on the value of a. On the other hand, declared as reactive, the value of a is automatically updated whenever the values of b or c change, without the program having to re-execute the statement a = b + c to determine the presently assigned value of a.
pro[] main: int = {
var[mut] b, c = 5, 4;
var[rac] a: int = b + c
.echo(a) // prints 9
c = 10;
.echo(a) // now it prints 10
}
Static types
Current milestone note: static variables are also part of later systems/runtime
work. The current V1 compiler keeps them outside the implemented subset.
Is a variable which allows a value to be retained from one call of the function to another, meaning that its lifetime declaration. and can be used as var[sta] or var[!]. This variable is special, because if it is initialized, it is placed in the data segment (aka: initialized data) of the program memory. If the variable is not set, it is places in .bss segmant (aka: uninitialized data)
pro[] main: int = {
{
var[!] aNumber: int = 5
}
{
.echo(aNumber) // it works as it is a static variable.
}
}
Scope
As discussed before, files in the same package share one package scope. That means package-level functions and variables may be used across sibling files without importing those sibling files one by one.
However, package-private declarations are still different from exported declarations:
- default visibility means the declaration is available inside the same package
exp/+means the declaration may be used through imports from outside the packagehid/-means the declaration is visible only inside its own file
So the visibility model is:
- package scope by default
- exported outside the package with
exp - file-only with
hid
In order for a variable to be accessed by the importer, it needs the exp flag option, so var[exp], or var[+].
package shko, file1.fol
fun[exp] add(a, b: int): int = { return a + b }
fun sub(a, b: int): int = { return a - b }
package vij, file1.fol
use shko: loc = {"../folder/shko"}
fun[] main(): int = {
.echo(add(5, 4)) // this works, `add` is exported
.echo(sub(5, 4)) // this fails, `sub` is not exported
return add(5, 4)
}
There is even the opposite option too. If we want a function or variable to be used only inside its own file, even though the package is shared, then we use the hid option flag: var[hid] or var[-].
file1.fol
var[-] aVar: str = "yo, sup!"
file2.fol
fun[] main(): int = {
.echo(aVar) // this throws, `aVar` is hidden to its own file
return 0
}
Multiple
Many to many
Many variables can be assigned at once, This is especially usefull, if variables have same options but different types eg. variable is mutabe and exported:
~var[exp] oneVar: int[32] = 24, twoVar = 13, threeVar: string = "shko";
Or to assign multiple variables of the same type:
~var[exp] oneVar, twoVar: int[32] = 24, 13;
To assign multiple variables of multiple types, the type is omitted, however, this way we can not put options on the type (obviously, the default type is assign by compiler):
~var[exp] oneVar, twoVar, threeVar = 24, 13, "shko";
Another “shameless plagiarism” from golang can be used by using ( ... ) to group variables:
~var[exp] (
oneVar: int[32] = 13,
twoVar: int[8] = 13,
threeVar: str = "shko",
)
Many to one
Many variables of the same type can be assigned to one output too:
var oneVar, twoVar: int[8] = 2;
However, each of them gets a copy of the variable on a new memory address:
.assert([ref]oneVar == [ref]twoVar) // this will return false
One to many
And lastly, one variable can be assigned to multiple ones. This by using container types:
oneVar grouppy: seq[int] = { 5, 2, 4, 6 }
Or a more complicated one:
var anothermulti: set[str, seq[num[f32]]] = { "string", {5.5, 4.3, 7, .5, 3.2} }
Or a very simple one:
var simplemulti: any = { 5, 6, {"go", "go", "go"} }
Containers
Containers are of special type, they hold other types within. As described before, there are few of them
Access
To acces container variables, brackets like this [] are use:
var shortvar = anothermulti[1][3] // compiler will copy the value `anothermulti[1][3]` (which is a float) to a new memory location
Routines
Routines are callable declarations.
FOL has three routine families:
pro: procedures, used for effectful workfun: functions, intended for ordinary value-producing computationlog: logical routines and relation-like callable forms
Routine declarations support a shared structural pattern:
fun[options] name(params): return_type = { body }
pro[options] name(params): return_type = { body }
log[options] name(params): return_type = { body }
FOL also allows an alternate header style:
fun[options] name: return_type = (params) { body }
This chapter family covers parameters, calls, defaults, variadics, return values, and routine-specific semantics.
Current V1 routine model:
- values are returned with an explicit
return; there is no implicitresultvariable in the current compiler - the short form that omits the return type is not current; the return type must be declared
- last-expression / implicit-tail return is not current
- routine overloading is not supported; duplicate routine names are rejected
- a routine that declares a recoverable error type (
: T / E) must have both a return path and areportpath
The sections further down that show result = ..., short-form omission,
last-expression return, and same-name overloading describe earlier design, not
the current compiler surface.
Types
There are two main types of routines in fol:
-
Procedurues
A procedure is a piece of code that is called by name. It can be passed data to operate on (i.e. the parameters) and can optionally return data (the return value). All data that is passed to a procedure is explicitly passed.
-
Functions
A function is called pure function if it always returns the same result for same argument values and it has no side effects like modifying an argument (or global variable) or outputting to I/O. The only result of calling a pure function is the return value.
Parameters
Formal parameters
Routines typically describe computations. There are two ways that a routine can gain access to the data that it is to process: through direct access to nonlocal variables (declared elsewhere but visible in the routine) or through parameter passing. Data passed through parameters are accessed using names that are local to the routine. Routine create their own unnamed namespace. Every routine has its own Workspace. This means that every variable inside the routine is only usable during the execution of the routine (and then the variables go away).
Parameter passing is more flexible than direct access to nonlocal variables. Prrameters are special variables that are part of a routine’s signature. When a routine has parameters, you can provide it with concrete values for those parameters. The parameters in the routine header are called formal parameters. They are sometimes thought of as dummy variables because they are not variables in the usual sense: In most cases, they are bound to storage only when the routine is called, and that binding is often through some other program variables.
Parameters are declared as a list of identifiers separated by semicolon (or by a colon, but for code cleanness, the semicolon is preferred). A parameter is given a type by : typename. If after the parameter the : is not declared, but , colon to identfy another paremeter, of which both parameters are of the same type if after the second one the : and the type is placed. Then the same type parameters continue to grow with , until : is reached.
fun[] calc(el1, el2, el3: int[64]; changed: bol = true): int[64] = { result = el1 + el2 - el3 }
In routine signatures, you must declare the type of each parameter. Requiring type annotations in routine definitions is obligatory, which means the compiler almost never needs you to use them elsewhere in the code to figure out what you mean. Routine can parameter overloaded too. It makes possible to create multiple routine of the same name with different implementations. Calls to an overloaded routine will run a specific implementation of that routine appropriate to the context of the call, allowing one routine call to perform different tasks depending on context:
fun retBigger(el2, el2: int): int = { return el1 | this > el2 | el2 }
fun retBigger(el2, el2: flt): flt = { return el1 | this > el2 | el2 }
pro main: int = {
retBigger(4, 5); // calling a routine with intigers
retBigger(4.5, .3); // calling another routine with same name but floats
}
The overloading resolution algorithm determines which routine is the best match for the arguments. Example:
pro toLower(c: char): char = { // toLower for characters
if (c in {'A' ... 'Z'}){
result = chr(ord(c) + (ord('a') - ord('A')))
} else {
result = c
}
}
pro toLower(s: str): str = { // toLower for strings
result = newString(.len(s))
for i in {0 ... len(s) - 1}:
result[i] = toLower(s[i]) // calls toLower for characters; no recursion!
}
Actual parameters
routine call statements must include the name of the routine and a list of parameters to be bound to the formal parameters of the routine. These parameters are called actual parameters. They must be distinguished from formal parameters, because the two usually have different restrictions on their forms.
Positional parameters
The correspondence between actual and formal parameters, or the binding of actual parameters to formal parameters - is done by position: The first actual parameter is bound to the first formal parameter and so forth. Such parameters are called positional parameters. This is an effective and safe method of relating actual parameters to their corresponding formal parameters, as long as the parameter lists are relatively short.
fun[] calc(el1, el2, el3: int): int = { result = el1 + el2 - el3 }
pro main: int = {
calc(3,4,5); // calling routine with positional arguments
}
Keyword parameters
When parameter lists are long, however, it is easy to make mistakes in the order of actual parameters in the list. One solution to this problem is with keyword parameters, in which the name of the formal parameter to which an actual parameter is to be bound is specified with the actual parameter in a call. The advantage of keyword parameters is that they can appear in any order in the actual parameter list.
fun[] calc(el1, el2, el3: int): int = { result = el1 + el2 - el3 }
pro main: int = {
calc(el3 = 5, el2 = 4, el1 = 3); // calling routine with keywords arguments
}
Mixed parameters
Keyword and positional arguments can be used at the same time too. In V1,
ordinary positional arguments must come before named arguments. After a named
argument appears, later ordinary arguments are rejected because position is no
longer well defined.
The one supported exception is call-site unpack for the final variadic
parameter. ...items may appear after named arguments when it is feeding that
final variadic input.
fun[] calc(el1, el2, el3: int, el4, el5: flt): int = { result[0] = ((el1 + el2) * el4 ) - (el3 ^ el5); }
pro main: int = {
calc(3, 4, el5 = 2, el4 = 5, el3 = 6); // element $el3 needs to be keyeorded at the end because
// its positional place is taken by keyword argument $el5
}
This remains invalid:
calc(el3 = 5, 4, el1 = 3)
But this is valid in V1 when the last parameter is variadic:
fun[] score(base: int, step: int = 2, extras: ... int): int = {
return base;
}
fun[] run(): int = {
var extras: seq[int] = {4, 5};
return score(base = 3, ...extras);
}
Default arguments
Formal parameters can have default values too. A default value is used if no actual parameter is passed to the formal parameter.
In V1:
- omitted parameters use their declared default
- named arguments may skip over defaulted parameters
- defaults can coexist with a final variadic parameter
fun[] calc(el1, el2, el3: int, rise: bool = true): int = { result[0] = el1 + el2 * el3 | this | el1 + el2; }
pro main: int = {
calc(3,3,2); // this returns 6, last positional parameter is not passed but
// the default `true` is used from the routine declaration
calc(3,3,2,false) // this returns 12
calc(el1 = 3, el2 = 3, el3 = 2) // named arguments may also rely on the default
}
Variadic routine
The use of ... as the type of argument at the end of the argument list
declares the routine as variadic. This must appear as the last argument of the
routine.
In V1, the final variadic parameter is bound as a seq[...] value. Extra
trailing call arguments are collected into that sequence.
fun[] calc(rise: bool; ints: ... int): int = { result[0] = ints[0] + ints[1] + ints[2] * ints[3] | this | ints[0] + ints[1]; }
pro main: int = {
calc(true,3,3,3,2); // this returns 81, four parmeters are passed as variadic arguments
calc(true,3,3,2) // this returns 0, as the routine multiplies with the forth varadic parameter
// and we have given only three (thus the forth is initialized as zero)
}
Call-site unpack is the companion feature to variadics. It forwards an existing sequence into the final variadic parameter:
fun[] calc(rise: bool; ints: ... int): int = {
return ints[0];
}
fun[] run(values: seq[int]): int = {
return calc(true, ...values);
}
This also works after named arguments:
fun[] score(base: int, step: int = 2, extras: ... int): int = {
return base;
}
fun[] run(values: seq[int]): int = {
return score(base = 3, ...values);
}
V1 intentionally does not support pseudo-arguments such as
extras[0] = 1 at call sites. Variadic inputs are still just routine
parameters, so call binding stays limited to:
- ordinary positional arguments
- named arguments by declared parameter name
- one final
...sequenceunpack for the variadic tail
{{% notice warn %}}
Nested procedures don’t have access to the outer scope, while nested function have but can’t change the state of it.
{{% /notice %}}
Return
The return type of the routine has to always be defined, just after the formal parameter definition. Following the general rule of FOL:
fun[] add(el1, el2: int[64]): int[64] = { result = el1 + el2 }
To make it shorter (so we don’t have to type int[64] two times), we can use a short form by omitting the return type. The compiler then will assign the returntype the same as the functions return value.
fun[] add(el1, el2: int[64]) = { result = el1 + el2 }
{{% notice info %}}
Current V1 routine summary:
- routines declare a success type after
: - routines may also declare a recoverable error type after
/ report exprexits through that declared error path- routine call results declared with
/ ErrorTypeare noterr[...]shell values - use
check(...)orexpr || fallbackfor those calls - ordinary plain-value use of
/ ErrorTypecalls is rejected - keep postfix
!foropt[...]anderr[...]shell values
{{% /notice %}}
The implicitly declared variable result is of the same type of the return type. For it top be implicitly declared, the return type of the function shoud be always declared, and not use the short form. The variable is initialized with zero value, and if not changed during the body implementation, the same value will return (so zero).
pro main(): int = {
fun[] add(el1, el2: int[64]): int[64] = { result = el1 + el2 } // using the implicitly declared $result variable
fun[] sub(el1, el2: int[64]) = { return el1 - el2 } // can't access the result variable, thus we use return
}
Recoverable error-aware routines use the current signature form:
fun[] read(path: str): int / str = {
report "missing path"
}
and are handled at the call site with check(...) or || rather than shell
unwrap or plain propagation.
Current intrinsic note:
.echo(...)is a dot-root diagnostic intrinsiccheck(...)is a keyword intrinsic for recoverable-call inspectionpanic(...)is a keyword intrinsic for immediate abortasandcastare registry-owned but still deferred in currentV1
Model reminder:
- any routine example that calls
.echo(...)assumes amemoartifact with bundledstdsupport available - routine examples without hosted behavior should stay valid in
coreormemowhere the surrounding chapter claims they are model-neutral
The final expression in the function will be used as return value. For this to be used, the return type of the function needs to be defined (so the function cnat be in the short form)). ver this can be used only in one statement body.
pro main(): int = {
fun[] add(el1, el2: int[64]): int[64] = { el1 + el2 } // This is tha last statement, this will serve as return
fun[] someting(el1,el2: int): int = {
if (condition) {
} else {
}
el1 + el2 // this will throw an error, cand be used in kulti statement body
}
fun[] add(el1, el2: int[64]) = { el1 + el2 } // this will throw an error, we can't use the short form of funciton in this way
Alternatively, return and report can exit a routine early from within
control flow. See the recoverable-error chapter for the full current V1
contract and the shell-vs-routine distinction.
Procedures
Procedures are most common type of routines in Fol. When a procedure is “called” the program “leaves” the current section of code and begins to execute the first line inside the procedure. Thus the procedure “flow of control” is:
- The program comes to a line of code containing a “procedure call”.
- The program enters the procedure (starts at the first line in the procedure code).
- All instructions inside of the procedure are executed from top to bottom.
- The program leaves the procedure and goes back to where it started from.
- Any data computed and RETURNED by the procedure is used in place of the procedure in the original line of code.
Procedures have side-effects, it can modifies some state variable value(s) outside its local environment, that is to say has an observable effect besides returning a value (the main effect) to the invoker of the operation. State data updated “outside” of the operation may be maintained “inside” a stateful object or a wider stateful system within which the operation is performed.
Current milestone note:
- ordinary procedure declarations are part of
V1 - recoverable procedure errors (
Result / Error) are part ofV1 - ownership-aware transfers, unique heap moves, and explicit
[bor]parameters are part of the shippedV3memory contract mux[T]parameters and the direct named-routine task boundary are part of the shippedV3processor contract
Pointer and borrowing examples in this chapter therefore describe current V3 behavior when the artifact’s capability mode permits the underlying value. Broader indirect task dispatch and future ownership/interop designs remain outside that shipped surface.
Procedures can also declare a custom recoverable error type with / after the result type:
pro[] write(path: str): int / io_err = {
report "permission denied";
}
The first : declares the result type, and / declares the routine error type.
Current V1 note:
- a procedure declared as
pro[] write(...): T / Edoes not produce anerr[E]shell value that can be unwrapped with[uwp] - it produces a recoverable routine result with a success path and an error path
- use
check(...)orexpr || fallbackat the call site - keep postfix
!foropt[...]anderr[...]shell values only
Passing values
Procedure parameters are ordinary typed inputs unless the parameter has an
explicit ownership option. Values passed to ordinary parameters follow the
type’s transfer rule. A parameter written as name[bor]: T borrows its
argument for the call without taking ownership.
Simple example:
pro[] inspect(value[bor]: int): int = {
return value;
};
pro[] main(): int = {
var value: int = 7;
return inspect(value);
};
Ownership and borrowing
The authoritative version split is:
- ordinary procedures and functions are part of
V1 - recoverable routine errors are part of
V1 - explicit
name[bor]: Tborrow parameters are part of the shippedV3memory contract - typed pointers and unique heap ownership are also part of that
V3contract
Borrow give-back uses the !borrow prefix. Ownership behavior never depends on
identifier casing, and there is no alternate parenthesized parameter grammar.
See the memory chapters and plan/VERSIONS.md for the version boundary.
Functions
Functions compared to procedure are pure. A pure function is a function that has the following properties:
- Its return value is the same for the same arguments (no variation with local static variables, non-local variables, mutable reference arguments or input streams from I/O devices).
- Its evaluation has no side effects (no mutation of local static variables, non-local variables, mutable reference arguments or I/O streams).
Thus a pure function is a computational analogue of a mathematical function. Pure functions are declared with fun[]
fun[] add(el1, el2: int[64]): int[64] = { result = el1 + el2 }
Current boundary:
- the current compiler requires an explicit
returnand a declared return type; theresult = ...spelling, short-form return-type omission, and last-expression return shown here are earlier design, not current behavior - the closures, currying, and generator material at the end of this chapter is later design work, not part of the current compiler surface
- nested and anonymous routine bodies cannot implicitly capture an outer local in the shipped compiler; pass the value through a declared parameter instead
When a function has a custom recoverable error type, use / after the result type:
fun[] read(path: str): str / io_err = {
report "file not found";
}
The first : declares the result type, and / declares the routine error type.
Current V1 note:
- a function declared as
fun[] read(...): T / Edoes not return anerr[E]shell value - it returns through a recoverable routine error path
- handle that path immediately with
check(...)orexpr || fallback - plain assignment, return, and ordinary expression use are rejected
- postfix
!stays reserved foropt[...]anderr[...]shell values {{% notice warn %}}
Functions in FOL are lazy-initialized.
{{% /notice %}}
So it is an evaluation strategy which delays the evaluation of the function until its value is needed. You call a function passing it some arguments that were expensive to calculate and then the function don’t need all of them due to some other arguments.
Consider a function that logs a message:
log.debug("Called foo() passing it " + .to_string(argument_a) + " and " + .to_string(argument_b));
The log library has various log levels like “debug”, “warning”, “error” etc. This allows you to control how much is actually logged; the above message will only be visible if the log level is set to the “debug” level. However, even when it is not shown the string will still be constructed and then discarded, which is wasteful.
{{% notice tip %}}
Since Fol supports first class functions, it allows functions to be assigned to variables, passed as arguments to other functions and returned from other functions.
{{% /notice %}}
Anonymous functoins
Anonymous function is a function definition that is not bound to an identifier. These are a form of nested function, in allowing access to variables in the scope of the containing function (non-local functions).
Staring by assigning a anonymous function to a vriable:
var f = fun (a, b: int): int = { // assigning a variable to function
return a + b
}
.echo(f(5,6)) // prints 11
var f: int = (a, b: int){ // this is an short alternative of same variable assignmet to function
return a + b
}
It is also possible to call a anonymous function without assigning it to a variable.
`version 1`
fun[] (a, b: int) = { `define anonymous function`
.echo(a + b)
}(5, 6) `calling anonymous function`
`version 2`
(a, b: int){ `define anonymous function`
.echo(a + b)
}(5, 6) `calling anonymous function`
Closures
Functions can appear at the top level in a module as well as inside other scopes, in which case they are called nested functions. A nested function can access local variables from its enclosing scope and if it does so it becomes a closure. Any captured variables are stored in a hidden additional argument to the closure (its environment) and they are accessed by reference by both the closure and its enclosing scope (i.e. any modifications made to them are visible in both places). The closure environment may be allocated on the heap or on the stack if the compiler determines that this would be safe.
This remains future design. Any eventual heap-allocated closure environment
must require memo; an implementation must not hide a source-visible heap
capability inside core merely as an optimization.
There are two types of closures:
- anonymous
- named
Anonymus closures automatically capture variables, while named closures need to be specified what to capture. For capture we use the [] just before the type declaration.
fun[] add(n: int): int = {
fun added(x: int)[n]: int = { // we make a named closure
return x + n // variable $n can be accesed because we have captured ti
}
return adder()
}
var added = add(1) // assigning closure to variable
added(5) // this returns 6
fun[] add(n: int): int = {
return fun(x: int): int = { // we make a anonymous closure
return x + n // variable $n can be accesed from within the nested function
}
}
Currying
Currying is converting a single function of “n” arguments into “n” functions with a “single” argument each. Given the following function:
fun f(x,y,z) = { z(x(y));}
When curried, becomes:
fun f(x) = { fun(y) = { fun(z) = { z(x(y)); } } }
And calling it woud be like:
f(x)(y)(z)
However, the more iportant thing is taht, currying is a way of constructing functions that allows partial application of a function’s arguments. What this means is that you can pass all of the arguments a function is expecting and get the result, or pass a subset of those arguments and get a function back that’s waiting for the rest of the arguments.
fun calc(x): int = {
return fun(y): int = {
return fun (z): int = {
return x + y + z
}
}
}
var value: int = calc(5)(6) // this is okay, the function is still finished
var another int = value(8) // this completes the function
var allIn: int = calc(5)(6)(8) // or this as alternative
Higer-order functions
A higher-order function is a function that takes a function as an argument. This is commonly used to customize the behavior of a generically defined function, often a looping construct or recursion scheme.
They are functions which do at least one of the following:
- takes one or more functions as arguments
- returns a function as its result
//function as parameter
fun[] add1({fun adder(x: int): int}): int = {
return adder(x + n)
}
//function as return
fun[] add2(): {fun (x: int): int} = {
var f = fun (a, b: int): int = {
return a + b
}
return f
}
Generators (future design)
Generators and the language yield expression are not part of the shipped
V1, bounded V2, or V3 contracts. The lexer, parser, and resolver preserve
the syntax, which also lets tree-sitter keep it readable, but the current
typechecker rejects yield and lowering retains a defensive unsupported
boundary. Parser or highlighting support must not be read as executable
generator support.
An earlier design sketch proposed a container-returning routine that yielded one value at a time:
fun someIter: vec[int] = {
var curInt = 0;
loop(){
yield curInt.inc(1)
}
}
That snippet is a future syntax sketch, not current source. Its eligible return
types, ownership behavior, suspension model, lowering, and runtime contract
remain undecided. Generator yield is also unrelated to V3 | async and
| await, which produce and consume internal eventuals without suspending the
calling routine as a generator.
Methods
A method is a routine associated with a receiver type. In FOL, methods can be
declared as either fun or pro and called with dot syntax
(value.method(...)).
Methods in FOL are procedural sugar, not object-oriented behavior.
A method call is just sugar for calling a routine whose first explicit input is the receiver value. In other words:
tool.parse_msg(10)
should be read as the procedural call:
parse_msg(tool, 10)
This syntax does not introduce:
- classes
- inheritance
- object-owned method bodies
- object-method dispatch as a separate runtime model
typ declares data. Receiver-qualified routines are still ordinary routines.
Current parser-supported receiver declaration syntax is:
fun (parser)parse_msg(code: int): str = {
return "ok";
}
pro (parser)update(code: int): int = {
return code;
}
The receiver type appears in parentheses right after fun or pro, followed by the method name.
That receiver clause does not move the routine “inside” the type. It only says which type may be used in dot-call form for that routine.
The current parser accepts a broader receiver syntax than the final V1 semantic subset. At parse time, receiver positions can still accept named, qualified, builtin-scalar, and bracketed/composite type references. That is a parser fact, not an object model.
The dedicated parser-level rejection in this hardening phase is still for special
builtin forms such as any, none, and non.
Invalid example:
fun (any)parse_msg(code: int): str = {
return "ok";
}
This form reports: Method receiver type cannot be any, non, or none.
Method calls use standard dot syntax:
var tool: parser = parser.new()
var msg: str = tool.parse_msg(10)
This is equivalent in meaning to passing tool as the first routine argument.
The dot form is only the call-site spelling.
Method calls use the same V1 call-binding rules as free routine calls:
- ordinary positional arguments may appear before named arguments
- named arguments bind by declared parameter name
- defaulted parameters may be omitted
- a final variadic parameter may collect trailing arguments
- variadic collection comes only from the explicit
... Tmarker; a plainseq[T]parameter takes one sequence value ...sequencemay appear after named arguments when it feeds that final variadic parameter
Example:
typ Counter: rec = {
total: int
};
fun (Counter)shift(step: int = 2, extras: ... int): int = {
return step;
}
fun[] run(current: Counter, extras: seq[int]): int = {
return current.shift(step = 3, ...extras);
}
V1 does not support indexed pseudo-arguments such as
current.shift(extras[0] = 1, ...extras). That spelling looks like assignment,
not call binding, so the language keeps variadic binding explicit and simple.
Inside a receiver-qualified routine body, the receiver value is available
through self. self is typed as the receiver type and is lowered as the
routine’s ordinary first argument; it does not introduce an object model.
typ Counter: rec = {
var total: int;
};
fun (Counter)read(): int = {
return self.total;
}
Generic receiver types participate too: fun (Box[T])get(T)(): T can return
self.value, and each concrete call site monomorphizes the routine.
For record-focused V1 code, the intended reading is:
- records hold data
- routines stay separate
- the receiver clause enables
value.method(...)spelling and gives the body procedural access to the receiver throughself
Custom error routines also support reporting method call results when receiver-qualified signatures are available:
fun (parser)parse_err(code: int): str = {
return "bad-input";
}
fun run(tool: parser, code: int): int / str = {
report tool.parse_err(code)
return 0
}
Logicals
Logicals, which are logic routines, and represent logic programming, state the routine as a set of logical relations (e.g., a grandparent is the parent of a parent of someone). Such rutines are similar to the database languages. A program is executed by an “inference engine” that answers a query by searching these relations systematically to make inferences that will answer a query.
{{% notice info %}}
One of the main goals of the development of symbolic logic hasbeen to capture the notion of logical consequence with formal, mechanical, means. If the conditions for a certain class of problems can be formalized within a suitable logic as a set of premises, and if a problem to be solved can bestated as a sentence in the logic, then a solution might be found by constructing a formal proof of the problem statement from the premises
{{% /notice %}}
Declaration
In FOL, logic programming is considered as a first class citzen with axioms (axi) as facts and logicals (log) as rules, thus resembling Prolog language. For example:
Facts
Declaring a list of facts (axioms)
var likes: axi[str, str] = { {"bob","alice"} , {"alice","bob"}, {"dan","sally"} };
Rules
Declaring a rule that states if A likes B and B likes A, they are dating
log dating(a, b: str): bol = {
likes:[a,b] and
likes:[b,a]
}
Declaring a rule that states if A likes B and B likes A, they are just friends
log frends(a, b): bol = {
likes:[a,b] or
likes:[b,a]
}
{{% notice warn %}}
Rules can have only facts and varibles within
{{% /notice %}}
Return
A logical log can return different values, but they are either of type bol, or of type container (axioms axi or vectors vec):
Lets define a axiom of parents and childrens called parents and another one of parents that can dance called dances:
var parent: axi[str, str] = { {"albert","bob"},
{"albert","betty"},
{"albert","bill"},
{"alice","bob"},
{"alice","betty"},
{"alice","bill"},
{"bob","carl"},
{"bob","tom"} };
var dances axi[str] = { "albert", "alice", "carl" };
Boolean
Here we return a boolean bol. This rule check if a parent can dance:
log can_parent_dance(a: str): bol = {
parent:[a,_] and dances:[a]
}
can_parent_dance("albert") // return true, "albert" is both a parent and can dance
can_parent_dance("bob") // return false, "bob" is a parent but can't dance
can_parent_dance("carl") // return false, "carl" is not a parent
Lets examine this:
parent:[a,_] and dances:[a]
this is a combintion of two facts. Here we say if a is parent of anyone (we dont care whose, that’s why we use meh symbol [a,_]) and if true, then we check if parent a (since he is a parent now, we fact-checked) can dance.
Vector
The same, we can create a vector of elements. For example, if we want to get the list of parents that dance:
log all_parents_that_dance(): vec[str] = {
parent:[*->X,_] and
dances:[X->Y]
Y
}
all_parents_that_dance() // this will return a string vector {"albert", "alice"}
Now lets analyze the body of the rule:
parent:[*->X,_] and
dances:[X->Y]
Y
Here are a combination of facts and variable assignment through silents. Silents are a single letter identifiers. If a silent constant is not declared, it gets declared and assigned in-place.
Taking a look each line:
parent:[X,_] and
this gets all parents ([*->X,_]),and assign them to silent X. So, X is a list of all parents.
then:
dances[X->Y]:
this takes the list of parents X and checks each if they can dance, and filter it by assigning it to Y so [X->Y] it will have only the parents that can dance.
then:
Y
this just returns the list Y of parents that can dance.
Relationship
If A is object and objects can be destroyed, then A can be destroyed. As a result axioms can be related or conditioned to other axioms too, much like facts.
For example: if carl is the son of bob and bob is the son of albert then carl must be the grandson of albert:
log grandparent(a: str): vec[str] = {
parent[*->X,a]: and
parent[*->Y,X]:
Y
}
Or: if bob is the son of albert and betty is the doughter of albert, then bob and betty must be syblings:
log are_syblings(a, b: str): vec[str] = {
parent[*->X,a]: and
parent[X->Y,b]:
Y
}
Same with uncle relationship:
var brothers: axi[str] = { {"bob":"bill"}, {"bill","bob"} };
log has_uncle(a: str): vec[str] = {
parent[*->Y,a]: and
brothers[Y,*->Z]:;
Z
}
Conditional facts
Here an example, the axioms hates will add a memeber romeo only if the relation x is satisfied:
var stabs: axi = {{"tybalt","mercutio","sword"}}
var hates: axi;
log romeHates(X: str): bol = {
stabs[X,"mercutio",_]:
}
hates+["romeo",X] if (romeHates(X));
Anonymous logicals
Conditional facts can be added with the help of anonymous logicals/rules:
eats+[x,"cheesburger"] if (eats[x,"bread"] and eats[X,"cheese"]);
eats+[x:"cheesburger"] if (log (a: str): bol = {
eats[a,"bread"]: and
eats[a,"cheese"]:
}(x));
Nested facts
var line: axi = { {{4,5},{4,8}}, {{8,5},{4,5}} }
log vertical(line: axi): bol = {
line[*->A,*->B]: and
A[*->X,Y*->]: and
B[X,*->Y2]:
}
log horizontal(line: axi): bol = {
line[*->A,*->B]: and
A[*->X,*->Y]: and
B[*->X2,Y]:
}
assert(vertical(line.at(0))
assert(horizontal(line.at(1))
Filtering
Another example of filtering a more complex axion:
var class: axi;
class.add({"cs340","spring",{"tue","thur"},{12,13},"john","coor_5"})
class.add({"cs340",winter,{"wed","fri"},{15,16},"bruce","coor_3"})
log instructor(class: str): vec[str] = {
class[class,_,[_,"fri"],_,*->X,_]
X
}
Construct Types
This section covers named type construction beyond the built-in type families.
The main construct surfaces are:
- aliases:
ali - record-like definitions
- entry-like definitions
These chapters describe how named data models are introduced, how members are declared, and how construct values are initialized and accessed.
Aliases
An alias declaration gives a new name to an existing type surface. The alias does not create a new object model. It gives the type a stable name that can be reused in declarations, signatures, and receiver-qualified routines.
There are two related forms:
- aliasing
- extending
Aliasing
typ[ali] I5: arr[int, 5];
Now code can use I5 instead of repeating arr[int, 5]:
var fiveIntegers: I5 = { 0, 1, 2, 3, 4 };
Another example is naming a constrained color component type:
typ[ali] rgb: int[8][.range(255)];
typ[ali] rgbSet: set[rgb, rgb, rgb];
Aliases are useful because:
- they avoid repeating long type expressions
- they give a type surface a meaningful name
- they provide a named receiver surface for receiver-qualified routines
Receiver-qualified routines remain procedural. If a value is written as
value.method(arg), read that as call-site sugar for method(value, arg).
The routine is still separate from the data declaration.
Standalone alias declarations
The standalone ali declaration is the short form of the same aliasing
surface:
ali Meters: int;
Like other declarations, an alias is package-visible by default. Exporting it
across package boundaries uses the same option surface as typ, fun, and
var:
ali[exp] Meters: int;
exp is the only option an alias declaration accepts.
Current milestone note:
- aliasing and extension over current
V1built-in and declared types are part of the present language surface - true foreign-type interop remains later work
- C ABI and Rust interop belong to the planned
V4milestone, not the current compiler contract
Extending
Extensions expose an existing type under an explicit receiver surface so that new receiver-qualified routines can be declared for it.
typ[ext] type: type;
This is still procedural. It does not turn the type into an object or create inheritance. It only allows routines to be written against that receiver surface.
For example, an explicit receiver surface for int:
typ[ext] int: int;
pro (int)print(): non = {
.echo(self)
}
pro main(): non = {
5.print()
}
The call 5.print() is still just receiver sugar for print(5).
An older version of this chapter used a string-to-vector extension containing
yield. That mixed future generator design into the current alias contract.
Language yield is parsed and retained for future work, but the current
typechecker and lowerer reject it; it is not part of V3. Alias and extension
support therefore does not imply generator support.
Structs
Structs are the way to declare new type of data. A struct binds an identifier, the type name, to a type.
A struct definition creates a new, distinct type and are few of them in FOL:
- records
- entries
Definition
Records
A record is an aggregate of data elements in which the individual elements are identified by names and types and accessed through offsets from the beginning of the structure. There is frequently a need in programs to model a collection of data in which the individual elements are not of the same type or size. For example, information about a college student might include name, student number, grade point average, and so forth. A data type for such a collection might use a character string for the name, an integer for the student number, a floating- point for the grade point average, and so forth. Records are designed for this kind of need.
It may appear that records and heterogeneous set are the same, but that is not the case. The elements of a heterogeneous set[] are all references to data values that may reside in scattered locations. The elements of a record are of potentially different sizes and reside in adjacent memory locations. Records are primarily data layouts.
typ user: rec = {
var username: str;
var email: str;
var sign_in_count: int[64];
var active: bol;
};
Records are data, not objects
typ ...: rec = { ... } declares a data type. FOL does not treat records as
objects with hidden behavior, inheritance, or class-owned method bodies. If a
record has operations associated with it, those operations are still declared
as ordinary receiver-qualified routines outside the record body.
typ computer: rec = {
brand: str;
memory: int
}
fun (computer)get_type(): str = {
return self.brand
}
var laptop: computer = { brand = "acme", memory = 16 }
.echo(laptop.get_type())
The call laptop.get_type() is procedural sugar for calling the receiver
routine with laptop as its first explicit input.
Current V1 backend/runtime note:
- backends may emit records and entries as plain target-language structs/enums
- that does not change the language model: they are still data plus ordinary receiver-qualified routines
- when runtime-visible formatting is needed, generated backends should preserve
the
fol-runtimeaggregate formatting contract instead of inventing a backend-specific display shape
Entries
Is an a group of constants (identified with ent) consisting of a set of named values called elements.
typ color: ent = {
var BLUE: str = "#0037cd"
var RED str = "#ff0000"
var BLACK str = "#000000"
var WHITE str = "#ffffff"
};
if( something == color.BLUE ) { doathing } else { donothing }
Entries as enums
Unums represent enumerated data. An enumeration type (or enum type) is a value type defined by a set of named constants of the underlying integral numeric type.
typ aUnion: ent = {
var BLUE, RED, BLACK, WHITE: int[8] = {..3}
}
Initializaion
To use a record after we’ve defined it, we create an instance of that record by specifying concrete values for each of the fields. We create an instance by stating the name of the record and then add curly brackets containing key: value pairs, where the keys are the names of the fields and the values are the data we want to store in those fields. We don’t have to specify the fields in the same order in which we declared them in the record. In other words, the record definition is like a general template for the type, and instances fill in that template with particular data to create values of the type.
@var user1: user = {
email = "someone@example.com",
username = "someusername123",
active = true,
sign_in_count = 1,
};
Named initialization:
@var[mut] user1: user = { email = "someone@example.com", username = "someusername123", active = true, sign_in_count = 1 }
Ordered initialization
@var[mut] user1: user = { "someone@example.com", "someusername123", true, 1 }
Accessing
To get a specific value from a record, use dot notation: if we wanted just
this user’s email address, we can use user1.email wherever we want that
value. If the instance is mutable, we can change a value by assigning into a
particular field. Note that the entire instance must be mutable; FOL doesn’t
allow us to mark only certain fields as mutable.
var user1: user = {
email = "someone@example.com",
username = "someusername123",
active = true,
sign_in_count = 1,
};
user1.email = "new.mail@example.com";
Current boundaries:
- assignment targets a single field of a mutable binding (
user1.email = ...); nested targets such asa.b.c = ...are not part of the current surface - bracket field access (
user1[email]) is a later design surface, not part of the current compiler contract
Returning
As with any expression, we can construct a new instance of the record as the last expression in the function body to implicitly return that new instance. As specified in function return, the final expression in the function will be used as return value. For this to be used, the return type of the function needs to be defined (here is defined as user) and this can be used only in one statement body. Here we have declared only one variable user1 and that itslef spanc into multi rows:
pro buildUser(email, username: str): user = { user1: user = {
email = "someone@example.com",
username = "someusername123",
active = true,
sign_in_count = 1,
} }
Nesting
Records can be nested by creating a record type using other record types as the type for the fields of record. Nesting one record within another can be a useful way to model more complex structures:
var empl1: employee = {
FirstName = "Mark",
LastName = "Jones",
Email = "mark@gmail.com",
Age = 25,
MonthlySalary = {
Basic = 15000.00,
Bonus = {
HTA = 2100.00,
RA = 5000.00,
},
},
}
Defauling
Records can have default values in their fields too.
typ user: rec = {
var username: str;
var email: str;
var sign_in_count: int[64] = 1;
var active: bol = true;
};
This makes possible to enforce some fields (empty ones), and leave the defaults untouched:
@var[mut] user1: user = { email = "someone@example.com", username = "someusername123" }
Limiting
We can also restrict the values (with ranges) assigned to each field:
typ rgb: rec[] = {
var r: int[8][.range(255)];
var g: int[8][.range(255)];
var b: int[8][.range(255)];
}
var mint: rgb = { 153, 255, 187 }
This of course can be achieve just with variable types and aliased types and sets too, but we would need to create two types:
typ rgb: set[int[8][.range(255)], int[8][.range(255)], int[8][.range(255)]];
var mint: rgb = { 153, 255, 187 }
Methods
A record may have receiver-qualified routines associated with it. This does not turn the record into an object model. It only means a routine may use dot-call syntax when its first explicit input is a value of that record type. To create such a routine for a record, declare the receiver type on the routine itself:
fun (recieverRecord)someFunction(): str = { self.somestring; };
After declaring the record receiver, the routine body may refer to that input
through self. A receiver is simply the explicit first input that enables
dot-call syntax. The data and the routine remain separate declarations.
typ user: rec = {
var username: str;
var email: str;
var sign_in_count: int[64];
var active: bol;
};
fun (user)getName(): str = { result = self.username; };
Receiver-qualified routines have one main ergonomic benefit over plain routine
calls: they let the call site read value.method(...). In the same package,
multiple routines may still share the same method name if the receiver types
are different.
Each record value can therefore use the dot form, but the underlying model remains procedural:
user1.getName()
should be read as:
getName(user1)
There is no hidden record-owned method table in that spelling.
The routine itself is still declared separately:
var[mut] user1: user = { email = "someone@example.com", username = "someusername123", active = true, sign_in_count = 1 }
.echo(user1.getName());
Standards
This chapter now has a split status:
- one narrow
V2Milestone 2 subset is implemented - the broader standards design is still future
V2
Current implemented Milestone 2 subset:
- protocol standards declared with
std name: pro = { ... } - required receiver-qualified routine signatures only
- type-side conformance claims through the existing contract-header shape
- exact routine-signature matching for conformance
- current hardened example set for that subset is:
examples/standards_protocol_m2examples/standards_protocol_pair_m2examples/standards_protocol_multi_m2examples/fail_standard_blueprint_m2examples/fail_standard_as_type_m2examples/fail_standard_missing_routine_m2examples/fail_standard_signature_m2examples/fail_standard_import_ambiguity_m2
Still future:
- blueprint standards as real semantic contracts
- extended standards as real semantic contracts
- required data-member conformance
- standards as ordinary concrete types
- dispatch/inference driven by standards
- object-style method semantics
Part of the current shipped full-V2 contract:
- standards-as-constraints through protocol standards
- procedural constrained-generic call binding
- static conformance checking for those constraints
- calling a constraint’s required routines on the constrained generic
parameter itself:
fun measure(T: sized)(thing: T): int = { return thing.size(); };monomorphizes per instantiation, dispatching to each conformer’s own receiver routine (or the standard’s default body) at compile time - blueprint standards (
std X: blu) as static field contracts - extended standards (
std X: ext) combining required routines and fields
The intent of standards is procedural and data-oriented. They are not class hierarchies, inheritance trees, or object systems.
Standard
In later milestones, a standard is intended to be a named collection of
required receiver-qualified routine signatures and/or required data, created
with std.
std geometry: pro = {
fun area(): flt[64];
fun perim(): flt[64];
};
The shipped forms are:
- protocol
pro[]for required routines - blueprint
blu[]for required data - extended
ext[]for routines plus data
std geometry: pro = {
fun area(): flt[64];
fun perim(): flt[64];
};
std geometry: blu = {
var color: rgb;
var size: int;
};
std geometry: ext = {
fun area(): flt[64];
fun perim(): flt[64];
var color: rgb;
var size: int;
};
Contract
Current Milestone 2 support allows a type to declare that it satisfies a protocol standard and requires matching receiver-qualified routines.
Current implemented shape:
std geo: pro = {
fun area(): int;
};
typ Rect()(geo): rec = {
var width: int;
};
fun (Rect)area(): int = {
return 1;
};
This subset is intentionally narrow:
- only
prois semantic today - only required routines are semantic today
- richer requirement forms such as generic, receiver-qualified, and capturing standard requirements remain unsupported
- the type claim is checked procedurally
- lowering preserves protocol-standard and conformance metadata for audits and procedural backend lowering
- backend execution works for the checked-in positive protocol examples through ordinary receiver-qualified routine calls, not through a runtime object model
- editor hardening now covers contract-header hover/definition on checked-in standards examples while keeping broader required-routine hover support out of the claimed contract
The broader design remains future work. Later milestones may allow a type to declare that it satisfies richer standards and check required data and receiver-qualified routines together.
std geo: pro = {
fun area(): flt[64];
fun perim(): flt[64];
};
std rect(geo): rec[] = {
width: int[64];
heigh: int[64];
}
Under that design, rect would need matching receiver-qualified routines such
as:
fun (rect)area(): flt[64] = { result = self.width + self.heigh }
fun (rect)perim(): flt[64] = { result = 2 * self.width + 2 * self.heigh }
The goal is still procedural. A call like shape.area() remains sugar for a
receiver-qualified routine call, not an object-owned virtual method.
For the current full V2 target, broader dispatch and inference semantics stay
outside the shipped contract.
Generics
This chapter describes V2 generic-language work rather than current V1
compiler behavior.
Current milestone note:
- Milestone 1 now includes a narrow generic-routine subset through:
- parser
- resolver
- typecheck
- editor audit
- that current subset supports:
- generic routine declarations
- type parameters
- generic parameter references in parameter and return types
- direct calls with narrow argument-driven inference
- current hardened example set for that subset is:
examples/generic_routine_m1examples/generic_routine_pair_m1examples/generic_routine_cross_file_m1examples/generic_receiver_m1examples/generic_receiver_cross_file_m1examples/generic_receiver_overload_m1m2examples/generic_type_semantic_m1m2- positive semantic-check fixture for generic type declarations
examples/generic_type_exec_m1m2examples/generic_standard_constraint_m1m2examples/generic_standard_constraint_generic_m1m2examples/fail_generic_misuse_m1examples/fail_generic_cross_file_m1examples/fail_generic_receiver_m1examples/fail_generic_standard_constraint_m1m2
- generic routine lowering now succeeds for the shipped Milestone 1 example set
- generic routine backend execution now works for the shipped positive Milestone 1 examples
- ownership at a generic call boundary follows the concrete argument: clone-safe arguments remain available to the caller, while owned values and unique pointers move. Inside a routine, an unconstrained generic parameter is conservatively move-only because the routine cannot prove that every future instantiation is clone-safe; forwarding it therefore moves rather than deep-cloning a unique value
- an unresolved generic parameter cannot cross
[>]or| asyncuntil FOL defines a thread-safety and lifetime contract for generics; calls whose arguments infer concrete thread-safe types remain valid - receiver-qualified generic routines, matching default arguments, and concrete instantiated generic-type receivers and concrete recoverable error types are now part of the executable Milestone 1 subset
- generic receiver routines are now current contract: a routine such as
fun (Box[T])get(T)(): Tdeclares its generic parameters explicitly, binds them through the receiver and ordinary arguments at each call site, and monomorphizes into one concrete routine per instantiation - receiver-qualified routine bodies read receiver state through
self, which is typed as the receiver and lowered as the routine’s first argument - generic type execution and constrained generic execution now have checked-in example packages too
- nested generic-type composition now works for the checked nested-record
subset such as
Box[Box[int]] - generic type instantiations carry nominal identity:
Box[int]andCup[int]are distinct types even with an identical field shape, so a method shared by name dispatches to each base’s own receiver routine rather than being rejected as ambiguous;examples/generic_receiver_overload_m1m2pins this - broader Milestone 1 edge-case policy is still tracked separately from that shipped positive core
- current editor hardening covers hover/definition on checked-in generic examples without claiming broader generic-aware completion than the shipped editor currently provides
- generic error shells remain outside the current shipped Milestone 1 routine subset
- recursive value edges remain rejected because they have no finite inline
layout. V3 owned heap recursion is now supported: an edge such as
opt @Nodelowers nominally toOption<Box<Node>>. Recursive generic instantiation through an inline container remains outside the shipped generic subset, pinned byexamples/fail_generic_recursive_m1m2 - generic types are not part of
V1; they now belong to the shipped narrow full-V2contract instead - examples here should be read as:
- current narrow
V2Milestone 1 generic-routine work where noted - otherwise later
V2design
- current narrow
Types
Generic Functions
Generic programming aims to express one routine over a family of concrete types, with the requirements written explicitly in the signature.
pro max[T: gen](a, b: T): T = {
result = a | a < b | b;
};
fun biggerFloat(a, b: flt[32]): flt[32] = { max(a, b) }
fun biggerInteger(a, b: int[64]): int[64] = { max(a, b) }
Generic Types
Full V2 now includes generic type declarations and explicit instantiation.
Chosen contract:
- the canonical declaration surface follows the existing parser-owned shape
- generic records and generic aliases are in scope for full
V2 - generic arguments are explicit and arity-checked
- generic types use the same monomorphization-oriented execution strategy as generic routines
Example declaration shapes:
typ Box(T: item): rec = {
value: T;
};
typ Pair(T: left, U: right): map[T, U];
Generic Calls
Full V2 includes standards-as-constraints, but not broad dispatch semantics.
Chosen constraint contract:
- protocol standards are the only generic-constraint surface in the full
V2target - a constraint may name a generic standard at concrete arguments
(
fun drive(T: Holder[int])(...)): the standard’s own generic parameters are bound to those arguments, so a constraint call substitutes them (a requiredfun fetch(): ItemonHolder[int]types asint), and the conformer must claim the standard at the same arguments;examples/generic_standard_constraint_generic_m1m2pins this - constrained generic calls remain procedural and are checked through declared conformance
- standards-as-constraints do not imply runtime object dispatch
This chapter still does not define an object-dispatch system. If later work uses receiver-qualified routine syntax together with constraints, that remains a procedural call-binding rule unless the contract changes explicitly.
std foo: pro = { fun bar(); }
typ[ext] int, str: int, str;
fun (int)bar() = { }
fun (str)bar() = { }
pro callBar(T: foo)(value: T) = { value.bar() } // dispatch with generics
pro barCall( value: foo ) = { value.bar() } // dispatch with standards
pro main: int = {
callBar(2);
callBar("go");
barCall(2);
barCall("go")
}
Modules And Source Layout
This section defines how FOL source is organized across files, folders, packages, imports, and named module-like declarations.
It covers:
- imports through
use - namespaces and package layout
- block-like named definitions
- test-oriented module surfaces
At a high level:
- files in the same package contribute to the same package surface
- namespaces are expressed through folder structure and
::access - imported sources use the public source kinds
locandpkg - bundled std is reached through a declared internal dependency and a
pkgimport, not through a third public source kind
Imports
An import declaration states that the source file containing the declaration depends on functionality of the imported package and enables access to exported identifiers of that package.
Syntax to import a library is:
use alias: source_kind = {"source"}
Current source kinds are:
locfor local directory importspkgfor formal dependency packages, including installed/fetched packages and a declared bundled-standard alias
What use imports
use works against the source-layout model:
- the folder root is the package
- subfolders are namespaces inside that package
- a file is only a source file, not a separate module by itself
So a use target is normally:
- a whole package
- or one namespace inside that package
use does not mean “import another file from the same folder”.
Files in the same package already share package scope.
Imports are for reaching another package or another namespace boundary.
Also note:
usebrings in exported functionalityhiddeclarations remain file-only even inside the same package- importing a package does not erase the original file boundary rules
useis for consuming functionality, not for defining package dependencies
Import kinds
loc
loc is the simplest import kind:
- it points to a local directory
- that directory is scanned as a FOL package / namespace tree
- no
build.folis required
This makes loc useful for local workspace code, experiments, and monorepo-style sharing.
pkg
pkg is for formal dependency packages. The dependency may come from an
installed/fetched external package or from the explicitly declared internal
standard target; source syntax does not gain a separate std source kind.
Unlike loc, a pkg import does not just point at an arbitrary source directory.
It points at an installed package root that must define its identity and build surface explicitly.
The package layer discovers that root first, and ordinary name resolution happens only after the package has been prepared.
For a pkg package root:
build.folis requiredbuild.folstores package metadata, direct dependencies, and build logic
Package Metadata And Build Files
Formal packages use one control file at the root:
build.fol
build.fol
build.fol is the package build entry file.
This file is responsible for:
- declaring package metadata
- declaring direct dependencies
- declaring package build logic
- declaring artifacts, steps, and generated outputs through the build API
- becoming the canonical package entrypoint for
fol code build/run/test/check
build.fol is still an ordinary FOL file.
It is parsed with the same front-end as other .fol sources.
The difference is that the package layer evaluates one canonical build routine inside it.
Today that means:
fol code build/run/test/checkstarts frombuild.fol- the canonical entry is
pro[] build(): non - old
def root: loc = ...anddef build(...)forms are not the build model
So:
defis still a general FOL declaration formbuild.folis not a separate mini-languagebuild.foluses an ordinary routine entrypoint, like Zig’sbuild.zig
That means:
- ordinary source
.folfiles useuseto consume packages/namespaces build.folusespro[] build(): nonplus.build()to configure package metadata, direct dependencies, and the build graph
So use and the build routine serve different jobs:
use= consume functionalitypro[] build()inbuild.fol= define package/build surface
Standard library
Bundled std is reached through the dependency system. In build.fol, add:
build.add_dep({
alias = "std",
source = "internal",
target = "standard",
});
This dependency is valid for a memo artifact and exposes the hosted API tier.
It is not a third artifact model: std is not an accepted fol_model value.
It is also not an execution switch. A core or unhosted memo executable can
use fol code run and fol code test without declaring this dependency;
declare it only when source code needs a shipped hosted API.
Then import from the std dependency alias with pkg:
use std: pkg = {"std"};
fun[] main(): int = {
return std::fmt::answer();
};
To use only one namespace of fmt:
use std: pkg = {"std"};
fun[] main(): int = {
return std::fmt::math::answer();
};
Using the bundled std.io bootstrap surface:
use std: pkg = {"std"};
fun[] main(): int = {
var shown: str = std::io::echo_str("hello");
return 7;
};
Local libraries
To include a local package or namespace, point loc at the directory:
use bend: loc = {"../folder/bender"};
Then to acces only a namespace:
use space: loc = {"../folder/bender/space"};
That second form is namespace import, not “single file import”.
If space contains multiple .fol files in the same folder, they still belong to the same imported namespace.
loc does not require build.fol.
But if the target directory already defines build.fol at its root, that directory is treated as a formal package root and should be imported through pkg, not loc.
External packages
External packages are imported through their declared pkg dependency alias:
use space: pkg = {"space"};
Nested installed package paths use the same quoted target rule:
use nested: pkg = {"other/package/nested"};
Old unquoted targets are invalid and should fail in the parser:
use std: pkg = {std};
pkg imports are different from loc:
- the imported root is an installed package root
- that root must contain
build.fol build.folis the package control file and currently defines package metadata, dependencies, exports, and build declarations that package loading depends on- raw transport URLs do not appear in source code; package acquisition and installed-package preparation are separate from ordinary source resolution
Declarations
Each .fol file inside a folder is part of the same package.
The important part is that files are connected, but not merged:
- every physical
.folfile is still its own source file - files in the same folder share one package scope
- declarations are order independent across those files
- a declaration started in one file can not continue into the next file
There is no need to import sibling files from the same package. They already belong to the same package scope.
Package, Namespace, And File Scope
It helps to think about source layout in three layers:
- package scope: all
.folfiles directly inside the package folder - namespace scope: declarations inside subfolders of that package
- file scope: declarations marked
hid, which stay visible only inside their own file
In short:
- same folder = same package
- subfolder = nested namespace
hid= file only- files are never imported directly as standalone modules
Package scope
Files that live directly in the package root share one package scope:
root/
math/
add.fol
sub.fol
Both add.fol and sub.fol belong to package math, so declarations from one file may be used by the other without importing the sibling file.
Namespace scope
Subfolders do not create a new package by themselves. They create nested namespaces inside the same package:
root/
math/
add.fol
stats/
mean.fol
Here:
add.folis in package namespacemathmean.folis in package namespacemath::stats
Code may reach namespace members either by:
- direct
use - or qualified access with
::
File scope
Sometimes a declaration should stay inside one file even though the package is shared.
That is what hid is for.
// file1.fol
var[hid] cache_key: str = "local"
// file2.fol
pro[] main: int = {
.echo(cache_key) // error: hidden declarations are file-only
}
Namespaces
A namespace can be defined in a subfolder of the main folder, and namespaces can be nested.
To acces the namespace there are two ways:
- direct import with
use - or code access with
::
Direct import
use aNS: loc = {""home/folder/printing/logg""}
pro[] main: int = {
logg.warn("something")
}
Code access
use aNS: loc = {""home/folder/printing""}
pro[] main: int = {
printing::logg.warn("something")
}
Mental model
For source layout, the mental model is:
- one folder root gives one package
- each file in that folder is a real source file in that package
- subfolders extend the namespace path
useimports packages or namespaceshidkeeps a declaration private to one filelocimports a local directory tree without a package control filepkgimports a formal external package defined bybuild.fol- bundled std is declared as an internal dependency and imported through
pkg
This means FOL is not “one file = one module”. The package is the folder; the file is a source unit inside that package.
Package Roots
When a directory is treated as a package root, the exact contract depends on the import kind:
loc: plain local directory import, no package metadata requiredpkg: installed external package import with explicit root files
For pkg, the root is not just “a folder containing .fol files”.
It is a formal package root with:
build.folas the package control file
This keeps the language model clean:
- source files
useother namespaces/packages - package build execution starts from
pro[] build(): noninbuild.fol - package metadata and direct dependencies are configured through
.build()insidebuild.fol - package loading happens before ordinary name resolution
build.fol itself is still ordinary FOL syntax.
It is not a separate mini-language.
The package layer simply gives package/build meaning to the canonical build
routine there.
Blocks
Current boundary:
- the namespace, package-scope, and import material above is the current compiler surface
- the block forms in this section (
_{},.{}, namedblk[]blocks, and unconditional jump) are not implemented; they are later design work, not part of the current compiler surface
Block statement is used for scopes where members get destroyed when scope is finished. And there are two ways to define a block:
- unnamed blocks and
- named blocks
Unnamed blocks
Are simply scopes, that may or may not return value, and are represented as: { //block }, with . before the brackets for return types and _ for non return types:
pro[] main: int = {
_{
.echo("simple type block")
}
.echo(.{ return "return type block" })
}
Named blocks
Blocks can be used as labels too, when we want to unconditionally jump to a specific part of the code.
pro[] main: int = {
def block: blk[] = { // $block A named block that can be referenced
// implementation
}
def mark: blk[] // $mark A named block that can be referenced, usually for "jump" statements
}
Tests
Current boundary:
def ... : tst[...]test blocks are not implemented in the current compiler surface- everything below describes intended design, not current behavior
Blocks defined with type tst, have access to the module (or namespace) defined in tst["name", access].
def test1: tst["sometest", shko] = {}
def "some unit testing": tst[shko] = {}
Error Handling
FOL does not center error handling around exceptions.
This section distinguishes two broad error categories:
- breaking errors:
unrecoverable failures, typically associated with
panic - recoverable errors:
errors that can be propagated or handled, typically associated with
report
The detailed chapters explain:
- how each category behaves
- how routines expose recoverable error types
- how error-aware forms interact with control flow and pipes
- how the current compiler reports syntax, package, and resolver failures
Current compiler diagnostics
The current compiler surface guarantees the following reporting behaviors across the active parser/package/resolver/typecheck/lower/backend chain:
- every diagnostic carries a stable producer-owned code (e.g.
R1003); the defaulthumanoutput shows it next to a family chip, and--output plainshows it in brackets (error[R1003]:) - any code can be expanded with
fol code explain <CODE> - all failures keep exact primary
file:line:columnlocations - human-readable diagnostics render source snippets and underline the primary span
- related sites such as duplicate declarations or ambiguity candidates appear as secondary labels
- JSON diagnostics preserve the same structured information with labels, notes, helps, and stable producer-owned diagnostic codes
- the parser recovers after failed declarations instead of cascading errors
- exact consecutive diagnostic duplicates are suppressed in compiler reports, with a hard cap at 50
- LSP publishing removes only exact wire-identical duplicates; diagnostics that merely share a line and code remain distinct
The exact wording of messages is still implementation detail, but the current compiler contract is that locations, codes, and structured diagnostic shape are stable enough to build tests and tooling around them.
For the detailed compiler-facing reporting model, see Compiler Diagnostics.
Breaking errors
panic keyword allows a program to terminate immediately and provide feedback to the caller of the program. It should be used when a program reaches an unrecoverable state. This most commonly occurs when a bug of some kind has been detected and it’s not clear to the programmer how to handle the error.
pro main(): int = {
panic "Hello";
.echo("End of main"); //unreachable statement
}
In the above example, the program will terminate immediately when it encounters the panic keyword.
Output:
main.fol:3
routine 'main' panicked at 'Hello'
-------
Trying to acces an out of bound element of array:
pro main(): int = {
var a: arr[int, 3] = {10,20,30};
a[10]; //invokes a panic since index 10 cannot be reached
}
Output:
main.fol:4
routine 'main' panicked at 'index out of bounds: the len is 3 but the index is 10'
-------
a[10];
^-------- index out of bounds: the len is 3 but the index is 10
A program can invoke panic if business rules are violated, for example: if the value assigned to the variable is odd it throws an error:
pro main(): int = {
var no = 13;
//try with odd and even
if (no % 2 == 0) {
.echo("Thank you , number is even");
} else {
panic "NOT_AN_EVEN";
}
.echo("End of main");
}
Output:
main.fol:9
routine 'main' panicked at 'NOT_AN_EVEN'
-------
Recoverable errors
Recoverable errors are part of the current V1 language contract, but they are
split into two different surfaces:
T / Efor routine-call handlingerr[...]for normal storable values
Those are intentionally not the same thing.
T / E is immediate call-site handling
Use / ErrorType after the success type:
fun read_code(path: str): int / str = {
when(path == "") {
case(true) { report "missing path" }
* { return 7 }
}
}
This means:
- success yields
int report exprexits through the routine error path withstr- the call result is not a storable plain value
report expr must match the declared error type:
fun read_code(path: str): int / str = {
report "missing path"
}
The following is invalid because the reported value is the wrong type:
fun read_code(path: str): int / str = {
report 1
}
No plain propagation
In current V1, / ErrorType routine results do not flow through ordinary
expressions.
These are rejected:
read_code(path)
var value = read_code(path)
return read_code(path)
consume(read_code(path))
read_code(path) + 1
/ ErrorType must be handled immediately at the call site. Evaluating a
recoverable call as a standalone statement does not count as handling it.
The same rule applies after V3 | async: a recoverable eventual must be
awaited, and the awaited result must be consumed by check(...) or ||.
Moving the eventual to another binding moves that obligation; dropping,
overwriting, or leaving the final binding at scope exit is rejected. An
infallible eventual has no recoverable error obligation and may remain
unawaited, although it is still joined at process exit.
The obligation must be discharged before lexical fallthrough and before a
break, return, or report exits any scope that still owns it. At a branch
join, all continuing branches must leave compatible obligation state: all may
preserve the same owner for handling after the join, transfer consistently, or
await and handle it. Consuming or transferring on only some paths is rejected.
Process-exit joining waits for work; it does not count as handling a recoverable
error.
check(...)
check(expr) asks whether a / ErrorType expression failed. The expression may
be a direct recoverable routine call or a V3 awaited recoverable eventual.
It returns bol.
fun main(path: str): bol = {
return check(read_code(path))
}
check(...) works on direct recoverable routine calls and on expressions such
as pending | await when pending carries a recoverable result. It does not
inspect err[...] shell values.
||
expr || fallback handles a / ErrorType expression immediately. That may be
a direct recoverable routine call or a V3 awaited recoverable eventual.
Rules:
- if
exprsucceeds, use its success value - if
exprfails, evaluatefallback fallbackmay:- provide a default value
reportpanic
Examples:
fun with_default(path: str): int = {
return read_code(path) || 0
}
fun with_context(path: str): int / str = {
return read_code(path) || report "read failed"
}
fun must_succeed(path: str): int = {
return read_code(path) || panic "read failed"
}
err[...] is the storable error form
err[...] is a normal value type. It is a nil-able shell: nil is the
no-error (success) state and a present value carries a stored error payload.
return nil produces the no-error state; return payload stores an error.
You may store it, pass it, return it, and unwrap it later:
ali Failure: err[str]
fun keep(value: Failure): Failure = {
return value
}
fun unwrap(value: Failure): str = {
return [uwp]value
}
Because it is nil-able, an err[T] scrutinee also drives when ... on ... *:
on(payload) binds the stored error and * takes the nil (no-error) branch.
Postfix [uwp]value and inner-place value[] unwrap the stored error and panic on
nil.
This is different from:
fun read_code(path: str): int / str = { ... }
A call to read_code(...) is not an err[str] value. If you need a storable
error container, use err[...]. If you use / ErrorType, handle it with
check(...) or ||.
Current V1 boundary
The current compiler supports:
- declared routine error types with
/ report exprcheck(expr)expr || fallbackerr[...]shell/value behavior
The current compiler rejects:
- plain assignment of
/ ErrorTypecall results - direct returns of
/ ErrorTypecall results - implicit conversion from
/ ErrorTypeintoerr[...] - postfix
!on/ ErrorTyperoutine calls
For backend work:
/ ErrorTyperoutine calls lower through the recoverable runtime ABIerr[...]remains a separate shell/value runtime type- a recoverable executable entry is adapted through the shared backend-only
fol_runtime::processseam in every runtime model; it does not require bundledstd
That process adapter translates the entry outcome for the generated host
wrapper. It is not importable source API, does not promote core or memo to
the hosted tier, and is present for execution support regardless of whether the
program declares bundled std.
Those two categories are intentionally not merged.
Compiler Diagnostics
This chapter is about compiler reporting, not about language-level panic or
report semantics.
In other words:
panicandreportdescribe what your program does- diagnostics describe what the compiler tells you when it cannot continue or when it wants to surface important information
Why this chapter exists
FOL now has a real compiler pipeline:
fol-streamfol-lexerfol-parserfol-packagefol-resolverfol-typecheckfol-lowerfol-runtimefol-backendfol-diagnostics
That means errors are no longer just loose strings printed from one place. Compiler failures now move through a shared diagnostics layer with stable structure.
This matters for three reasons:
- humans need readable compiler output
- tests need stable enough structure to assert against
- future tools need a machine-readable format that is not just a copy of the human renderer
What a diagnostic contains
At the current compiler stage, a diagnostic can carry:
- severity
- main message
- a stable diagnostic code (e.g.
P1001,R1003,T1003,O1001) - one primary location
- zero or more related locations
- notes
- helps
- suggestions
The current compiler mostly emits Error, but the reporting layer also supports
Warning and Info.
Diagnostic codes
Every diagnostic carries a stable producer-owned code. The code identifies the error family and specific failure without relying on message text.
Current code families:
| Prefix | Producer | Examples |
|---|---|---|
P1xxx | parser | P1001 syntax, P1002 file root |
K1xxx | package loading | K1001 metadata, K1002 layout |
R1xxx | resolver | R1003 unresolved, R1005 ambiguous |
T1xxx | type checker | T1003 type mismatch |
O1xxx | ownership checker | O1001 move, task, or resource-state violation |
O2xxx | lexical borrow checker | O2001 owner borrowed, O2002 conflict, O2003 mutability, O2004 returned borrow reused |
L1xxx | lowering | L1001 unsupported surface |
F1xxx | frontend | F1001 invalid input, F1002 workspace not found |
K11xx | build evaluator | K1101 build failure |
Codes are structurally assigned. The parser carries an explicit ParseErrorKind
field on each error rather than deriving the code from message text. This means
message wording can change without breaking code identity.
The default human output shows the code after the message, alongside a
plain-language family chip (e.g. NAMES ... R1003). The plain output shows it
in brackets:
error[R1003]: could not resolve name 'answer'
Any code can be expanded on demand with fol code explain <CODE> (see
Tool Commands).
JSON output includes the code as a top-level field:
{ "code": "R1003", "message": "could not resolve name 'answer'" }
Primary location
The most important part of a diagnostic is its primary location.
That location is currently expressed as:
- file
- line
- column
- optional span length
This is what allows the compiler to point at the exact token or source span that caused the failure.
Every diagnostic now carries a real location. Parser errors that previously lacked locations (safety-bound overflows, constraint violations like duplicate parameter names) now extract file/line/column from the current token position.
Typical examples:
- a parser error at the token that made a declaration invalid
- a package-loading error at the control file or package root that failed
- a resolver error at the unresolved identifier or ambiguous reference
- a typecheck error at the expression or declaration whose types do not match
- an ownership error at the transfer, borrow, task, channel, or delayed-effect boundary that made the value inaccessible
- a lowering error at a typed surface with no current lowering rule
Related locations
Some compiler failures are not well described by one location alone.
For example:
- duplicate declarations
- ambiguous references
- duplicate package metadata fields
In those cases the compiler keeps one primary site and can also attach related sites as secondary labels.
That allows the compiler to say things like:
- this declaration conflicts with an earlier declaration
- this name could refer to either of these two candidates
- this metadata field was already defined elsewhere
Notes, helps, and suggestions
FOL diagnostics separate extra guidance into different buckets instead of forcing everything into one long message.
The current contract is:
- the main message says what went wrong
- notes add technical context
- helps add actionable guidance
- suggestions describe a possible replacement or next step when the producer can express one
This split matters because tooling and tests can preserve structure instead of trying to parse intent back out of prose.
Error recovery
The parser implements error recovery so that a single syntax mistake does not cascade into dozens of unrelated errors.
When a declaration parse fails, the parser calls sync_to_next_declaration to
skip forward to the next declaration-start keyword (fun, var, def, typ,
pro, log, seg, ali, lab, con, use) or EOF. This means:
fun[exp] emit(...) = { ... }produces exactly 1 error, not 20+- two broken declarations separated by a good one produce 2 errors, and the good declaration still parses correctly
Cascade suppression
Even with parser recovery, edge cases in any pipeline stage can cascade.
The diagnostic report layer applies two safety nets:
- exact consecutive dedup: if the most recently added diagnostic is fully identical to a new one, the new one is suppressed; a shared line/code alone is not enough, so distinct ranges, messages, labels, and related sites remain visible
- hard cap: the report accepts at most 50 diagnostics total and shows “(output truncated)” when the limit is reached
These limits prevent walls of identical errors without hiding genuinely distinct failures.
Human-readable diagnostics
The CLI has two human-facing output modes, selected with the global --output
flag. Both render from the same structured diagnostic model.
human (default)
The default renderer prints a framed, colored report:
- a family chip that names the problem in plain language (
PARSER,NAMES,TYPES,OWNERSHIP,LOWERING,PACKAGE,BUILD,BACKEND), the bold message, and the diagnostic code - a framed source snippet (
┌─ file:line:column) with the offending line and a caret under the primary span - secondary labels for related sites
= note:,= help:, and= try:lines- a footer with a one-line plain-language hint and a
run `fol code explain <CODE>` for morepointer - a closing
found N error(s)summary
Illustrative shape (colors omitted):
NAMES could not resolve name 'answer' R1003
┌─ app/main.fol:3:12
│
3 │ return answer
│ ^^^^^^ unresolved name
= note: no visible declaration with that name was found in the current scope chain
= help: check imports or declare the name before use
a name or import problem — a symbol could not be resolved · run `fol code explain R1003` for more
found 1 error
Colors disable themselves automatically when stdout is not a terminal, so piped and CI output stays plain text.
plain
--output plain prints the older, unstyled human format:
- a severity prefix with the diagnostic code in brackets (e.g.
error[R1003]:) - an arrow line with
file:line:column - a source snippet with an underline for the primary span
note:/help:lines and related-label summaries
Illustrative shape:
error[R1003]: could not resolve name 'answer'
--> app/main.fol:3:12
|
3 | return answer
| ^^^^^^ unresolved name
note: no visible declaration with that name was found in the current scope chain
help: check imports or declare the name before use
In both modes the messages are clean human-readable text. The compiler does not
prepend internal kind labels like ResolverUnresolvedName: to messages; the
diagnostic code is the stable identifier.
Source fallbacks
Sometimes the compiler knows the location but cannot render the source line itself.
Examples:
- the file is no longer readable
- the file path is missing
- the requested line is outside the current file contents
In those cases the compiler still keeps the location and falls back cleanly instead of crashing the renderer.
So the priority order is:
- exact location
- source snippet when available
- explicit fallback note when the snippet cannot be shown
JSON diagnostics
When the CLI is invoked with --json, diagnostics are emitted as structured
JSON instead of human-readable text.
This output is meant for scripts, tests, editor tooling, and future integration layers.
Important rule:
- JSON is not a lossy summary of human output
Instead, both human and JSON outputs are generated from the same structured diagnostic model.
The editor/LSP layer should follow that same rule too: editor diagnostics should be adapted from the shared structured diagnostic model rather than rebuilt from free-form strings.
That means JSON can preserve:
- severity
- code
- message
- primary location
- related labels
- notes
- helps
- suggestions
Illustrative shape:
{
"severity": "Error",
"code": "R1003",
"message": "could not resolve name 'answer'",
"location": {
"file": "app/main.fol",
"line": 3,
"column": 12,
"length": 6
},
"labels": [
{
"kind": "Primary",
"message": "unresolved name",
"location": {
"file": "app/main.fol",
"line": 3,
"column": 12,
"length": 6
}
}
],
"notes": [
"no visible declaration with that name was found in the current scope chain"
],
"helps": [
"check imports or declare the name before use"
],
"suggestions": []
}
Again, the exact payload can evolve, but the important guarantee is that the structured fields are first-class rather than reverse-engineered from text.
Which compiler phases currently participate
At head, the main producers that lower into the shared diagnostics layer are:
- parser
- package loading
- resolver
- type checking
- lowering
- build evaluator
- backend
- frontend (workspace discovery and input validation)
That means diagnostics are already strong across:
- syntax errors (with error recovery so cascades are contained)
- package metadata and package-root errors
- import-loading failures
- unresolved names
- duplicate names
- ambiguous references
- type mismatches and unsupported capability surfaces in the shipped V1, V2, and V3 contracts
- V2 generic/protocol conformance failures for the checked-in shipped subset
- V3 move/resource-state failures (
O1xxx) and lexical borrow failures (O2xxx) - V3 processor tier, direct-task-target, channel lifecycle, thread-boundary, eventual must-handle, and deferred mutex-effect failures
- unsupported lowered surfaces before target emission
- backend emission and build failures when lowered workspaces cannot become runnable artifacts
- build graph evaluation failures
Runtime-capability and execution-routing failures remain distinct. Using
heap-backed values from core, or hosted APIs without a memo artifact and its
declared bundled std, is a compiler-owned capability diagnostic. Trying to
run or test a foreign selected target is a frontend target-compatibility
diagnostic. Merely running or testing a host-compatible core or memo
artifact is not a missing-std error.
This is the important boundary for the current compiler stage:
- the compiler can parse, resolve, type-check, lower, and execute the supported V1 contract, the explicitly shipped V2 subset, and both shipped V3 pillars
- diagnostics already cover failures from each of those stages plus backend emission/build failures
- current hard boundaries are diagnosed rather than silently accepted: for
example unique-pointer field dereference without place-aware projection IR,
moved-owner deferred reinitialization, terminating
reportinside deferred cleanup, indirect spawn/async targets, unhandled recoverable eventuals, channel endpoint lifecycle violations, and deferred mutex guard effects - later targets, optimizations, C ABI work, and Rust interop remain outside the current V1/V2/V3 contract
V3 diagnostic completeness is inventory-driven. Every checked-in fail_mem_*
and fail_proc_* package must appear in the shared
test/v3_example_inventory.rs table with its expected producer code, message
fragment, and related-site requirement. Compiler integration and LSP tests
consume that same table, so the editor cannot silently weaken a boundary or
replace a structured compiler failure with an editor-only guess. The published
directory lists live in the memory and processor section indexes.
What diagnostics do not guarantee
Diagnostics are strong, but a structured error family is not a promise that every future language design is implemented.
Current limits still matter:
- parser diagnostics do not imply type checking has happened
- V2 diagnostics cover only the explicitly shipped generic/protocol subset, not every standards, blueprint, or generic design in the book
- V3 ownership diagnostics do not imply place-aware partial moves, general thread-safety contracts for unconstrained generics, or a flow-sensitive/NLL borrow checker
- V3 processor diagnostics do not imply indirect task dispatch, nameable eventual types, arbitrary channel composites, deferred mutex guard effects, cancellation, or an async runtime
- coercion and conversion diagnostics only cover conversions the current compiler actually implements
- C ABI diagnostics are future
V4package/type/backend work - Rust interop diagnostics are future
V4package/type/backend work
So the current guarantee is:
- if stream, lexer, parser, package loading, resolver, typechecker (including ownership/borrowing), lowering, backend, build evaluation, or frontend can identify the problem now, diagnostics should be structured and exact
But not:
- all language-semantic errors already exist today
Practical rule of thumb
When reading compiler output, think in this order:
- look at the diagnostic code in brackets to identify the error family
- trust the primary location
- use related labels to understand competing or earlier sites
- read notes for technical context
- read helps for the most actionable next step
That mental model matches how the compiler currently structures reporting.
Syntactic Sugar
This section collects forms that are primarily about expressiveness, convenience, or alternate notation.
These chapters should be read after the core statement, expression, and declaration rules, because most sugar expands on top of those more fundamental forms.
The current sugar chapters cover:
- silent/discard forms
- pipes
- dfr and edf
- mixed/compound convenience forms
- limits
- matching
- rolling
- unpacking
- inquiry
- chaining
Silents
Single letter identifiers (SILENTs) identifiers are a form of languages sugar assignment.
Letter
Lowercase
Many times is needed to use a variable in-place and to decluter the code we use silents:
each(var x: str; x in {..10}){
// implementation
}
each(x in {..10}){ // we use the sicale `x` here
// implementation
}
Uppercase
If a silent is uppercase, then it is a constant, can’t be changed. This is very important when using FOL for logic programming:
log vertical(l: line): bol = {
l[A:B] and // we assign sicales `A` and `B`
A[X:Y] and // we assign sicales `X` and `Y`
B[X:Y2] // here we assign only `Y2` becase `X` exists from before
}
Symbols
Meh
Meh is the _ identifier. The use of the term “meh” shows that the user is apathetic, uninterested, or indifferent to the question or subject at hand. It is occasionally used as an adjective, meaning something is mediocre or unremarkable.
We use meh when we want to discard the variable, or we dont intend to use:
var array: arr[int, 3] = {1, 2, 3};
var a, _, b: int = array; // we discard, the middle value
Y’all
Y’all is the * identifier. It represents app possible values that can be.
when(true) {
case (x == 6){ // implementation }
case (y.set()){ // implementation }
* { // default implementation }
}
Pipes
Pipes connect the value on the left to the expression on the right.
The basic idea is still:
left | right
where the right-hand side sees the left-hand side as this.
Ordinary value piping
Current boundary:
- the ordinary value-pipe (
left | ... this) shown in this section is later design work, not part of the current compiler surface - the recoverable-call surfaces below (
check(...)and||) are the current compiler surface - the specialized
call() | asyncandeventual | awaitprocessor stages are shipped V3std-only forms; they do not enable generalthis-based piping
Use | when you want to continue transforming a normal value:
fun add(x: int, y: int): int = {
return x + y
}
fun main(): int = {
return add(4, 5) | when(this > 8) {
case(true) { 6 }
* { 0 }
}
}
This is ordinary value flow. The pipe itself does not create a special error model.
Recoverable calls are separate from ordinary pipes
Routines declared with / ErrorType produce recoverable call results:
fun read_code(path: str): int / str = {
when(path == "") {
case(true) { report "missing path" }
* { return 7 }
}
}
For these calls, the current V1 compiler does not treat plain | as the
main error-handling tool.
Instead, the implemented recoverable-call surfaces are:
check(expr)expr || fallback
check and panic are compiler intrinsics in the current V1 compiler. They
are not imported library functions.
check(expr)
check(expr) asks whether a recoverable / ErrorType expression failed. That
may be a direct routine call or, in V3, an awaited recoverable eventual.
It returns bol.
fun main(path: str): int / str = {
when(check(read_code(path))) {
case(true) { report "read failed" }
* { return 0 }
}
}
This is the current inspection surface for direct V1 recoverable calls and V3 awaited recoverable results.
|| fallback
Double-pipe is the current shorthand for recovery:
fun read_code(path: str): int / str = {
when(path == "") {
case(true) { report "missing path" }
* { return 7 }
}
}
fun with_default(path: str): int = {
return read_code(path) || 5
}
Meaning:
- if the call succeeds, use the success value
- if the call fails, evaluate the right-hand side
The fallback may be:
- a default value
report ...panic ...
Examples:
fun recover(path: str): int = {
return read_code(path) || 5
}
fun re_report(path: str): int / str = {
return read_code(path) || report "read failed"
}
fun must_succeed(path: str): int = {
return read_code(path) || panic "read failed"
}
V3 async and await stages
V3 ships two exact processor pipe stages:
fun[] load_async(path: str): int = {
var pending = read_code(path) | async;
return (pending | await) || 0;
}
fun[] async_failed(path: str): bol = {
var pending = read_code(path) | async;
return check(pending | await);
}
| async starts a direct named call on an OS thread and produces an internal
eventual. | await consumes that eventual binding and blocks for its result. If
the original call is recoverable, the awaited result must flow immediately into
check(...) or ||. These stages require the explicit bundled std
dependency; see Eventuals.
What ordinary | does not mean
The current compiler does not claim that plain | automatically forwards both
the success value and the error to the next stage.
That older description is too broad for the current implementation.
For recoverable calls, use check(expr) or expr || fallback. The exact V3
async and await stages above are specialized processor forms; they do not
make the later general value-pipe design part of the shipped language.
Dfr
dfr is scope-exit cleanup sugar. edf is its error-only V3 counterpart.
The shipped forms are intentionally narrow:
- only the
dfr { ... }andedf { ... }block forms are supported - each block registers work on the current lexical scope
dfrruns when that scope exits by fallthrough orbreak, and before areturn, recoverablereport, orpanicleaves it- multiple deferred blocks run in reverse registration order
edfruns only on a recoverablereportexit, not on normal return or panic
This is control-flow sugar.
It is not a destructor system, user-defined drop hook, object system, or async cleanup system.
Model reminder:
-
memoalone does not expose the hosted.echo(...)substrate -
the printing examples below use
fol_model = "memo", declare the bundled standard library explicitly inbuild.fol, and call itsstd.iowrappers:build.add_dep({ alias = "std", source = "internal", target = "standard" }); -
source files then import that dependency with
use std: pkg = {"std"}; -
the explicit dependency upgrades the effective tier to hosted;
stdis not a thirdfol_model -
dfrandedfare not std-only; both remain valid incoreandmemo -
a
coreor unhostedmemoprogram using them can still run; bundledstdappears here only because these examples print from source
Basic form
use std: pkg = {"std"};
pro[] main(): int = {
dfr {
std::io::echo_str("closing");
};
std::io::echo_str("work");
return 7;
}
The deferred body runs before control leaves main.
Reverse order
use std: pkg = {"std"};
pro[] main(): non = {
dfr { std::io::echo_int(1); };
dfr { std::io::echo_int(2); };
}
This executes as:
2- then
1
Nested scopes
Deferred bodies belong to the scope where they are declared:
use std: pkg = {"std"};
pro[] main(flag: bol): non = {
dfr { std::io::echo_str("outer"); };
when(flag) {
case(true) {
dfr { std::io::echo_str("inner"); };
return;
}
* { }
}
}
If the inner scope exits first, its deferred bodies run before the outer scope’s deferred bodies.
Error-only cleanup
edf registers a body for recoverable error exits only:
use std: pkg = {"std"};
fun[] load(fail: bol): int / int = {
edf {
std::io::echo_int(2);
};
dfr {
std::io::echo_int(1);
};
when(fail) {
case(true) { report 7; }
* { return 0; }
}
};
On success, only dfr runs. On report, both registrations run in reverse
registration order. On panic, dfr runs but edf does not, because panic is
not a recoverable error-shell exit.
Deferred-body limits
The registered body is checked now but executed later. V3 therefore rejects effects that the current lowering cannot replay safely:
return,break,report, andpaniccannot appear inside adfroredfbody; deferred cleanup cannot initiate another exit while the surrounding exit is being replayed- a moved binding cannot be reinitialized there
- channel sender or receiver endpoints cannot be acquired there
| awaitand access to an existing eventual binding cannot occur insideedf; an error-only body cannot discharge eventual ownership on normal exits, and the restriction remains active inside adfrnested within thatedf- a
mux[T]handle cannot be locked, unlocked, used for guarded field access, or forwarded to anothermux[T]parameter there
These are compile-time boundaries, not cleanup operations that are silently
skipped. An outer report still triggers registered dfr and edf bodies;
only a new report from inside one of those bodies is rejected. An all-exit
dfr may await and handle a recoverable eventual, because it runs on every
modeled exit. Error-only edf cannot. Put the other affected control flow,
channel acquisition, owner reinitialization, or mutex work in ordinary code.
Ownership ordering
At lexical scope exit FOL applies this order:
- select the registered blocks for the exit kind (
dfr, plusedffor a recoverable error) - run those blocks in reverse registration order
- release any still-active borrows
- free still-owned heap values by ordinary scope drop
A moved value is no longer in the source binding’s owned set, so it is not freed twice. Deferred work always runs before the scope drops its remaining owned heap values.
A dfr or edf body that references a move-only binding from an enclosing
scope reserves that binding through the exit of the registration scope. The
binding cannot be transferred after the deferred block is registered, because
the deferred body still needs its value. Moving the value before registration
instead makes the reference inside the deferred body an ordinary use-after-move
error. A reservation registered in a nested block ends after that block’s
deferred work has run, so the owner may be transferred afterward.
Mutex guard effects are not deferred in V3. A dfr or edf body cannot
call .lock() or .unlock() on a mux[T] parameter, and it cannot access
mutex-guarded fields even when the surrounding scope currently holds the
guard. It also cannot forward the mutex handle to another mux[T] routine,
because that would hide delayed guard transitions behind the call. Perform
guarded work in ordinary control flow before registering deferred cleanup.
Channel endpoint acquisition is similarly rejected inside dfr and edf.
Acquire any sender handle before registration and perform receiver operations
in ordinary control flow.
The same delayed-effect boundary applies to owner reinitialization. A moved
binding cannot be reinitialized inside dfr or edf: typechecking that write
as immediate would make the binding look usable before the deferred assignment
actually runs. Reinitialize the binding in ordinary control flow before the
deferred block is registered.
Explicit captures
A delayed block declares how outer locals enter it with a capture list — every routine-local binding the body uses must appear in it. Each entry names an outer binding and states its operation:
var seen: int = 2;
var owned: ptr[int] = [ref]seed;
dfr[seen[bor], owned[mov]] {
std::io::echo_int(seen + [drf]owned);
};
[bor] observes the binding — the owner stays restricted while the block can
still run, and the loan is read-only: assigning through a [bor] capture is
rejected. A block that mutates its capture states the composite [mut, bor]
mutable loan instead:
var[mut] total: int = 5;
dfr[total[mut, bor]] { total = total + 37; };
mut composes only with bor. [mov] transfers the value into the block’s
environment: the outer binding is invalidated for the rest of the scope, and
the block still owns the value when scope-exit cleanup executes it. [cpy]
and [cln] duplicate the value under the same capability rules as spawn
captures. A bare capture name without an operation is rejected, as is a
channel endpoint capture — endpoints belong to spawned tasks, not delayed
blocks. Using an undeclared outer local
inside the block is an ownership error; module-level values and routine names
are not captures and need no declaration.
Current milestone boundary
The basic dfr form belongs to V1. Ownership-aware ordering and edf are the
shipped V3 memory contract. V3 processor resources do not become generalized
deferred-cleanup hooks: outstanding tasks join at process exit, channels close
through endpoint ownership/lifecycle, and an eventual can be consumed at most
once by | await. Recoverable eventuals must be awaited and handled on every
exit path; an all-exit dfr may do so, while an error-only edf may not.
Infallible eventuals may instead remain outstanding for the process-exit join.
Await inside edf—including inside a nested dfr—channel endpoint acquisition,
and mutex guard effects inside dfr/edf are rejected. Native/foreign resource
cleanup remains V4 interop work.
Mixture
Optional
var someMixtureInt: ?int = 45;
Never
var someNverType: !int = panic();
Limits
Current boundary:
- the
type[][.range(...)]limit surfaces are not implemented in the current compiler - everything below describes intended design, not current behavior; limits are later design work, not part of the current compiler surface
Limiting is a syntactic way to set boundaries for variables. The way FOL does is by using [] right after the type declaration type[], so: type[options][limits]
Initger limiting
Example, making a intiger variable have only numbers from 0 to 255 that represents an RGB value for a single color:
var rgb: int[][.range(255)];
Character limiting
It works with strings too, say we want a string that can should be of a particular form, for example an email:
var email: str[][.regex('[._a-z0-9]+@[a-z.]+')]
Matching
Current boundary:
- only
isarms (plus thecaseand*default forms) are the current compiler surface in(range/set matching) andhas(membership) arms are declared syntax whose semantics are later-milestone work; the compiler rejects them with explicit boundary diagnostics
Variable
As variable assignment:
var checker: str = if(variable) {
in {..10} -> "in range of 1-10";
in {11..20} -> "in range of 11-20";
* -> "out of range";
}
var is_it: int = if(variable) {
is "one" -> 1;
is "two" -> 2;
* -> 0;
}
var has_it: bol = if(variable) {
has "o", "k" -> true;
* -> false;
}
Function
As function return:
fun someValue(variable: int): str = when(variable) {
in {..10} -> "1-10";
in {11..20} -> "11-20";
* -> "0";
}
Rolling
Current boundary:
- rolling / list-comprehension expressions are not part of the current compiler surface; the compiler rejects them with an explicit “not yet supported” diagnostic
- everything below describes intended design, not current behavior
- this is later design work, not part of the current V1 compiler surface
Rolling or list comprehension is a syntactic construct available FOL for creating a list based on existing lists. It follows the form of the mathematical set-builder notation - set comprehension.
Rolling has the same syntactic components to represent generation of a list in order from an input list or iterator:
- A variable representing members of an input list.
- An input list (or iterator).
- An optional predicate expression.
- And an output expression producing members of the output list from members of the input iterable that satisfy the predicate.
The order of generation of members of the output list is based on the order of items in the input. Syntactically, rolling consist of an iterable containing an expression followed by a for statement. In FOL the syntax follows exacly the Python’s list comprehension syntax:
var aList: vec[] = { x for x in iterable if condition }
Rolling provides an alternative syntax to creating lists and other sequential data types. While other methods of iteration, such as for loops, can also be used to create lists, rolling may be preferred because they can limit the number of lines used in your program.
var aList: vec[] = {..12};
var another: vec[] = { ( x * x ) for ( x in aList ) if ( x % 3 == 0 ) }
var matrix: mat[int, int] = { x * y for ( x in {..5}, y in {..5} ) }
Unpacking
Current boundary: fixed-name unpacking (
var a, b = container) is implemented over set, array, vector, and sequence sources. Rest patterns (*name) parse but are rejected by typechecking in this milestone.
Current boundary:
- unpacking / iterable-destructuring forms (
var a, *_ = ...) are not part of the current compiler surface; the compiler rejects them - everything below describes intended design, not current behavior
- this is later design work, not part of the current V1 compiler surface
Unpacking—also known as iterable destructuring—is another form of pattern matching used to extract data from collections of data. Take a look at the following example:
var start, *_ = { 1, 4, 3, 8 }
.echo(start) // Prints 1
.echo(_) // Prints [4, 3, 8]
In this example, we’re able to extract the first element of the list and ignore the rest. Likewise, we can just as easily extract the last element of the list:
var *_, end = { "red", "blue", "green" }
.echo(end) // Prints "green"
In fact, with pattern matching, we can extract whatever we want from a data set assuming we know it’s structure:
var start, *_, (last_word_first_letter, *_) = { "Hi", "How", "are", "you?" }
.echo(last_word_first_letter) // Prints "y"
.echo(start) // Prints "Hi"
Now, that has to be one of the coolest programming language features. Instead of extracting data by hand using indices, we can just write a pattern to match which values we want to unpack or destructure.
Inquiry
Current boundary:
- inquiry
where(...)clauses parse, but no inquiry body typechecks today; theisoperator used inside them is gated - everything below describes intended design, not current behavior; inquiries are later design work, not part of the current compiler surface
Inquiries are inline unit tests and are a part of the basic syntax sugar. In other words, we don’t have to import any libraries or build up any suites to run tests.
Instead, FOL includes a couple of clauses for testing within the source code:
fun sum(l: ...?int): int = {
when(l.length()) {
is 0 => 0;
is 1 => l[0];
$ => l[0] + sum(l[1:]);
}
where(self) {
sum() is 0;
sum(8) is 8;
sum(1, 2, 3) is 6;
}
}
Here, we can see an awesome list sum function. Within the function, there are two basic cases: empty and not empty. In the empty case, the function returns 0. Otherwise, the function performs the sum.
At that point, most languages would be done, and testing would be an afterthought. Well, that’s not true in FOL. To add tests, we just include a where clause. In this case, we test an empty list and a list with an expected sum of 6.
When the code is executed, the tests run. However, the tests are non-blocking, so code will continue to run barring any catastrophic issues.
Chaining
Optional chaining is a process for querying and calling properties, methods, and subscripts on an optional that might currently be nil. If the optional contains a value, the property, method, or subscript call succeeds; if the optional is nil, the property, method, or subscript call returns nil. Multiple queries can be chained together, and the entire chain fails gracefully if any link in the chain is nil.
Before I can really explain optional chaining, we have to get a grasp of what an optional value is. In FOL, variables cannot be empty. In other words, variables cannot store a value of NIL, at least not directly. This is a great feature because we can assume that all variables contain some value. Of course, sometimes variables need to be NIL. Fortunately, FOL provides that through a boxing feature called optionals. Optionals allow a user to wrap a value in a container which can be unwrapped to reveal either a value or NIL:
var printString: ?str;
printString = "Hello, World!"
.echo(printString!)
In this example, we declare an optional string and give it a value of “Hello, World!” Since we know that the variable stores a str, we can unconditionally unwrap the value and echo it. Of course, unconditional unwrapping is typically bad practice, so I’m only showing it for the purposes of showing off optionals.
Current V1 compiler note:
nilis type-checked only when the surrounding context already expects anopt[...]orerr[...]shell.- A standalone
nilwith no expected shell type is rejected during typechecking. - Unwrap
[uwp]valueis currently type-checked foropt[T]anderr[T]and yieldsT. - Bare
err[]does not currently support unwrap because there is no payload value to recover. - This
!surface is shell-only. It does not unwrap routine call results declared withResultType / ErrorType.
At any rate, optional chaining takes this concept of optionals and applies it to method calls and fields. For instance, imagine we have some long chain of method calls:
important_char = commandline_input.split('-').get(5).charAt(7)
In this example, we take some command line input and split it by hyphen. Then, we grab the fifth token and pull out the seventh character. If at any point, one of these method calls fails, our program will crash.
With optional chaining, we can actually catch the NIL return values at any point in the chain and fail gracefully. Instead of a crash, we get an important_char value of NIL. Now, that’s quite a bit more desirable than dealing with the pyramid of doom.
The current V1 compiler milestone only implements the plain nil/!
surfaces described above. Full optional chaining remains later work.
Value Conversion
This section covers conversions between types.
FOL distinguishes:
- coercion: conversions considered safe and unambiguous
- casting: conversions that should be explicit in source
Coercion
The book-level idea of coercion is simple:
- coercion is implicit
- it should only happen when the conversion is safe and unambiguous
For the current V1 compiler milestone, that contract is intentionally narrow.
V1 coercion currently allows:
- exact type matches
- alias-wrapped values whose apparent type matches the expected type
neverflowing into any expected type- the current optional/error shell lifting used by ordinary
V1surfaces
V1 coercion currently does not allow:
- implicit
int -> flt - implicit
flt -> int - implicit width or signedness changes
- implicit container reshaping
- implicit string/character/container conversions
So today the rule is:
- if two values are not already the same semantic family, the compiler should reject the implicit conversion
This is deliberate.
The conversion chapter in the language design is broader than the current
compiler guarantee, and V1 chooses explicit rejection over silent guessing.
Examples that are accepted in V1:
var count: int = 1
var ratio: flt = 1.5
fun[] take_int(value: int): int = {
return value;
}
fun[] take_float(value: flt): flt = {
return value;
}
Examples that are rejected in V1:
var count: int = 1.5
var ratio: flt = 1
fun[] take_float(value: flt): flt = {
return value;
}
fun[] bad(): flt = {
return take_float(1);
}
Later versions may widen this contract, but V1 keeps coercion intentionally
small so type behavior stays predictable.
Casting
Casting is the explicit side of value conversion.
The long-term language direction is:
- coercion stays implicit and narrow
- casting stays explicit and source-visible
For the current V1 compiler milestone, casting syntax is parsed, but casting
semantics are not implemented yet.
That means:
value as targetvalue cast target
are both valid syntax surfaces, but they are not part of the supported V1
type system.
The current compiler behavior is explicit:
- it does not silently reinterpret these expressions
- it does not treat them as ordinary coercions
- it reports them as unsupported
V1typecheck surfaces
Example:
fun[] bad_as(value: int): int = {
return value as text;
}
fun[] bad_cast(value: int): int = {
return value cast target;
}
Both forms currently fail during typechecking.
This boundary is intentional. Before FOL can support casting for real, the compiler needs a stable legality contract answering questions such as:
- which scalar casts are allowed
- whether lossy casts are permitted
- whether container casts exist
- how aliases interact with explicit conversion
- how future foreign/ABI types participate in conversion
That last point is deliberately later work:
- C ABI and Rust interop are planned
V4features - casting rules for foreign or ABI-facing types should be specified together
with that
V4interop contract, not guessed earlier
Until that contract exists, V1 treats cast syntax as parsed-but-unsupported
instead of guessing semantics.
Memory Model
This section explains FOL’s V3 memory model: explicit ownership operations
([mov]/[cpy]/[cln]/[bor] and friends) at every transfer boundary,
non-lexical borrows, static places and partial moves, named and elided
lifetimes, capability standards, custom finalization, closure environments,
and the unique/shared/weak/synchronized pointer family. The normative contract
is plan/V3_MEM.md; the first-shipped historical subset is recorded in its
Appendix A.
The main topics are:
- ownership
- pointers
- stack vs heap intuition
- allocation lifetime
- cross-thread memory concerns
The detailed chapters are the normative source. This index also keeps the
published example matrix aligned with the shared machine inventory in
test/v3_example_inventory.rs. Adding, removing, or renaming a mem_* or
fail_mem_* package requires updating both inventories in the same change.
The memory pillar is not complete at compiler acceptance. Each row below is
also guarded through lowering/runtime behavior, frontend capability routing,
structured diagnostics and explanations, formatter/tool commands, LSP
diagnostics/navigation/completion/tokens, Tree-sitter grammar/queries/corpus,
tests, docs, and the book. The exact cross-layer mapping lives in
Compiler Integration
and the repository-level docs/editor-sync.md matrix.
Shipped example inventory
M1: ownership and owned allocation
Positive:
examples/mem_owner_reinitialize_m1examples/mem_set_observation_m1examples/mem_when_bool_gate_m1examples/mem_linked_list_m1examples/mem_tree_m1examples/mem_move_stack_vs_heap_m1examples/mem_partial_move_m1examples/mem_fin_finalizer_m1examples/mem_fin_move_m1examples/mem_fin_early_m1
Negative:
examples/fail_mem_use_after_move_m1examples/fail_mem_loop_move_m1examples/fail_mem_explicit_move_reuse_m1examples/fail_mem_discarded_move_m1examples/fail_mem_deferred_reinit_m1examples/fail_mem_recursive_value_m1examples/fail_mem_heap_in_core_m1examples/fail_mem_uninit_borrow_m1examples/fail_mem_partial_move_m1examples/fail_mem_global_fin_m1examples/fail_mem_fin_partial_move_m1examples/fail_mem_fin_fun_finalizer_m1examples/fail_mem_fin_reuse_m1examples/fail_mem_clone_fin_m1examples/fail_mem_clone_fin_claim_m1examples/fail_mem_generic_copy_bound_m1examples/fail_mem_generic_send_bound_transitive_m1
M2: lexical borrowing and deferred ownership
Positive:
examples/mem_borrow_m2examples/mem_borrow_giveback_m2examples/mem_borrow_param_m2examples/mem_mut_borrow_m2examples/mem_ownership_ops_m2examples/mem_capabilities_m2examples/mem_copy_clone_m2examples/mem_custom_clone_m2examples/mem_borrow_receiver_m2examples/mem_mut_receiver_m2examples/mem_receiver_ops_m2examples/mem_reborrow_m2examples/mem_nll_last_use_m2examples/mem_named_lifetime_m2examples/mem_temp_borrow_m2examples/mem_place_borrow_m2examples/mem_edf_m2examples/mem_dfr_capture_m2examples/mem_dfr_capture_mut_m2examples/mem_closure_capture_m2examples/mem_closure_borrow_m2examples/mem_closure_env_lifetime_m2
Negative:
examples/fail_mem_deferred_report_m2examples/fail_mem_dfr_capture_bare_m2examples/fail_mem_dfr_capture_bor_mutation_m2examples/fail_mem_dfr_capture_undeclared_m2examples/fail_mem_closure_move_only_m2examples/fail_mem_closure_escape_m2examples/fail_mem_closure_env_leak_m2examples/fail_mem_copy_moveonly_m2examples/fail_mem_copy_record_field_m2examples/fail_mem_send_field_m2examples/fail_mem_send_nested_field_m2examples/fail_mem_clone_nested_field_m2examples/fail_mem_share_field_m2examples/fail_mem_owner_while_borrowed_m2examples/fail_mem_second_mut_borrow_m2examples/fail_mem_mut_borrow_immutable_owner_m2examples/fail_mem_borrow_reuse_m2examples/fail_mem_conditional_giveback_m2examples/fail_mem_deferred_giveback_m2examples/fail_mem_ownership_op_combo_m2examples/fail_mem_copy_fin_conflict_m2examples/fail_mem_copy_field_m2examples/fail_mem_copy_nested_field_m2examples/fail_mem_copy_operand_unclaimed_m2examples/fail_mem_fun_mut_receiver_m2examples/fail_mem_escaping_borrow_m2examples/fail_mem_temp_borrow_escape_m2examples/fail_mem_place_borrow_owner_m2
M3: typed pointers
Positive:
examples/mem_ptr_unique_m3examples/mem_ptr_shared_m3examples/mem_ptr_shared_recursive_m3examples/mem_ptr_weak_m3examples/mem_ptr_weak_clone_m3examples/mem_ptr_weak_upgrade_m3examples/mem_ptr_weak_cycle_m3examples/mem_ptr_weak_nested_m3examples/mem_shell_access_m3examples/mem_shell_prefix_m3
Negative:
examples/fail_mem_borrowed_ptr_move_deref_m3examples/fail_mem_ptr_raw_m3examples/fail_mem_ptr_weak_m3examples/fail_mem_shell_access_m3examples/fail_mem_ptr_in_core_m3examples/fail_mem_pointer_field_deref_m3examples/fail_mem_shared_ptr_move_deref_m3examples/fail_mem_shared_ptr_write_m3
Ownership
FOL’s V3 memory model uses compile-time ownership. It does not add a garbage collector, runtime ownership tags, or user-defined destructors.
V3 ships unique heap ownership, move checking, lexical borrowing, and typed unique/shared pointers. Transfer behavior follows the value type, not merely the binding’s location: clone-safe values are copied by cloning, while unique ownership and aggregates containing unique ownership move.
Stack and heap bindings
Bindings live on the stack unless heap allocation is explicit:
var stack_value: int = 64;
var[new] heap_value: int = 64;
@var other_heap_value: int = 64;
@var is sugar for a binding with [new]. Heap allocation requires the
memo capability model. A memo artifact with bundled std remains
heap-capable, but allocation does not itself require hosted APIs. Ownership
checking is a compile-time rule and is also active in core.
There is no [@] binding option and no manual .de_alloc() or .free().
Unique heap values lower to Rust Box<T> and are freed implicitly when their
owning scope ends.
Assignment and ownership transfer
Assignment and rebinding use the value’s ownership class:
- clone-safe values clone, so the source and destination remain usable
- heap-owned values and unique pointers move, so the source becomes inaccessible
- an aggregate is move-only when it contains a move-only value
- a shared pointer clones its reference count instead of moving its source
fun[] main(): int = {
var stack_a: int = 2;
var stack_b: int = stack_a;
@var heap_a: int = 3;
@var heap_b: int = heap_a;
return stack_a + stack_b;
};
A later read of heap_a is a compile error in the O#### OWNERSHIP diagnostic
family. The diagnostic points at both the invalid use and the transfer site.
The backend emits clone-safe transfers with .clone() and unique transfers as
ordinary Rust moves. No runtime tag decides which operation occurs.
Evaluating a move-only value as a standalone expression is also a transfer even when its result is discarded. Lowering still evaluates that expression, so the source cannot be used afterward. Clone-safe scalar expression statements remain usable because their values clone.
A whole mutable binding can be reinitialized after its old value moves:
var[mut] pointer: ptr[int] = [ref]first;
consume(pointer);
pointer = [ref]second;
return [drf]pointer;
The assignment target is a storage place, not a read of the missing old value. The right-hand side is checked and transferred first, then a successful store makes the binding usable again. Ordinary mutability and active-borrow rules still apply. Self-assignment of a live mutable owner transfers the value through the assignment and leaves the same binding initialized. Assignment is a statement and does not yield a second copy of the stored value.
Transferring a move-only record field consumes the whole source binding. V3 does not leave a partially moved record available through its other fields. Any indexed read whose element or map value is move-only remains unsupported, including selecting a clone-safe field from that element afterward. Index access materializes the whole element, so these containers need an explicit removal operation rather than a clone-based read.
Container queries are observations, not transfers. .len(local) and indexed
lookup through direct locals preserve the receiver and map key, including when
their types contain unique pointers. A move-only receiver or key reached
through a field projection is not yet observable because V3 has no place-aware
projection IR; such a lookup is rejected instead of partially moving its
source. The same boundary rejects nested field access through a move-only
intermediate.
Dereferencing is a by-value operation whose ownership effect depends on the
pointee. [drf]pointer clones a clone-safe pointee and leaves the pointer usable.
If the pointee is move-only, dereferencing a direct unique ptr[T] transfers
the pointee and consumes that pointer. A shared or borrowed pointer cannot
surrender a move-only pointee, so those dereferences are rejected; their
read-only dereference is available only when the pointee is clone-safe.
Dereferencing a unique-pointer field is also a place observation, not a whole
field transfer. Until V3 has place-aware projection IR, [drf]record.pointer is
rejected even when its pointee is clone-safe; otherwise backend lowering would
partially move the pointer just to observe its target. Direct pointer bindings
remain available. A field containing ptr[shared, T] can be dereferenced when
T is clone-safe.
Forward slices currently create a new vec[...] or seq[...] by cloning the
selected elements. Their element type must therefore be clone-safe. Fixed-size
array slices need a distinct runtime-sized result contract and remain
unsupported. Ordinary collection iteration is also index-based and clone-based,
so a collection containing move-only elements cannot use it yet. Channel
receiver iteration is different: it consumes each payload and may carry
move-only values.
Top-level storage is limited to clone-safe, non-borrowed values that are safe in
the backend’s shared static cells. Move-only values, borrowed values, full
channels, and values containing ptr[shared, T] must be declared inside a
routine. This prevents global loads from duplicating unique ownership, extending
a lexical borrow to static lifetime, or placing Rc in thread-safe storage.
A when result transfers the final value of the selected branch into its join
value. Branches are checked from the same incoming ownership state, so the same
owner may be transferred by mutually exclusive alternatives. After the when,
every owner that could have been transferred by a continuing branch is treated
as moved.
Value-matching when arms use the same equality contract as ==: selector and
case values must have the same equality-safe scalar type. Pointer, record, and
other move-only selectors are rejected rather than being moved by an implicit
comparison. A when without value arms is instead a boolean control gate, so it
also rejects move-only selectors rather than evaluating and silently moving an
unused owner.
Loop bodies are lexical scopes that execute once per iteration. A move-only
binding declared outside a repeating loop cannot be transferred from the loop
body or its repeated condition: a later iteration would try to consume the
same value again. Create the move-only value inside the loop when each
iteration needs a fresh owner, or transfer it after the loop. A return that
transfers a value is allowed because it exits the routine instead of reaching
another iteration.
Deferred bodies also participate in ownership checking. When a dfr or edf
body references a move-only binding from an enclosing scope, that binding is
reserved until the registration scope exits and the selected deferred work has
run. A later transfer in the same scope is rejected with both the transfer and
deferred-use locations. If the deferred block belongs to a nested scope, the
reservation ends with that nested scope.
Reinitializing an already-moved binding inside dfr or edf is rejected in
V3. The assignment runs only at scope exit, so applying its ownership effect
when the deferred body is registered would unsafely expose the binding before
the new value exists.
Recursive owned data
Owned heap indirection gives recursive types a finite runtime shape:
typ Node: rec = {
value: int,
next: opt @Node,
};
In type position, @Node means an owned heap Node. The example above lowers
to one nominal Rust structure whose recursive field has the shape
FolOption<Box<Node>>.
A recursive value edge without owned indirection is still invalid:
typ Bad: rec = {
next: Bad,
};
Such a type has no finite value layout. The compiler recommends an owned edge
such as opt @Bad instead.
Borrowing
A borrow is an alias that does not take ownership:
var owner: Node = { value = 7, next = nil };
var[bor] view: Node = owner;
var value: int = view.value;
[bor]owner borrows from an owner as an expression:
var[bor] view: Node = [bor]owner;
Borrowing is non-lexical. A borrow stays active only until its last use, not until the end of the lexical scope where its binding was created. Once a loan’s final read has passed, the owner becomes usable again in the same scope with no explicit give-back.
While a borrow is active:
- the owner cannot be read, written, moved, or borrowed incompatibly
- an immutable borrower is read-only
- a borrowed value cannot transfer move-only data out of its owner; clone-safe observations remain available
- any mutable borrower excludes every other borrower of that owner
Leaving the scope returns access to the owner automatically:
var owner: Node = { value = 7, next = nil };
{
var[bor] view: Node = owner;
var value: int = view.value;
};
return owner.value;
[end]view gives a borrow back before scope exit. A returned borrow cannot be used
again; attempting to read view afterward reports O2004 with the give-back
site as related information:
var[bor] view: Node = owner;
var value: int = view.value;
[end]view;
return view.value;
The ! prefix is give-back only. It is not deletion or manual memory free.
A borrow binding may be reborrowed: [bor]view creates a nested loan from an
existing borrow, released like any other loan by its last use or an explicit
[end]. A reborrow cannot outlive the loan it derives from.
Mutable borrowing
A mutable borrow requires both an explicitly mutable owner and an explicitly mutable borrow binding:
var[mut] owner: Node = { value = 7, next = nil };
var[mut, bor] view: Node = owner;
view.value = 9;
[end]view;
return owner.value;
var[bor] is immutable unless mut is also present. A var[mut, bor] binding
may update the original owner, but it still does not own the value and cannot
move unique data out of it. Attempting a mutable borrow from var[imu], or
creating another borrow while a mutable borrow is active, is an O####
ownership diagnostic.
Borrow parameters
Routine parameters use the same explicit option position:
fun[] inspect(item[bor]: Node): int = {
return item.value;
};
The backend emits a Rust reference parameter. Passing an owner requires an
explicit [bor]owner; an existing compatible borrow binding can be passed directly
and reused by later [bor] calls. The call borrow ends when the call returns,
while an existing borrow binding keeps its surrounding lexical lifetime.
Parameter spelling or casing never changes ownership semantics;
name[bor]: T is the only borrow-parameter form. A borrowed parameter can
observe clone-safe data, but it cannot return or forward a move-only value as
an owned argument.
Borrowing is compile-time-only and legal in core. It does not allocate or add
runtime reference bookkeeping.
Named lifetimes
When a routine returns a borrow, the compiler must know which input the result
may alias. A lifetime parameter L: lif names a single region and ties the
borrowed inputs and the borrowed result together:
fun pick(L: lif)(left: Item[bor=L], right: Item[bor=L]): Item[bor=L] = {
return left;
};
Item[bor=L] is a borrow bound to region L. The returned borrow is valid for
as long as every input it may alias, so the caller’s owners must outlive the
result. A routine that borrows a single input and returns a borrow may leave the
region implicit; naming it is required only when several borrows share one
result region.
Receiver ownership
A method states how it takes its receiver with the same option position; the
receiver binds to self:
fun (Node[bor])inspect(): int = { return self.value; };
pro (Node[mut, bor])update(): non = { self.value = 9; return; };
fun (Node)consume(): int = { return self.value; };
A fun may take a shared [bor] receiver or move its receiver by value; only a
pro may take a mutable [mut, bor] receiver. At the call site the ownership
operation binds the receiver directly, with no surrounding parentheses:
[bor]node.inspect();
[mut, bor]node.update();
[mov]node.consume();
[op]receiver.method() groups as ([op]receiver).method(): the operation
describes how the receiver crosses the call boundary, not the call’s result, and
a [mut, bor] receiver’s field updates persist to the original binding. The
rule is scoped to a trailing method call — a plain place chain keeps the
operation over the place, so [mov]bundle.held still moves the held subfield
rather than bundle. The same grouping applies to the bracket unary operations,
so [drf]pointer.method() dereferences pointer and then calls the method.
Closure captures
An anonymous routine used as a first-class value declares how outer locals enter its environment with the same capture list delayed blocks use. The routine value’s type is its visible call signature; the environment re-supplies the captured values on every invocation:
var base: int = 30;
var adder: {fun (n: int): int} = fun(n: int)[base[cpy]]: int = { return n + base; };
var first: int = adder(12);
[cpy] and [cln] duplicate the outer value into the environment, leaving the
source live. [mov] transfers a clone-safe value and invalidates the outer
binding; moving a move-only value into a routine value is rejected because the
closure may run more than once — spawn the routine directly when the value
should transfer into exactly one execution.
A local, nonescaping closure may also borrow: [bor] captures infer their
lifetime as the enclosing scope. The owner stays readable but is frozen — no
mutation, no transfer — while the closure can still run, and the closure value
itself cannot escape that scope: returning it, passing it to a call, storing
it, or rebinding it is rejected. The one sanctioned crossing is a parameter
whose routine type names its environment lifetime — {fun (): int}[bor=L]
with a declared L: lif — which receives the closure and obeys the same
nonescaping rules inside the callee
(examples/mem_closure_env_lifetime_m2 and
examples/fail_mem_closure_env_leak_m2 pin that boundary). Channel-endpoint captures never enter routine
values (examples/mem_closure_capture_m2, examples/mem_closure_borrow_m2,
examples/fail_mem_closure_move_only_m2, and
examples/fail_mem_closure_escape_m2 pin the contract).
Capabilities
Five compiler-owned standards describe what may be done with a value. A type lists the ones it guarantees in its conformance position, and the compiler verifies each claim against the type’s fields.
| Standard | Meaning |
|---|---|
copy | [cpy]value may duplicate the value; the source stays usable |
clone | [cln]value may create an independent copy; the source stays usable |
fin | the type runs custom finalization when it is dropped |
send | owned access may cross a task or thread boundary |
share | shared access may cross a task or thread boundary |
A type states its capabilities in the conformance list:
typ Point()(copy): rec = { x: int, y: int };
typ Job()(clone): rec = { input: int };
typ Buffer()(clone, send): rec = { bytes: seq[int] };
Verification
Each claim is checked recursively against the type’s fields. clone has a
structural default: a type is clonable when every field is clonable, so a
clone claim needs no extra code for ordinary data. copy has no structural
default — every aggregate field of a copy type must itself claim copy.
copy implies clone, and copy and fin cannot coexist.
send and share require every field to be thread-safe transitively, so a type
holding a non-synchronized shared pointer or a fin resource cannot claim them.
Operations
[cpy]value requires the value’s type to declare copy; a structurally
copy-safe record without the (copy) header is clone- or move-only.
[cln]value requires clone, which the structural default usually satisfies.
Custom clone
A type may override the structural clone with a pure borrowed-receiver method
named clone:
typ Counter()(clone): rec = { value: int, clones: int };
fun (Counter[bor])clone(): Counter = {
return { value = self.value, clones = self.clones + 1 };
};
[cln]counter then dispatches to this method instead of copying fields
structurally. A custom clone is a fun with a shared [bor] receiver: it
observes the source and returns an independent value.
Generic bounds
A capability may constrain a generic parameter. The obligation is checked at each call site against the concrete type argument, so the routine is only usable with types that satisfy the standard:
fun keep(T: copy)(value: T): T = { return [cpy]value; };
Finalization
A type that claims the fin capability runs custom cleanup when its owner is
released. The cleanup is a compiler-owned receiver contract: a pro named
finalize that takes the value by value and returns non.
typ File()(fin): rec = { descriptor: int };
pro (File)finalize(): non = {
close(self.descriptor);
return;
};
The finalizer is a pro because releasing a foreign resource is an effect. A
fin type cannot also claim copy: a value with cleanup is not trivially
duplicable.
Scope-exit cleanup
The finalizer runs automatically when the owning scope exits, exactly once per value:
fun[] main(): int = {
var handle: File = { descriptor = 3 };
return handle.descriptor;
// handle.finalize() runs here, as the scope exits
};
When a value is moved, responsibility for finalization moves with it: the new owner finalizes it, and the original binding does not.
Early finalization
[fin]value runs the finalizer immediately and invalidates the source. The
value is finalized exactly once, so scope-exit cleanup does not run a second
time:
var handle: File = { descriptor = 3 };
observe(handle.descriptor);
[fin]handle;
// handle is now consumed; no second finalize at scope exit
Pointers
FOL V3 has typed unique and shared pointers. Pointer construction allocates, so
it requires the memo capability model. A memo artifact with bundled std
remains heap-capable, but pointer construction does not itself require hosted
APIs.
Unique pointers
ptr[T] is a uniquely owned pointer to T:
fun[] main(): int = {
var value: int = 7;
var inner: ptr[int] = [ref]value;
var outer: ptr[ptr[int]] = [ref]inner;
var[mut] extracted: ptr[int] = [drf]outer;
[drf]extracted = 9;
return [drf]extracted;
};
[ref]value allocates the pointed-to value and produces a unique pointer. The
backend represents it as Box<T>. Unique pointers move on transfer, just like
other unique heap-owned values, and are freed when their owner leaves scope.
Constructing a pointer from a move-only value transfers that value into the new
allocation.
[drf]pointer is a by-value dereference to the T pointee:
- if
Tis clone-safe, dereference clonesTand leaves the pointer usable - if
Tis move-only, dereferencing a unique pointer transfersTout and consumes that pointer
The example consumes outer because its pointee is another unique pointer.
extracted then owns that inner pointer. A direct unique-pointer binding
declared with var[mut] supports write-through assignment such as
[drf]extracted = 9.
Direct unique-pointer bindings can be dereferenced, but a unique pointer reached
through a record field cannot be dereferenced in V3. That observation needs a
place-aware field projection in lowering; treating the field as an ordinary
value would partially move the pointer merely to read its pointee. This field
boundary also applies when T is clone-safe. Keep the unique pointer in a
direct binding, or use ptr[shared, T] with a clone-safe pointee when a
read-only pointer field is the intended shape.
Shared pointers
ptr[shared, T] is a reference-counted shared pointer:
var value: int = 7;
var first: ptr[shared, int] = [ref]value;
var second: ptr[shared, int] = first;
return [drf]first + [drf]second;
The backend represents shared pointers as Rc<T>. Assigning one clones the
reference count, so first and second refer to the same allocation. Shared
pointers are read-only; write-through is rejected.
A single [drf]p yields T for both unique and shared pointers. The reference
counting layer is not exposed as another pointer that needs a second
dereference. Because a shared pointer cannot remove the value from all of its
aliases, this read is available only when T is clone-safe. Dereferencing
ptr[shared, ptr[int]], for example, is rejected rather than cloning or moving
the unique inner pointer.
Borrowed pointers
A pointer can be borrowed like any other owned value:
fun[] read(pointer[bor]: ptr[int]): int = {
return [drf]pointer;
};
The borrowed pointer is a non-owning, read-only view. It can be passed directly
to another compatible [bor] parameter and reused for later calls. Dereference
can clone a clone-safe pointee such as int, but it cannot move a move-only
pointee through the borrow. A borrowed ptr[ptr[int]] therefore cannot produce
the inner ptr[int] by value. Write-through also requires a direct mutable
unique-pointer binding; a borrowed pointer is not such a binding.
Shared recursive graphs
Shared pointer indirection gives recursive graph edges a finite layout:
typ Node: rec = {
value: int,
next: opt ptr[shared, Node],
};
This lowers to an optional Rc<Node> edge. Shared recursion is legal, but V3
has no cycle collector: a cycle of shared pointers leaks unless a weak edge
breaks it.
Rc is not thread-safe and cannot cross a processor spawn boundary. The V3
processor pillar enforces that boundary; ptr[shared, sync, T] is the
synchronized (Arc-backed) shared owner for values that may cross it.
Weak pointers
ptr[weak, T] observes a shared allocation without keeping it alive.
[weak]shared creates the weak handle, and [upg]weak attempts to revive a
shared owner, producing opt[ptr[shared, T]] — nil when the allocation is
already gone. Neither operation consumes its operand.
fun[] main(): int = {
var value: int = 20;
var strong: ptr[shared, int] = [ref]value;
var observer: ptr[weak, int] = [weak]strong;
var revived: opt[ptr[shared, int]] = [upg]observer;
when(revived) {
on(alive) { return [drf]alive; }
* { return 0; }
}
};
Dereferencing a weak handle directly is rejected — upgrade first, then handle
the nil arm. Weak handles support explicit [cln], may live inside records,
and are the tool for breaking shared-pointer cycles
(examples/mem_ptr_weak_cycle_m3 breaks a parent/child cycle with a weak
back-edge).
Inner-place access
value[] reads the inner place uniformly across the pointer and shell families:
for a pointer it is a dereference, and for an opt[T] it is the present payload,
panicking if the option is nil.
var slot: ptr[shared, int] = [ref]count;
var maybe: opt[int] = lookup();
return slot[] + maybe[];
The same [] spelling works wherever a value wraps an inner place, so a
dereference and a shell unwrap share one surface.
Raw pointers are out
ptr[raw, T] is reserved but rejected with an explicit V4 interop diagnostic.
V3 does not provide raw pointer construction, manual .free(), or unsafe
delete. [end]x remains borrow give-back only; it never deletes a pointer.
Custom cleanup goes through the fin finalization capability instead.
Operations in core
Borrowing and pointer type declarations are legal in core: [bor]owner
creates a lexical borrow without allocation, and [end]borrow ends it early.
Constructing a pointer with [ref]value allocates, so it is rejected in core
even though the pointer’s type can still be analyzed there.
Concurrency
This section covers FOL’s first shipped V3 concurrency and asynchronous
execution subset. Every processor surface is hosted std-only and is
implemented with OS threads and Rust standard-library synchronization; FOL does
not use a separate async runtime.
Here std-only describes source capability: the artifact uses
fol_model = "memo" and the package explicitly declares the bundled internal
standard dependency. It does not mean FOL needs std merely to launch a
program. Host-compatible core and unhosted memo executables can run too;
they simply cannot use processor constructs.
The current chapter split is:
- eventuals
- tasks, channels,
select, and mutex parameters
Together they define the language-level model for task execution, coordination, and concurrent ownership boundaries.
The processor pillar is not complete at compiler acceptance. Each row below is
also guarded through lowering/runtime behavior, evaluated frontend capability
routing, structured diagnostics and explanations, formatter/tool commands, LSP
diagnostics/navigation/completion/tokens, Tree-sitter grammar/queries/corpus,
tests, docs, and the book. The exact cross-layer mapping lives in
Compiler Integration
and the repository-level docs/editor-sync.md matrix.
Shipped example inventory
This is the canonical processor example inventory. The milestone chapters and
the V3 processor plan link here instead of maintaining smaller lists that can
drift. Adding, removing, or renaming a processor example requires updating this
published list and the shared machine inventory in
test/v3_example_inventory.rs in the same change; compiler integration and
editor tests consume that shared machine inventory.
P1: spawn
Positive:
examples/proc_spawn_m1examples/proc_spawn_move_heap_m1examples/proc_spawn_canonical_m1examples/proc_spawn_detached_m1examples/proc_shared_sync_ptr_m1examples/proc_spawn_borrow_m1
Negative:
examples/fail_proc_spawn_detached_borrow_m1examples/fail_proc_spawn_fin_m1examples/fail_proc_spawn_borrow_mutate_m1examples/fail_proc_spawn_weak_m3examples/fail_proc_spawn_heap_use_after_move_m1examples/fail_proc_spawn_in_core_m1examples/fail_proc_spawn_indirect_m1examples/fail_proc_spawn_in_memo_m1examples/fail_proc_spawn_rc_cross_m1examples/fail_proc_spawn_recoverable_m1
P2: channels
Positive:
examples/proc_channel_capture_m2examples/proc_channel_loop_m2examples/proc_channel_m2examples/proc_channel_pull_m2examples/proc_sender_endpoint_m2examples/proc_receiver_endpoint_m2
Negative:
examples/fail_proc_channel_bare_send_m2examples/fail_proc_channel_capture_rx_m2examples/fail_proc_channel_in_core_m2examples/fail_proc_channel_in_memo_m2examples/fail_proc_channel_index_m2examples/fail_proc_channel_receive_bare_m2examples/fail_proc_channel_spawn_consumer_m2examples/fail_proc_clone_receiver_m2
P3: select and mutexes
Positive:
examples/proc_mutex_explicit_unlock_m3examples/proc_mutex_local_m3examples/proc_mutex_guard_m3examples/proc_mutex_guard_end_m3examples/proc_mutex_m3examples/proc_select_m3
Negative:
examples/fail_proc_mutex_deferred_m3examples/fail_proc_mutex_deferred_forward_m3examples/fail_proc_mutex_deferred_lock_m3examples/fail_proc_mutex_deferred_unlock_m3examples/fail_proc_mutex_double_paren_m3examples/fail_proc_mutex_guard_await_m3examples/fail_proc_mutex_guard_move_m3examples/fail_proc_mutex_in_core_m3examples/fail_proc_mutex_in_memo_m3examples/fail_proc_select_empty_m3examples/fail_proc_select_in_core_m3examples/fail_proc_select_in_memo_m3examples/fail_proc_select_old_form_m3
P4: eventuals
Positive:
examples/proc_async_await_m4examples/proc_await_error_m4examples/proc_evt_named_m4examples/proc_evt_lifetime_m4
Negative:
examples/fail_proc_evt_return_elided_m4examples/fail_proc_evt_param_elided_m4examples/fail_proc_evt_detached_m4examples/fail_proc_evt_embedded_m4examples/fail_proc_async_in_core_m4examples/fail_proc_async_break_outer_m4examples/fail_proc_async_edf_await_m4examples/fail_proc_async_indirect_m4examples/fail_proc_async_in_memo_m4examples/fail_proc_async_nested_capture_m4examples/fail_proc_async_recoverable_discard_m4examples/fail_proc_async_recoverable_overwrite_m4examples/fail_proc_async_recoverable_unawaited_m4examples/fail_proc_await_in_core_m4examples/fail_proc_await_in_memo_m4examples/fail_proc_await_recoverable_discard_m4
Eventuals
Eventuals are a V3 processor feature and are std-only. They use operating-
system threads; FOL does not use Rust async/await, futures, Tokio,
continuations, or colored routines.
The package therefore selects fol_model = "memo" for the artifact and
declares the bundled internal standard dependency. That declaration enables
the processor API; it is not what makes the executable runnable.
The chosen pipe surface is:
var pending = calculate() | async;
var value = pending | await;
| async starts the call on an OS thread and produces a one-shot eventual.
| await blocks the current OS thread until that computation finishes. A local
binding may name the eventual with its lexical lifetime elided (V3_MEM §8.1):
evt[T] for an infallible call and evt[T / E] for a recoverable one, e.g.
var work: evt[int] = compute(7) | async. The public lifetime-carrying
evt[L, T] spelling is also a namable type, naming the region the eventual
belongs to. An eventual that crosses a routine signature must spell that
lifetime on both sides: a parameter or return type declares L: lif and
writes evt[L, T], and the lifetime name must resolve to a declared lifetime
parameter. The elided evt[T] form is local-declaration shorthand only.
This signature rule is what makes L enforceable without a region solver.
Handles cannot be embedded in aggregates or wrappers, sent through channels,
stored at module level, captured by routine values, or enter detached tasks —
so the only way a handle travels is through signatures and local moves.
Requiring the evt[L, T] spelling wherever a signature carries one means a
handle provably cannot outlive the parent scope L names.
The call to the left of | async must resolve directly to a named routine
declaration. Both calculate() and a qualified call such as
workers::calculate() are supported; qualification selects a declaration and
does not introduce runtime dispatch. Stored routine values, stored anonymous
routines, and routine parameters are indirect calls and are not async task
targets in V3. Receiver-method call syntax is also excluded from the task
target surface; call a free named routine directly instead.
Nested routine bodies cannot implicitly capture outer locals. Pass ordinary outer values through declared parameters instead of relying on a hidden closure environment. An outer eventual cannot use that workaround because generic crossings are forbidden; await and handle it in the outer routine.
Eventual bindings are move-only. A plain binding or assignment transfers the
eventual and makes the source binding unavailable. An eventual may be consumed
at most once: | await consumes the current binding, so a second await is a
use-after-consume error. An infallible eventual need not be awaited and is joined
at process exit; a recoverable eventual has the stronger must-handle rule below.
V3 does not embed eventuals in composite values or pass them through generic
parameters, because generic bodies do not yet carry a move-only contract. Await
the value before crossing either boundary.
Arguments sent into | async obey the same thread boundary as [>]: borrowed
values, ptr[shared, T], and unresolved generic parameters cannot cross it.
Omitted defaults are arguments too and are checked against that boundary before
the task starts. A generic call remains allowed when inference produces only
concrete thread-safe parameter types.
Thread-safe move-only results can cross back through an eventual. The producer
transfers the result into the eventual and | await transfers it to the
awaiting routine; neither step clones it. Unique pointers are one shipped
example of this rule. A result containing ptr[shared, T] is still rejected
because its Rc representation cannot cross the OS-thread boundary.
Error behavior stays identical to the synchronous call. An infallible call
awaits to T. A routine declared as T / E remains recoverable after await and
must be handled with the existing check(...) or || surfaces. Async and await
do not introduce a second error channel. A recoverable eventual is a must-handle
value: its final owner must await it and immediately handle the result. Moving it
to another binding moves that obligation. Lexical fallthrough and any break,
return, or report that exits a scope with a live obligation are rejected. At
a branch join, all continuing branches must leave compatible state: they may
all preserve the same owner for handling after the join, transfer consistently,
or discharge the obligation. Consuming or transferring on only some paths is
rejected. Dropping it as a statement, overwriting it while live, or discarding
the awaited result is also rejected.
| await is not allowed inside edf, and an existing eventual binding cannot
be accessed there. Error-only deferred cleanup does not run on normal exits, so
it cannot be used to discharge eventual ownership. This restriction remains in
force through nested blocks, including a dfr declared inside the edf. Await
or transfer the value in ordinary control flow instead.
Program exit joins outstanding async work just as it joins bare [>] tasks.
This includes an infallible eventual that was created and then never
awaited: it is not detached merely because no source-level binding consumes it.
Joining is not error handling, so a recoverable eventual cannot use that path to
discard its error. Cancellation, worker pools, and runtime scheduling controls
are not part of V3.
An eventual is a one-shot task result, not a resumable generator or coroutine.
V3 does not execute generator yield; that statement remains recognized by
the parser but is rejected by semantic analysis and lowering. async also does
not color the called routine or change its declaration.
The eventual slice is implemented end to end. Its exact positive (including the
namable evt[T] local), tier-failure, and must-handle failure example set is
maintained in the
canonical shipped processor inventory.
Tasks, Channels, and Mutexes
The processor surface is a V3 systems feature. Every processor construct is
std-only: a package must declare the bundled internal standard dependency.
Choosing core or memo alone does not provide threads or hosted services.
The artifact itself uses fol_model = "memo"; there is no std model.
This dependency gates processor APIs, not process execution: std-free core
and memo artifacts may still run on a compatible host.
Spawn
[>]call() starts one operating-system thread and lets the current routine
continue. FOL has no worker pool, scheduler settings, Rust async runtime, or
Tokio dependency. Every spawned task is registered with the process and the
program joins all outstanding tasks before its entry point exits.
use std: pkg = {"std"};
fun[] worker(): int = {
return std::io::echo_int(17);
};
fun[] main(): int = {
[>]worker();
return 0;
};
A bare spawn is always fire-and-forget: [>]call() creates no eventual and
cannot be awaited. It must therefore call an infallible routine. To run a
recoverable routine asynchronously, omit [>], write call() | async, then
consume the eventual with | await and handle the result with check(...) or
||.
The call form must resolve directly to a named routine declaration. Both an
unqualified call such as [>]worker() and a qualified call such as
[>]workers::worker() are supported. Stored routine values, stored anonymous
routines, and routine parameters remain indirect calls and are not spawn
targets in V3. The explicit zero-parameter anonymous spawn form carries an
explicit capture list: channel sender endpoints (c[tx]) and value operations
(data[mov], amount[cpy], record[cln]) thread their captures into the
task, and state[bor] lends the outer binding to the scoped task: the owner
stays readable but is frozen — no mutation, no transfer — until the scope
joins its tasks. Borrowed captures require a clone-safe, thread-safe owner. Receiver-method call
syntax is not a named spawn-target form; use a free routine name or qualified
path instead.
The spawn boundary follows the V3 memory rules:
- clone-safe values clone into the task
- thread-safe move-only values, including
@ownership and unique pointers, move into the task and leave the sender moved-out - borrowed values cross only as explicit
[bor]captures of a scoped task, which freeze their owner until the scope joins ptr[shared, T]values do not cross the boundary because theirRcbacking is not thread-safe- unresolved generic parameters do not cross until FOL has a thread-safety and lifetime contract for generics; concrete thread-safe instantiations can cross
- omitted defaults are checked as task arguments under the same rules as explicit arguments
Cross-thread shared mutation belongs to mux[T] parameters, not Rc.
The exact positive and negative spawn examples are maintained in the canonical shipped processor inventory.
Channels
Channels are an implemented processor surface. The contract is an unbounded
MPSC chn[T] backed by std::sync::mpsc: c[tx] sends without blocking,
c[rx] performs a blocking pull that yields opt[T] — its present branch owns
a fresh payload and nil means every sender has closed — and receiver iteration
runs until all sender handles are dropped. Unwrap the result with c[rx][] or
bind it and inspect it with when ... on ... *. The old sequence-index spelling
c[rx][i] is not part of the contract.
A send value | c[tx] produces a must-handle err[T]: nil means the payload
was delivered, and the present branch owns the unsent payload when every
receiver has already closed. Bind it (var sent: err[int] = value | c[tx]),
inspect it with when ... on ... *, or propagate it; a bare send that discards
the result — and with it the unsent payload — is rejected.
A transmitter is a first-class value. c[tx] has type chn[tx, T], a
clone-capable sender endpoint that may be bound, passed to another routine, or
returned. Sending works through any sender value — value | sender — not only a
direct c[tx] access, so a producer can accept a chn[tx, T] parameter and emit
into it. The receiver endpoint is also a first-class chn[rx, T] value, but
unlike the cloneable sender it is unique and move-only: it may be bound, passed,
or returned, yet never duplicated, so a channel keeps exactly one receiver.
T may be a thread-safe move-only value. Sending consumes that value, and a
blocking receive, receiver iteration, or selected arm transfers the payload to
its destination without cloning it; the blocking receive delivers it inside the
opt[T] shell. Unique pointers are supported this way; values containing
ptr[shared, T] remain barred from OS-thread boundaries.
The first c[rx] acquisition relinquishes that channel binding’s local
transmitter capability. Clone or capture every needed c[tx] handle before
receiving. Sender handles acquired earlier remain valid and the channel closes
when the last of those handles is dropped; trying to acquire c[tx] after the
receiver is active is an ownership error, not a runtime panic. This explicit
endpoint lifecycle lets pull loops and select observe closure without keeping
an invisible transmitter alive.
Channel endpoint acquisition is not allowed inside dfr or edf, including
endpoint captures on anonymous spawned routines in a deferred body. Acquire all
sender handles before entering the deferred block, and keep receiver operations
in ordinary control flow; delayed endpoint acquisition cannot be ordered safely
against the first receiver acquisition.
V3 endpoint access is restricted to a direct local, parameter, or capture
binding owned by the current routine. Projected channel fields, channel
container elements, and implicit references to an outer routine’s channel are
not part of the shipped lifecycle model; keep the channel in a direct binding
before using [tx], [rx], iteration, or select. Top-level/global channel
bindings are also rejected; a channel belongs to the routine that owns its
receiver.
Full chn[T] values also cannot be embedded in records, entries, containers,
or ownership/error/optional wrappers in V3. This keeps the single receiver and
its endpoint lifecycle attached to one direct routine-local binding. Sender
handles that were acquired earlier remain independently cloneable.
Anonymous routines cannot declare chn[T] parameters in V3 because they do not
participate in named-routine sender/receiver effect refinement. Use a named
routine, or capture an already-existing sender explicitly with c[tx].
Spawned anonymous routines can clone a transmitter explicitly with
[>]fun()[c[tx]] = { ... }. Receiver endpoints are not cloned: MPSC retains a
single consuming side.
The exact positive, ownership-boundary, lifecycle, and tier-failure channel examples are maintained in the canonical shipped processor inventory.
Select
The chosen multiplexing form is a multi-arm statement:
select {
when first as value { consume(value); }
when second as value { consume(value); }
};
The old single-channel select(channel as name) { ... } form is not retained.
The runtime polls arms in source order with try_recv(). Closed arms are
skipped and a blocking select completes, continuing after the statement, when
all arms are closed. An optional * arm runs immediately when no receiver is
ready. Simultaneously-ready arms therefore have source-order bias in V3; no
fairness guarantee is promised.
A blocking select without * must contain at least one channel arm.
select {} is invalid and is rejected during typecheck rather than being left
for lowering. A default-only select is non-blocking because its * body runs
immediately.
Mutex parameters
The mutex is the first-class managed type mux[T]. The direct handle surface
locks and unlocks through the handle itself:
fun[] update(value: mux[Counter]): int = {
value.lock();
value.total = value.total + 1;
var result: int = value.total;
value.unlock();
return result;
};
mux[T] lowers to Arc<Mutex<T>>. A routine acquires the guard with .lock();
guarded fields are accessible only while that guard is active. The guard is
released automatically at the end of the lexical scope that acquired it, or
early with .unlock() in that same scope. Locking the same parameter twice is
rejected, as is unlocking without a guard acquired in the current lexical
scope. The historical ((name)) parameter spelling is not retained.
Mutex field access and .lock()/.unlock() are not allowed inside dfr or
edf bodies in V3. Guard transitions are tracked for immediate lexical
execution and are not replayed as delayed effects at scope exit. Forwarding a
mutex handle from a deferred body to another mux[T] routine is rejected for
the same reason.
The guarded T cannot be copied, returned, embedded, or passed to an ordinary
T parameter as a whole value. Passing the mutex handle directly to another
mux[T] parameter is allowed, including through spawn; data access still
requires that receiving routine to acquire its own guard.
Wrapping an existing owner into a mux[T] parameter transfers it into the
mutex and consumes the binding: the call site must state [mov]owner, and the
original binding is unusable afterward. An implicit copy at that boundary
would silently fork the state between the caller’s binding and the guarded
value, so it is rejected even for copy-safe types. Fresh values (record
literals or call results) pass without an operation — there is no surviving
source to fork.
For a synchronous call, the caller must unlock a handle before forwarding it
to another mux[T] parameter; otherwise the callee could block trying to acquire
the caller’s guard. One call also cannot pass the same handle to two mux[T]
parameters because those aliases can self-deadlock. Spawn and async calls are
task boundaries and may receive the cloned handle while the caller still holds
its guard; the new task waits until the caller releases it.
Guard values
Locking a borrowed handle produces a named, lifetime-scoped mutable guard
bound with var[mut, bor]. Field access goes through the guard while the lock
is held:
fun[] bump(state: mux[Counter]): int = {
var[mut, bor] guard: Counter = ([bor]state).lock();
guard.value = guard.value + 1;
var snapshot: int = guard.value;
[end]guard;
return snapshot;
};
The guard releases at its last use (the same non-lexical rule as ordinary
loans), or early and explicitly with [end]guard. A guard value cannot be
moved, copied, or cloned, and it cannot be held across a spawn, an await, or a
blocking receive — the checker rejects those boundaries rather than attempting
static deadlock analysis. End the guard first, then cross the boundary
(examples/proc_mutex_guard_end_m3 shows the early-end pattern;
examples/fail_proc_mutex_guard_await_m3 and
examples/fail_proc_mutex_guard_move_m3 pin the rejections).
The mux[T] calling convention is currently available only on named routines
that are called directly, through either unqualified or qualified names. A
named routine with mux[T] parameters cannot be stored or passed as a
first-class routine value, and anonymous routines cannot declare mux[T]
parameters. Those routine-value forms do not yet retain the mutex ABI metadata
needed to preserve Arc<Mutex<T>>; use a named direct call instead.
Despite this chapter’s historical filename, the shipped surface is OS-thread
tasks, channels, select, and mutexes—not resumable coroutines or generators.
Generator yield is parser-recognized but remains rejected by typecheck and
lowering in V3.
The exact positive, removed-syntax, deferred-effect, and tier-failure examples
for select and mutexes are maintained in the
canonical shipped processor inventory.
Interop Toolchain Boundary
The repository-wide hardening gate is complete for the initial certified
x86_64-unknown-linux-gnu lane. The locked H7 Make gate passes in FOL CI
through clean, exact PARC, LINC, and GERC commits. This unblocks the first
broader V4 milestone; it does not complete FOL V4’s broader language-level
interop milestones.
FOL integrates three independently usable sibling crates and does not copy their native semantics:
build.fol executable + one C header/object import
-> PARC CompleteSourcePackage
-> LINC ValidatedLinkAnalysis
-> GERC GenerationBundle
-> fol-build generated-file action graph
-> fol-backend auxiliary Rust crates and exact rustc arguments
-> linked and executed FOL binary
The stages have fixed ownership:
- PARC is the only C preprocessor, parser, recovery engine, source extractor, provenance store, and source-contract owner.
- LINC is the only native artifact inspector, compiler/ABI probe runner, symbol/provider validator, and ordered link-evidence owner.
- GERC is the only closed-world raw Rust FFI projector and emitter.
- FOL owns language policy, target and build-graph routing, generated-file materialization, the narrow H7 call anchor, backend process invocation, diagnostics, and eventual safe language wrappers.
The handoff is typed. FOL does not use JSON shape conversion, copied sibling
models, a second provider resolver, a second raw extern "C" emitter, shell
splitting, or text link-argument parsing. GERC’s typed link atoms remain
individual native process arguments when they reach rustc.
Locked inputs
The checked root interop.lock.toml is the machine authority. H7 is certified
against this exact snapshot:
| Stage | Package | Contract | Locked revision |
|---|---|---|---|
| PARC | follang-parc 0.16.0 | source package schema 2 | 0f52aeeeeec47a082c0d8a515130ee853aa1101d |
| LINC | follang-linc 0.1.0 with native-inspection | link-analysis schema 2 | c874d5b0332249524422d9d08c35b3d4edd7e3fa |
| GERC | follang-gerc 0.1.0 with pipeline-native | generation domain 1 | 000c1f6c12fba99f1a157267ee7db39d42bda8e3 |
The lock also freezes the GERC H5 compatibility driver, fixtures, and support
code under digest
13644fd1f6ad3f1de06338e5bd415604dbedc9b6baaaaed8a63f44515db004e7.
Cargo.lock alone cannot record Git identities for path dependencies.
The production H7 route therefore observes each compiled sibling path’s
canonical Git root, HEAD, worktree status, and normalized origin before any
sibling API runs. Revision values enter the evidence report only after those
runtime checks match the compiled lock identities.
Certified platform
The only promoted lane is:
x86_64-unknown-linux-gnu
ELF, LP64
explicit GCC executable and observed compiler identity
one executable artifact with one C object provider
The caller supplies normalized absolute paths for GCC and the bounded LINC probe workspace. LINC observes and fingerprints the compiler rather than FOL guessing its identity. The selected FOL target must equal every sibling target fingerprint before generated files or backend compilation are allowed.
Linux musl, other Linux architectures, Apple targets, Windows targets, frameworks, import libraries, multiple imports, and the general C type/API surface are not certified by H7. Clang remains sibling differential evidence; it is not the compiler for FOL’s promoted H7 lane.
Evidence and failure policy
The required smoke test starts from the real build.fol graph route. It
compiles a C provider object, scans its header through PARC, certifies the
provider through LINC, projects raw Rust through GERC, materializes the raw and
FOL-owned anchor crates through fol-build, passes exact ordered link
arguments to fol-backend, and runs the linked executable. Its reported
evidence contains:
- the three locked sibling revisions;
- source, target, link-analysis, generation, and provider fingerprints;
- the exact certified target.
The checked build separately retains the exact generated raw-binding and anchor crate roots passed to the backend. The system test inspects the fixed anchor source, builds both crates, executes the final binary, and verifies the provider’s per-run return value.
Required negative cases prove that partial PARC source, unresolved LINC
providers, and GERC generation rejection stop before generated/backend files
are written. Target mismatch is rejected before compiler or output-directory
I/O. A skipped system test is not success: the required Make target sets
FOL_H7_REQUIRED=1 and supplies an explicit canonical GCC path.
Verification commands
Run these on GNU/Linux from the FOL root with parc, linc, and gerc as
sibling checkouts:
make interop-check interop-locked test-interop
make interop-checkchecks lock shape, package/schema/feature compatibility, the H5 corpus identity, and compilation of the typed integration. It is the development check and does not require siblingHEADs to equal the lock.make interop-lockedadditionally requires each sibling to have the exact lockedHEAD, canonical GitHub origin, and a clean worktree.make test-interopdepends on the locked check, requires Linux and GCC, and runs the positive and fail-closed native H7 tests without an optional-skip path.
CI checks out FOL plus all three repositories in that sibling layout, installs
Rust 1.89.0, and invokes the same Make-owned locked smoke gate. Changing a
sibling revision requires changing interop.lock.toml, compiled lock
constants, CI checkout refs, compatibility evidence, and this snapshot
together.
With repository-wide hardening complete, this boundary unblocks the first broader V4 work. It does not itself expose general foreign declaration syntax, general pointers or ownership, C export, bounded header-import tooling, C++ ABI support, Rust facade generation, or a stable Rust binary ABI.