Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. lexical structure
  2. statements and expressions
  3. metaprogramming
  4. types
  5. declarations and items
  6. modules and source layout
  7. errors
  8. sugar and convenience forms
  9. conversions
  10. memory model
  11. 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:

binaryrolewhere it lives
folthe toolchain manager — the only binary on your PATH, the only thing a distribution packages/usr/bin/fol (or anywhere on PATH)
folcthe toolchain engine — compiler, build graph, package system, LSPinside 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:

  1. $FOL_HOME if the environment variable is set — the shared, machine-wide home (for example export FOL_HOME="$HOME/.fol").
  2. Otherwise, <project>/.fol/toolchain — the manager walks up from the current directory to the nearest build.fol and keeps toolchains next to the project’s build artifacts, inside the .fol/ directory every project already has.
  3. If neither exists (no variable, not inside a project), fol stops 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:

  1. fol +0.1.4 code build — explicit one-shot override, +<version|name>
  2. FOL_TOOLCHAIN=dev — environment override
  3. the //fol <version> pin in the nearest build.fol
  4. the default recorded in $FOL_HOME/config (fol self default …)
  5. 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> — the fol binary itself, ready to drop on PATH

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.gz
  • fol-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. folc finds 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. folc emits 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:
    • fun
    • pro
    • typ
    • use
  • Punctuation is written literally:
    • (
    • )
    • [
    • ]
    • {
    • }
    • :
    • =
  • Placeholder names are descriptive:
    • name
    • type
    • expr
    • body
    • source

For example:

fun[options] name(params): return_type = { body }

means:

  • fun is a literal keyword
  • options is a placeholder for zero or more routine options
  • name is the declared routine name
  • params is a parameter list
  • return_type is a type reference
  • body is 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 loc and pkg

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 mode
  • fol_model = "memo" adds heap-backed values such as str, vec, seq, set, and map, but still does not expose hosted APIs
  • a package whose memo artifact needs shipped hosted APIs declares build.add_dep({ alias = "std", source = "internal", target = "standard" }) and imports that alias with use 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, and log
  • 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, or seg

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.fol package entry files for new projects
  • root discovery
  • package preparation through fol-package
  • git-backed dependency fetching and materialization
  • fol.lock writing, locked fetches, offline warm-cache fetches, and update flows
  • workspace dependency/status reporting
  • build/run/test orchestration for shipped V1, the bounded V2 subset, and the shipped V3 surfaces
  • routed workspace build/run/test/check entry through build.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.fol through 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 fol frontend 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-package
  • fol-resolver
  • fol-typecheck
  • fol-lower
  • fol-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 bundled std because its generated source uses hosted std.io
  • a bare executable may use core or memo and does not need bundled std
  • 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 / test remain 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 work
  • fol pack
  • fol code
  • fol tool

Root aliases are single-letter only:

  • fol w
  • fol p
  • fol c
  • fol 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:

  • human
  • plain
  • json

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 init
  • fol work new
  • fol work info
  • fol work list
  • fol work deps
  • fol 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 --bin creates a memo binary whose generated source uses hosted std.io
  • the generated build.fol explicitly declares bundled std through build.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 core and memo packages may also use fol code run and fol code test

Pack

Package acquisition commands:

  • fol pack fetch
  • fol 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 check
  • fol code build
  • fol code run
  • fol code test
  • fol code emit rust
  • fol code emit lowered
  • fol 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:

  • core and memo artifacts can run and test without bundled std
  • declaring bundled std only makes hosted source APIs available to a memo artifact; 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 body
  • plain: code: / family: / title: lines followed by the body
  • json / --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 lsp
  • fol 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 clean
  • fol 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=ok for a tree with no ERROR or missing nodes, otherwise parse_status=ERROR
  • root kind plus total and named node counts
  • exact error_count and missing_count values
  • 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 lsp
  • fol 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 clean
  • fol 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 .fol files 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.fol follows 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-package
  • fol-resolver
  • fol-typecheck
  • fol-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:

  • parse returns the real S-expression, node counts, and recovered ERROR/missing-node ranges
  • highlight executes the shipped highlight query and returns every actual capture with its zero-based range and source text
  • symbols executes 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:

  1. compiler truth
  2. semantic editor services
  3. syntax editor services

Compiler truth lives in crates such as:

  • fol-lexer
  • fol-parser
  • fol-package
  • fol-resolver
  • fol-typecheck
  • fol-intrinsics
  • fol-diagnostics

Semantic editor services live in:

  • fol-editor LSP 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_model plus 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-typecheck instead 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:

  1. compiler stage creates a structured fol_diagnostics::Diagnostic
  2. editor tooling adapts that diagnostic for the active editor protocol
  3. 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:

  1. Does the lexer need new tokens or token families?
  2. Does the parser need new syntax or AST nodes?
  3. Does resolver or typechecker logic need new meaning?
  4. Do lowering, runtime, or backend transfer/execution rules change?
  5. Does frontend artifact routing or capability evaluation change?
  6. Does the feature introduce or change diagnostics and explanations?
  7. Does the formatter or a public fol tool inspection command need an update?
  8. Does the LSP need hover, completion, definition, symbols, tokens, positions, or artifact-scope updates?
  9. Does Tree-sitter need grammar, query, or executable corpus updates?
  10. Can any new editor-visible facts be generated from compiler-owned sources?
  11. 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 (memo plus bundled std) 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-editor owns 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.scm
  • queries/fol/locals.scm
  • queries/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:

FilePurposeOwner
tree-sitter/grammar.jsGrammar rules, precedence, conflictsEditor/syntax maintainer
queries/fol/highlights.scmHighlight capture groups and query patternsEditor/syntax maintainer
queries/fol/locals.scmScope and definition trackingEditor/syntax maintainer
queries/fol/symbols.scmSymbol navigation capturesEditor/syntax maintainer
test/corpus/*.txtCorpus programs plus their expected node treesEditor/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:

FactHandwritten LocationCompiler Source
Builtin type nameshighlights.scm regex ^(int|bol|...)$BuiltinType::ALL_NAMES in fol-typecheck
Dot-call intrinsic nameshighlights.scm regex ^(len|echo|...)$Implemented DotRootCall entries in fol-intrinsics
Container type nameshighlights.scm node labels + grammar.js choiceCONTAINER_TYPE_NAMES in fol-parser
Shell type nameshighlights.scm node labels + grammar.js choiceSHELL_TYPE_NAMES in fol-parser
Source kind nameshighlights.scm node labels + grammar.js choiceSOURCE_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 seg or lab declaration)
  • 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_intrinsic grammar 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 @keyword to @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 FileCovers
declarations.txtuse, ali, typ, fun, log, var declarations
expressions.txtIntrinsic calls, when/loop control flow, break/return
recoverable.txtError propagation (/), report, pipe-or (||)
v3_ownership.txtowned allocation, borrow options, ~var, and give-back
v3_pointers.txtnested ptr[...] / chn[...] types, @ types, address-of, and dereference
v3_deferred.txtdfr and error-only edf blocks
v3_channels_select_mutex.txtspawn, channel endpoints, multi-arm select, and mux[T] parameters
v3_eventuals.txtspawn plus | async and | await stages
v3_lexical_boundaries.txtmultiline 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:

  1. Lexer — new keywords, operators, tokens
  2. Parser — new AST nodes, syntax rules
  3. Semantics — resolver, typecheck, lowering, intrinsics
  4. Runtime/backend — representation, transfer, cleanup, execution
  5. Frontend/build routing — artifact model, active dependencies, command legality
  6. Diagnostics — codes, explanations, labels, related sites
  7. Formatter/tool commands — protected text, parse/highlight/symbol output
  8. LSP — hover, completion, definition, symbols, positions
  9. Tree-sitter — grammar, queries, corpus
  10. Generated facts and inventories — compiler-owned constants, exact examples
  11. Docs and book — language chapters, tooling pages, boundary claims
  12. 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-resolver
  • fol-typecheck
  • fol-lower
  • fol-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-lower
  • fol-runtime
  • fol-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-package when graph truth changes
  • fol-frontend routing 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.fol declarations
  • 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-diagnostics contract tests
  • editor/LSP diagnostic adapter tests if the visible shape changes
  • docs under 650_errors if 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, or symbols expose 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-editor semantic analysis
  • fol-editor semantic 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.js
  • queries/fol/highlights.scm
  • queries/fol/locals.scm
  • queries/fol/symbols.scm
  • tree-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:

  1. Is compiler meaning implemented?
  2. Does lowering/runtime/backend preserve that meaning on every control-flow path?
  3. Does frontend routing select the exact evaluated artifact capability?
  4. Are diagnostics coded, explained, structured, and location-correct?
  5. Do formatter and public tool commands understand the new syntax boundaries?
  6. Does the LSP reflect the meaning, model, ranges, and UX where needed?
  7. Does Tree-sitter reflect the syntax in grammar, queries, and executable corpus?
  8. Did we generate shared facts and reuse one canonical example inventory?
  9. Do positive examples run and deliberate boundaries fail with the expected code?
  10. 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/con bindings without an explicit annotation)
  • folding ranges (brace blocks)
  • selection ranges (word -> enclosing block -> file)
  • document symbols
  • workspace symbols across discovered .fol files 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:

  1. receive editor request/notification
  2. map the open document to a package/workspace context
  3. materialize an analysis overlay for the in-memory document state
  4. run parse/package/resolve/typecheck as needed
  5. 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 prepareRename enforcing the same eligibility
  • semantic tokens for semantic identifier categories
  • inferred-type inlay hints for unannotated var/lab/con bindings
  • brace-block folding ranges and word/block/file selection ranges
  • document symbols for the current file
  • workspace symbols across discovered .fol files 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_m1m2
    • examples/generic_standard_constraint_m1m2
    • examples/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 core or unhosted memo

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:

  1. Neovim opens a .fol file
  2. Tree-sitter handles syntax/highlighting
  3. Neovim launches fol tool lsp
  4. 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)
  • -D CLI 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 interface
  • semantic.rs — method signatures and type info for the resolver and typechecker
  • stdlib.rsBuildStdlibScope: the ambient scope injected into build.fol
  • executor.rs — executes the lowered FOL IR against the build graph
  • eval.rs — evaluate a build.fol from source; entry point for fol-package
  • option.rs — build option kinds, target triples, optimize modes
  • runtime.rs — runtime representation of artifacts, generated files, step bindings
  • step.rs — step planning, ordering, cache keys, execution reports
  • codegen.rs — system tool and codegen request types
  • artifact.rs — artifact pipeline definitions and output types
  • dependency.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 -D flags
  • 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 handle
  • build.export_*({...}) declares the build-facing surface a package chooses to expose
  • graph.file_from_root(...) and graph.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_exports
  • examples/build_source_paths
  • examples/build_dep_modes
  • examples/build_described_steps
  • examples/build_generated_dirs
  • examples/build_dep_handles
  • examples/build_output_handles
  • examples/build_install_prefix
  • examples/build_system_lib
  • examples/build_system_tool

Runtime-model reminder:

  • examples that rely on hosted language APIs such as .echo(...) or V3 processor facilities should spell fol_model = "memo" and declare bundled std
  • core and memo examples may be executable and use routed run / 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 / test reject foreign targets even though those targets may be built

Bundled std reminder:

  • std ships with FOL under lang/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 .fol files
  • It has one implicit build stdlib scope, exposing .build() and build-only handle methods
  • It can define local helper fun[], pro[], and typ declarations
  • Those local declarations are not exported to the package
  • It must declare exactly one pro[] build(): non entry

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 context
  • build.meta({...}) for package metadata
  • build.add_dep({...}) for one direct dependency
  • build.export_module({...}), build.export_artifact({...}), build.export_step({...}), and build.export_output({...}) for the dependency-facing build surface this package exposes
  • build.graph() for artifact and step graph work

Capability selection and dependency declaration are separate:

  • each artifact selects fol_model = "core" or fol_model = "memo"; omitting the field currently selects the default memo model
  • a memo source scope that uses hosted names declares bundled std with build.add_dep({ alias = "std", source = "internal", target = "standard" })
  • std is not a valid fol_model, and the dependency is not needed merely because an artifact is executable or registered with graph.add_run
  • fol code run and fol code test launch 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/pro helpers in build.fol and calling them from build() 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, and case in build.fol are 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:

  • name
  • version
  • kind
  • description
  • license

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:

  • alias
  • source
  • target
  • version
  • hash
  • mode
  • args

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:

  • target is only the repository locator
  • version chooses branch:<name>, tag:<name>, or commit:<sha>
  • hash is optional commit-prefix verification
  • selector query params in target are 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:

  • eager
  • lazy
  • on-demand

Current semantics:

  • eager direct package-store dependencies are prepared immediately during package loading
  • lazy dependency metadata is kept, but package-store preparation is deferred until a later build/import path needs it
  • on-demand same deferred loading rule as lazy, 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_exe
  • add_static_lib
  • install
  • add_run
  • standard_target

Named steps may also carry an optional description:

var docs = graph.step("docs", "Generate documentation");
  • standard_optimize
  • write_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:

  • core no heap and no source-level hosted OS/runtime APIs
  • memo alloc-like heap-backed facilities, still no source-level hosted OS/runtime APIs

The division deliberately follows Rust’s capability shape:

  • FOL core is analogous to #![no_std] with Rust core
  • FOL memo is analogous to #![no_std] with core plus alloc
  • FOL memo plus the bundled standard dependency is analogous to hosted Rust std

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:

  • core artifacts must not use heap-backed str, vec, seq, set, or map
  • core artifacts may still use arrays, records, routines, control flow, and dfr
  • memo artifacts may use heap-backed runtime types but not hosted language APIs
  • bundled std wrappers require an explicit internal standard dependency
  • hosted wrappers and processor features require fol_model = "memo" plus that explicit bundled std dependency
  • executable core and memo artifacts may use fol code run and fol code test without bundled std
Source requirementfol_modelBundled standard dependency
no heap-backed or hosted language APIscoreno
heap-backed values, no hosted APIsmemono
shipped hosted or processor APIsmemoyes

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:

  • core already means “no heap and no source-level hosted OS/runtime APIs” at the language and runtime-contract level
  • core artifacts are still emitted through the current Rust backend pipeline
  • core artifacts can execute through that pipeline without bundled std
  • that means core is 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_model
  • target
  • optimize

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:

  • name required library or framework name
  • mode optional, defaults to dynamic, also accepts static
  • framework optional bool, defaults to false
  • search_path optional 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:

  • target
  • optimize
  • 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_dir
  • build.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:

KindDescriptionCLI Example
boolBoolean flag-Dstrip=true
intInteger value-Djobs=4
strArbitrary string-Dprefix=/usr/local
enumOne of a fixed set of strings-Dbackend=llvm
pathFile or directory path-Droot=src/main.fol
targetTarget triple-Dtarget=x86_64-linux-gnu
optimizeOptimization 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:

  • tool required tool name
  • args optional string arguments
  • file_args optional source-file or generated-output handles passed as file arguments
  • env optional string-to-string environment map
  • output required 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:

  • SourceFile
  • SourceDir
  • GeneratedFile
  • 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.

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:

  • eager
  • lazy
  • on-demand

Current behavior:

  • eager prepares package-store dependencies up front during formal package loading
  • lazy keeps the declaration and defers package-store preparation until a later import/build path needs it
  • on-demand preserves 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:

TripleMeaning
x86_64-linux-gnux86-64 Linux with glibc
x86_64-linux-muslx86-64 Linux with musl libc
aarch64-linux-gnuARM64 Linux with glibc
aarch64-linux-muslARM64 Linux with musl libc
x86_64-macos-gnux86-64 macOS
aarch64-macos-gnuARM64 macOS
x86_64-windows-gnux86-64 Windows with MinGW
x86_64-windows-msvcx86-64 Windows with MSVC
aarch64-windows-msvcARM64 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:

ModeMeaning
debugNo optimization, full debug info
release-safeOptimized with safety checks
release-fastMaximum speed, no safety checks
release-smallMinimize 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

KindExample CLIDefault type
bool-Dverbose=truefalse
int-Djobs=80
str-Dprefix=/usr""
enum-Dbackend=llvmfirst value
path-Droot=src/main.fol""
target-Dtarget=x86_64-linux-gnuhost target
optimize-Doptimize=release-fastdebug

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 in build.fol and calling them from build() 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, and case build 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.

KindMethodOutput
Executablegraph.add_exeBinary
Static librarygraph.add_static_lib.a / .lib
Shared librarygraph.add_shared_lib.so / .dylib
Test bundlegraph.add_testRunnable 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

KindMethodDescription
Writegraph.write_fileWritten with literal string contents
Copygraph.copy_fileCopied from a source path
Tool outputgraph.add_system_toolProduced by an external tool
Tool dirgraph.add_system_tool_dirProduced as a generated directory
Codegengraph.add_codegenProduced by the FOL codegen pipeline
Codegen dirgraph.add_codegen_dirProduced as a generated directory
Captured runrun.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:

  1. --target
  2. artifact target declared in build.fol
  3. 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-gnu
  • x86_64-linux-musl -> x86_64-unknown-linux-musl
  • aarch64-linux-gnu -> aarch64-unknown-linux-gnu
  • aarch64-linux-musl -> aarch64-unknown-linux-musl
  • x86_64-windows-gnu -> x86_64-pc-windows-gnu
  • x86_64-windows-msvc -> x86_64-pc-windows-msvc
  • aarch64-windows-msvc -> aarch64-pc-windows-msvc
  • x86_64-macos-gnu -> x86_64-apple-darwin
  • aarch64-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 build supports host and non-host targets
  • fol code emit rust stays available for source inspection
  • fol code run is host-only
  • fol code test is 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 context
  • build.meta({...}) for package metadata
  • build.add_dep({...}) for direct dependencies
  • build.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:

  • SourceFile
  • SourceDir
  • GeneratedFile
  • 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:

  • tool
  • args
  • file_args
  • env
  • output

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 Graph or Build types
  • 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.

codedescription
\pplatform specific newline: CRLF on Windows, LF on Unix
\r, \ccarriage return
\n, \lline feed (often called newline)
\fform feed
\ttabulator
\vvertical tabulator
\\backslash
\“quotation mark
\’apostrophe
\ ‘0’..‘9’+character with decimal value d; all decimal digits directly following are used for the character
\aalert
\bbackspace
\eescape [ESC]
\x HHcharacter with hex value HH; exactly two hex digits are allowed
\u HHHHunicode 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 0x or 0X and then uses hex digits with optional separating underscores.
  • An octal literal starts with 0o or 0O and then uses octal digits with optional separating underscores.
  • A binary literal starts with 0b or 0B and 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:

brackettypepurpose
{ }Curly bracketsCode blocks, Namespaces, Containers
[ ]Square bracketsType options, Container acces, Multithreading
( )Round bracketsCalculations, 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 (with while, for, and each as 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:

  • case and is arms (plus the required * default) are the current V1 surface, in both statement bodies and the arrow expression form (is 3 -> 7;)
  • a when with no case/is arms is a statement-only boolean gate: its default body runs only when the selector is true; arbitrary values are not coerced to truth values
  • in (range/set matching), has (membership), of (type matching), and on are 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 ordinary when; the shipped V3 processor form is the separate select { 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; assert is 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.

symboldescription
-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 / 2 is -3)
  • remainder takes the sign of the dividend (-7 % 2 is -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.0 is infinity, 0.0 / 0.0 is 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).

SymbolMeaning
==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) = 1 if x = y = 1, and (x and y) = 0 otherwise.
  • or (disjunction), denoted (x or y), satisfies (x or y) = 0 if x = y = 0, and (x or) = 1 otherwise.
  • not (negation), denoted (not x), satisfies (not x) = 0 if x = 1 and (not x) = 1ifx = 0`.
assert((true and false), (false and true));
assert((true or false), true)
assert((not true), false)

Current evaluation note:

  • and/or evaluate 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 evaluates 1/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 }
syntaxmeaning
start..endfrom start to end
..endfrom 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
syntaxmeaning
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], and c[1:]) is implemented for vec[...] and seq[...]; 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.

syntaxmeaning
:the whole container
elA:elBfrom element elA to element elB
:elAfrom 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:

syntaxmeaning
::the whole container in reverse
elA::elBfrom element elA to element elB in reverse
::elAfrom 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(...), and panic(...)
  • core: the minimal runtime model with no heap and no source-level hosted OS/runtime APIs
  • memo: alloc-like heap-backed library/runtime support without source-level hosted OS/runtime APIs
  • std: the hosted API tier layered on memo by 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:

  • core artifacts must not rely on heap-backed or source-level hosted APIs
  • memo artifacts may use heap-backed facilities but not source-level hosted APIs
  • bundled std wrappers require a memo artifact plus explicit internal standard dependency

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 contract
  • check(...) 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 one bol

Query

.len(items)

Current V1 rule:

  • .len(...) accepts exactly one operand
  • the operand must currently be one of:
    • str
    • arr[...]
    • 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 memo artifact with the explicit bundled std dependency
  • 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 -1 at 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 / ErrorType expression failed and returns bol; that expression may be a direct routine call or, in V3, an awaited recoverable eventual
  • panic(...) 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

  • as
  • cast
  • assert
  • .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-intrinsics owns 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 a memo artifact with bundled std support available
  • arr[...] is valid in core
  • vec[...], seq[...], set[...], and map[...] require memo

Current boundary:

  • static arr[...], vec[...], seq[...], and map[...] 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 active V1 intrinsics

So the safe current query surface for containers is .len(...).

Current V1 runtime note:

  • arr[...] remains the fixed-size container family
  • arr[...] itself is available in core, provided its element type is also legal there
  • vec[...], seq[...], set[...], and map[...] are heap-backed memo families and remain available when bundled std layers hosted APIs on top
  • runtime-backed set[...] and map[...] 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, the err[...] error shell, and the nev never type are the current compiler surface
  • uni[...] (union) and any are 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[...] and err[...] are shell values.
  • Unwrap [uwp]value applies to those shell values.
  • routine calls declared with ResultType / ErrorType are not err[...] shells.
  • use check(...) or expr || fallback for 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, and lab declarations are the current compiler surface
  • @var and var[new] are the shipped V3 unique-heap forms and require fol_model = "memo"; a memo artifact with bundled std remains 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 package
  • hid / - 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 work
  • fun: functions, intended for ordinary value-producing computation
  • log: 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 implicit result variable 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 a report path

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 ...sequence unpack 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 expr exits through that declared error path
  • routine call results declared with / ErrorType are not err[...] shell values
  • use check(...) or expr || fallback for those calls
  • ordinary plain-value use of / ErrorType calls is rejected
  • keep postfix ! for opt[...] and err[...] 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 intrinsic
  • check(...) is a keyword intrinsic for recoverable-call inspection
  • panic(...) is a keyword intrinsic for immediate abort
  • as and cast are registry-owned but still deferred in current V1

Model reminder:

  • any routine example that calls .echo(...) assumes a memo artifact with bundled std support available
  • routine examples without hosted behavior should stay valid in core or memo where 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 of V1
  • ownership-aware transfers, unique heap moves, and explicit [bor] parameters are part of the shipped V3 memory contract
  • mux[T] parameters and the direct named-routine task boundary are part of the shipped V3 processor 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 / E does not produce an err[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(...) or expr || fallback at the call site
  • keep postfix ! for opt[...] and err[...] 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]: T borrow parameters are part of the shipped V3 memory contract
  • typed pointers and unique heap ownership are also part of that V3 contract

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 return and a declared return type; the result = ... 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 / E does not return an err[E] shell value
  • it returns through a recoverable routine error path
  • handle that path immediately with check(...) or expr || fallback
  • plain assignment, return, and ordinary expression use are rejected
  • postfix ! stays reserved for opt[...] and err[...] 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 ... T marker; a plain seq[T] parameter takes one sequence value
  • ...sequence may 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 through self

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 V1 built-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 V4 milestone, 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-runtime aggregate 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 as a.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 V2 Milestone 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_m2
    • examples/standards_protocol_pair_m2
    • examples/standards_protocol_multi_m2
    • examples/fail_standard_blueprint_m2
    • examples/fail_standard_as_type_m2
    • examples/fail_standard_missing_routine_m2
    • examples/fail_standard_signature_m2
    • examples/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 pro is 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_m1
    • examples/generic_routine_pair_m1
    • examples/generic_routine_cross_file_m1
    • examples/generic_receiver_m1
    • examples/generic_receiver_cross_file_m1
    • examples/generic_receiver_overload_m1m2
    • examples/generic_type_semantic_m1m2
      • positive semantic-check fixture for generic type declarations
    • examples/generic_type_exec_m1m2
    • examples/generic_standard_constraint_m1m2
    • examples/generic_standard_constraint_generic_m1m2
    • examples/fail_generic_misuse_m1
    • examples/fail_generic_cross_file_m1
    • examples/fail_generic_receiver_m1
    • examples/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 | async until 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)(): T declares 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] and Cup[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_m1m2 pins 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 @Node lowers nominally to Option<Box<Node>>. Recursive generic instantiation through an inline container remains outside the shipped generic subset, pinned by examples/fail_generic_recursive_m1m2
  • generic types are not part of V1; they now belong to the shipped narrow full-V2 contract instead
  • examples here should be read as:
    • current narrow V2 Milestone 1 generic-routine work where noted
    • otherwise later V2 design

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 V2 target
  • 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 required fun fetch(): Item on Holder[int] types as int), and the conformer must claim the standard at the same arguments; examples/generic_standard_constraint_generic_m1m2 pins 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 loc and pkg
  • bundled std is reached through a declared internal dependency and a pkg import, 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:

  • loc for local directory imports
  • pkg for 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:

  • use brings in exported functionality
  • hid declarations remain file-only even inside the same package
  • importing a package does not erase the original file boundary rules
  • use is 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.fol is 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.fol is required
  • build.fol stores 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/check starts from build.fol
  • the canonical entry is pro[] build(): non
  • old def root: loc = ... and def build(...) forms are not the build model

So:

  • def is still a general FOL declaration form
  • build.fol is not a separate mini-language
  • build.fol uses an ordinary routine entrypoint, like Zig’s build.zig

That means:

  • ordinary source .fol files use use to consume packages/namespaces
  • build.fol uses pro[] build(): non plus .build() to configure package metadata, direct dependencies, and the build graph

So use and the build routine serve different jobs:

  • use = consume functionality
  • pro[] build() in build.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.fol is 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 .fol file 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 .fol files 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.fol is in package namespace math
  • mean.fol is in package namespace math::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
  • use imports packages or namespaces
  • hid keeps a declaration private to one file
  • loc imports a local directory tree without a package control file
  • pkg imports a formal external package defined by build.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 required
  • pkg: 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.fol as the package control file

This keeps the language model clean:

  • source files use other namespaces/packages
  • package build execution starts from pro[] build(): non in build.fol
  • package metadata and direct dependencies are configured through .build() inside build.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 (_{}, .{}, named blk[] 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 default human output shows it next to a family chip, and --output plain shows it in brackets (error[R1003]:)
  • any code can be expanded with fol code explain <CODE>
  • all failures keep exact primary file:line:column locations
  • 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 / E for routine-call handling
  • err[...] 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 expr exits through the routine error path with str
  • 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 expr succeeds, use its success value
  • if expr fails, evaluate fallback
  • fallback may:
    • provide a default value
    • report
    • panic

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 expr
  • check(expr)
  • expr || fallback
  • err[...] shell/value behavior

The current compiler rejects:

  • plain assignment of / ErrorType call results
  • direct returns of / ErrorType call results
  • implicit conversion from / ErrorType into err[...]
  • postfix ! on / ErrorType routine calls

For backend work:

  • / ErrorType routine calls lower through the recoverable runtime ABI
  • err[...] remains a separate shell/value runtime type
  • a recoverable executable entry is adapted through the shared backend-only fol_runtime::process seam in every runtime model; it does not require bundled std

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:

  • panic and report describe 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-stream
  • fol-lexer
  • fol-parser
  • fol-package
  • fol-resolver
  • fol-typecheck
  • fol-lower
  • fol-runtime
  • fol-backend
  • fol-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:

PrefixProducerExamples
P1xxxparserP1001 syntax, P1002 file root
K1xxxpackage loadingK1001 metadata, K1002 layout
R1xxxresolverR1003 unresolved, R1005 ambiguous
T1xxxtype checkerT1003 type mismatch
O1xxxownership checkerO1001 move, task, or resource-state violation
O2xxxlexical borrow checkerO2001 owner borrowed, O2002 conflict, O2003 mutability, O2004 returned borrow reused
L1xxxloweringL1001 unsupported surface
F1xxxfrontendF1001 invalid input, F1002 workspace not found
K11xxbuild evaluatorK1101 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

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 more pointer
  • 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:

  1. exact location
  2. source snippet when available
  3. 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 report inside 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 V4 package/type/backend work
  • Rust interop diagnostics are future V4 package/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:

  1. look at the diagnostic code in brackets to identify the error family
  2. trust the primary location
  3. use related labels to understand competing or earlier sites
  4. read notes for technical context
  5. 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() | async and eventual | await processor stages are shipped V3 std-only forms; they do not enable general this-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 { ... } and edf { ... } block forms are supported
  • each block registers work on the current lexical scope
  • dfr runs when that scope exits by fallthrough or break, and before a return, recoverable report, or panic leaves it
  • multiple deferred blocks run in reverse registration order
  • edf runs only on a recoverable report exit, 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:

  • memo alone does not expose the hosted .echo(...) substrate

  • the printing examples below use fol_model = "memo", declare the bundled standard library explicitly in build.fol, and call its std.io wrappers:

    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; std is not a third fol_model

  • dfr and edf are not std-only; both remain valid in core and memo

  • a core or unhosted memo program using them can still run; bundled std appears 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, and panic cannot appear inside a dfr or edf body; 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
  • | await and access to an existing eventual binding cannot occur inside edf; an error-only body cannot discharge eventual ownership on normal exits, and the restriction remains active inside a dfr nested within that edf
  • a mux[T] handle cannot be locked, unlocked, used for guarded field access, or forwarded to another mux[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:

  1. select the registered blocks for the exit kind (dfr, plus edf for a recoverable error)
  2. run those blocks in reverse registration order
  3. release any still-active borrows
  4. 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 is arms (plus the case and * default forms) are the current compiler surface
  • in (range/set matching) and has (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; the is operator 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:

  • nil is type-checked only when the surrounding context already expects an opt[...] or err[...] shell.
  • A standalone nil with no expected shell type is rejected during typechecking.
  • Unwrap [uwp]value is currently type-checked for opt[T] and err[T] and yields T.
  • 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 with ResultType / 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
  • never flowing into any expected type
  • the current optional/error shell lifting used by ordinary V1 surfaces

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 target
  • value 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 V1 typecheck 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 V4 features
  • casting rules for foreign or ABI-facing types should be specified together with that V4 interop 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_m1
  • examples/mem_set_observation_m1
  • examples/mem_when_bool_gate_m1
  • examples/mem_linked_list_m1
  • examples/mem_tree_m1
  • examples/mem_move_stack_vs_heap_m1
  • examples/mem_partial_move_m1
  • examples/mem_fin_finalizer_m1
  • examples/mem_fin_move_m1
  • examples/mem_fin_early_m1

Negative:

  • examples/fail_mem_use_after_move_m1
  • examples/fail_mem_loop_move_m1
  • examples/fail_mem_explicit_move_reuse_m1
  • examples/fail_mem_discarded_move_m1
  • examples/fail_mem_deferred_reinit_m1
  • examples/fail_mem_recursive_value_m1
  • examples/fail_mem_heap_in_core_m1
  • examples/fail_mem_uninit_borrow_m1
  • examples/fail_mem_partial_move_m1
  • examples/fail_mem_global_fin_m1
  • examples/fail_mem_fin_partial_move_m1
  • examples/fail_mem_fin_fun_finalizer_m1
  • examples/fail_mem_fin_reuse_m1
  • examples/fail_mem_clone_fin_m1
  • examples/fail_mem_clone_fin_claim_m1
  • examples/fail_mem_generic_copy_bound_m1
  • examples/fail_mem_generic_send_bound_transitive_m1

M2: lexical borrowing and deferred ownership

Positive:

  • examples/mem_borrow_m2
  • examples/mem_borrow_giveback_m2
  • examples/mem_borrow_param_m2
  • examples/mem_mut_borrow_m2
  • examples/mem_ownership_ops_m2
  • examples/mem_capabilities_m2
  • examples/mem_copy_clone_m2
  • examples/mem_custom_clone_m2
  • examples/mem_borrow_receiver_m2
  • examples/mem_mut_receiver_m2
  • examples/mem_receiver_ops_m2
  • examples/mem_reborrow_m2
  • examples/mem_nll_last_use_m2
  • examples/mem_named_lifetime_m2
  • examples/mem_temp_borrow_m2
  • examples/mem_place_borrow_m2
  • examples/mem_edf_m2
  • examples/mem_dfr_capture_m2
  • examples/mem_dfr_capture_mut_m2
  • examples/mem_closure_capture_m2
  • examples/mem_closure_borrow_m2
  • examples/mem_closure_env_lifetime_m2

Negative:

  • examples/fail_mem_deferred_report_m2
  • examples/fail_mem_dfr_capture_bare_m2
  • examples/fail_mem_dfr_capture_bor_mutation_m2
  • examples/fail_mem_dfr_capture_undeclared_m2
  • examples/fail_mem_closure_move_only_m2
  • examples/fail_mem_closure_escape_m2
  • examples/fail_mem_closure_env_leak_m2
  • examples/fail_mem_copy_moveonly_m2
  • examples/fail_mem_copy_record_field_m2
  • examples/fail_mem_send_field_m2
  • examples/fail_mem_send_nested_field_m2
  • examples/fail_mem_clone_nested_field_m2
  • examples/fail_mem_share_field_m2
  • examples/fail_mem_owner_while_borrowed_m2
  • examples/fail_mem_second_mut_borrow_m2
  • examples/fail_mem_mut_borrow_immutable_owner_m2
  • examples/fail_mem_borrow_reuse_m2
  • examples/fail_mem_conditional_giveback_m2
  • examples/fail_mem_deferred_giveback_m2
  • examples/fail_mem_ownership_op_combo_m2
  • examples/fail_mem_copy_fin_conflict_m2
  • examples/fail_mem_copy_field_m2
  • examples/fail_mem_copy_nested_field_m2
  • examples/fail_mem_copy_operand_unclaimed_m2
  • examples/fail_mem_fun_mut_receiver_m2
  • examples/fail_mem_escaping_borrow_m2
  • examples/fail_mem_temp_borrow_escape_m2
  • examples/fail_mem_place_borrow_owner_m2

M3: typed pointers

Positive:

  • examples/mem_ptr_unique_m3
  • examples/mem_ptr_shared_m3
  • examples/mem_ptr_shared_recursive_m3
  • examples/mem_ptr_weak_m3
  • examples/mem_ptr_weak_clone_m3
  • examples/mem_ptr_weak_upgrade_m3
  • examples/mem_ptr_weak_cycle_m3
  • examples/mem_ptr_weak_nested_m3
  • examples/mem_shell_access_m3
  • examples/mem_shell_prefix_m3

Negative:

  • examples/fail_mem_borrowed_ptr_move_deref_m3
  • examples/fail_mem_ptr_raw_m3
  • examples/fail_mem_ptr_weak_m3
  • examples/fail_mem_shell_access_m3
  • examples/fail_mem_ptr_in_core_m3
  • examples/fail_mem_pointer_field_deref_m3
  • examples/fail_mem_shared_ptr_move_deref_m3
  • examples/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.

StandardMeaning
copy[cpy]value may duplicate the value; the source stays usable
clone[cln]value may create an independent copy; the source stays usable
finthe type runs custom finalization when it is dropped
sendowned access may cross a task or thread boundary
shareshared 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 T is clone-safe, dereference clones T and leaves the pointer usable
  • if T is move-only, dereferencing a unique pointer transfers T out 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_m1
  • examples/proc_spawn_move_heap_m1
  • examples/proc_spawn_canonical_m1
  • examples/proc_spawn_detached_m1
  • examples/proc_shared_sync_ptr_m1
  • examples/proc_spawn_borrow_m1

Negative:

  • examples/fail_proc_spawn_detached_borrow_m1
  • examples/fail_proc_spawn_fin_m1
  • examples/fail_proc_spawn_borrow_mutate_m1
  • examples/fail_proc_spawn_weak_m3
  • examples/fail_proc_spawn_heap_use_after_move_m1
  • examples/fail_proc_spawn_in_core_m1
  • examples/fail_proc_spawn_indirect_m1
  • examples/fail_proc_spawn_in_memo_m1
  • examples/fail_proc_spawn_rc_cross_m1
  • examples/fail_proc_spawn_recoverable_m1

P2: channels

Positive:

  • examples/proc_channel_capture_m2
  • examples/proc_channel_loop_m2
  • examples/proc_channel_m2
  • examples/proc_channel_pull_m2
  • examples/proc_sender_endpoint_m2
  • examples/proc_receiver_endpoint_m2

Negative:

  • examples/fail_proc_channel_bare_send_m2
  • examples/fail_proc_channel_capture_rx_m2
  • examples/fail_proc_channel_in_core_m2
  • examples/fail_proc_channel_in_memo_m2
  • examples/fail_proc_channel_index_m2
  • examples/fail_proc_channel_receive_bare_m2
  • examples/fail_proc_channel_spawn_consumer_m2
  • examples/fail_proc_clone_receiver_m2

P3: select and mutexes

Positive:

  • examples/proc_mutex_explicit_unlock_m3
  • examples/proc_mutex_local_m3
  • examples/proc_mutex_guard_m3
  • examples/proc_mutex_guard_end_m3
  • examples/proc_mutex_m3
  • examples/proc_select_m3

Negative:

  • examples/fail_proc_mutex_deferred_m3
  • examples/fail_proc_mutex_deferred_forward_m3
  • examples/fail_proc_mutex_deferred_lock_m3
  • examples/fail_proc_mutex_deferred_unlock_m3
  • examples/fail_proc_mutex_double_paren_m3
  • examples/fail_proc_mutex_guard_await_m3
  • examples/fail_proc_mutex_guard_move_m3
  • examples/fail_proc_mutex_in_core_m3
  • examples/fail_proc_mutex_in_memo_m3
  • examples/fail_proc_select_empty_m3
  • examples/fail_proc_select_in_core_m3
  • examples/fail_proc_select_in_memo_m3
  • examples/fail_proc_select_old_form_m3

P4: eventuals

Positive:

  • examples/proc_async_await_m4
  • examples/proc_await_error_m4
  • examples/proc_evt_named_m4
  • examples/proc_evt_lifetime_m4

Negative:

  • examples/fail_proc_evt_return_elided_m4
  • examples/fail_proc_evt_param_elided_m4
  • examples/fail_proc_evt_detached_m4
  • examples/fail_proc_evt_embedded_m4
  • examples/fail_proc_async_in_core_m4
  • examples/fail_proc_async_break_outer_m4
  • examples/fail_proc_async_edf_await_m4
  • examples/fail_proc_async_indirect_m4
  • examples/fail_proc_async_in_memo_m4
  • examples/fail_proc_async_nested_capture_m4
  • examples/fail_proc_async_recoverable_discard_m4
  • examples/fail_proc_async_recoverable_overwrite_m4
  • examples/fail_proc_async_recoverable_unawaited_m4
  • examples/fail_proc_await_in_core_m4
  • examples/fail_proc_await_in_memo_m4
  • examples/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 their Rc backing 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:

StagePackageContractLocked revision
PARCfollang-parc 0.16.0source package schema 20f52aeeeeec47a082c0d8a515130ee853aa1101d
LINCfollang-linc 0.1.0 with native-inspectionlink-analysis schema 2c874d5b0332249524422d9d08c35b3d4edd7e3fa
GERCfollang-gerc 0.1.0 with pipeline-nativegeneration domain 1000c1f6c12fba99f1a157267ee7db39d42bda8e3

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-check checks 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 sibling HEADs to equal the lock.
  • make interop-locked additionally requires each sibling to have the exact locked HEAD, canonical GitHub origin, and a clean worktree.
  • make test-interop depends 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.