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 and implementations
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
imp // implementation declarations
var // mutable bindings
let // immutable local bindings
con // constants
lab // labels and label-like bindings
fun // functions
pro // procedures
log // logical routines
typ // named types
ali // aliases
std // standards: protocol, blueprint, extension
Expression And Control Surface
FOL combines block-oriented control flow with expression forms:
if (condition) { ... }
when (value) { ... }
while (condition) { ... }
loop (condition) { ... }
for (binder in iterable) { ... }
each (binder in iterable) { ... }
Expressions include:
- literals
- calls and method calls
- ranges and container literals
- access forms
- pipes
- rolling expressions
- anonymous routines and lambdas
How To Use This Book
The book is organized from language foundation to higher-level facilities:
- lexical structure
- statements and expressions
- metaprogramming
- types
- declarations and items
- modules and source layout
- errors
- sugar and convenience forms
- conversions
- memory model
- concurrency
Read Notation And Conventions before using chapter examples as a normative reference.
For the current user-facing tool workflow, read Frontend Workflow.
Example
use log: std = {"fmt/log"};
var[hid] prefix: str = "arith";
fun[] main(): int = {
.echo(prefix);
.echo(add(3, 5));
return 0;
}
fun[exp] add(a, b: int): int = {
return a + b;
}
Overview
This opening section explains what FOL is, how this book is organized, and how syntax examples are written.
Use it before diving into the detailed lexical, type, item, and runtime chapters.
It also describes the user-facing workflow tool and the first editor-tooling surface before the language chapters begin.
Notation And Conventions
This book is the language specification for FOL.
It is written as a spec first:
- examples describe intended language behavior
- implementation status may lag behind the book
- when code and prose disagree, the disagreement should be resolved explicitly
Reading Syntax Examples
The examples in this book follow a few conventions.
- Keywords are written literally:
funprotypuse
- Punctuation is written literally:
()[]{}:=
- Placeholder names are descriptive:
nametypeexprbodysource
For example:
fun[options] name(params): return_type = { body }
means:
funis a literal keywordoptionsis a placeholder for zero or more routine optionsnameis the declared routine nameparamsis a parameter listreturn_typeis a type referencebodyis a routine body
Spec Vocabulary
The book uses the following terms consistently:
- declaration: a top-level or block-level form that introduces a named entity
- statement: an executable form that appears in a block
- expression: a form that produces a value
- type reference: a syntactic reference to a type
- type definition: a declaration form that creates a new named type
- source kind:
an import/source locator family such as
loc,pkg, orstd
Examples vs Grammar
Examples are illustrative, not exhaustive.
When a chapter gives one or two examples, that does not imply the syntax is limited to only those exact spellings. The normative rule is the chapter text plus the grammar intent described there.
Terminology Preferences
This book prefers the following terms:
- routine:
umbrella term for
fun,pro, andlog - record: named field-based type
- entry: named variant-based type
- standard: protocol/blueprint/extension-style contract surface
- module:
namespace/package surface addressed through
use,def, orseg
Status Notes
Some older chapters still use inconsistent wording inherited from earlier drafts.
During cleanup, the following principles apply:
- keep examples unless they are contradictory
- prefer clarifying rewrites over removal
- keep the chapter tree stable where possible
- make chapter indexes explain scope before detail
Frontend Workflow
FOL now has a dedicated frontend layer above the compiler pipeline.
That layer is the fol tool itself.
The frontend is implemented in fol-frontend, and it is the user-facing
workflow shell for:
- project/workspace setup
- fetch/update flows
- build/run/test/emit flows
- editor tooling dispatch under
fol tool
The detailed reference has moved to the Tooling section:
Use this overview page only as the entrypoint pointer.
- workflow commands
- direct compile dispatch
- root help
- output rendering
- frontend diagnostics
So the root binary is no longer its own separate CLI implementation.
Current Boundary
The current frontend milestone is about local workflows and the first backend.
It already covers:
- project and workspace scaffolding
- documented
build.folpackage entry files for new projects - root discovery
- package preparation through
fol-package - git-backed dependency fetching and materialization
fol.lockwriting, locked fetches, offline warm-cache fetches, and update flows- workspace dependency/status reporting
- full
V1build/run/test orchestration - routed workspace
build/run/test/checkentry throughbuild.fol - emitted Rust and lowered IR output
- editor-tooling entrypoints for parse, highlight, symbols, and LSP startup
- shell completions
- safe cleanup of build/cache/git/package-store roots
- frontend-owned direct compile routing
Future work is still expected around:
- richer package-store policy beyond the first git/store workflow
- lockfile/version solving beyond the current pinned git contract
- additional backend targets
Editor Tooling
FOL now has a dedicated editor-tooling crate:
fol-editor
It owns:
- Tree-sitter assets for syntax/highlighting/query work
- the language server for compiler-backed editor services
- build-file affordances for
build.folthrough the same parse/highlight/symbol and LSP surfaces used for ordinary source files - one public editor command surface under
fol tool ...
The detailed operational reference now lives in the Tooling section:
Use this page as the overview pointer, not the full reference.
Tooling
FOL now has a real tooling layer above the compiler.
That layer is split into:
- the
folfrontend workflow tool - editor-facing tooling under
fol tool ...
Use this section for:
- creating and organizing projects
- fetching and updating packages
- building, running, testing, and emitting artifacts
- understanding the editor stack
- integrating FOL with Neovim
The overview chapter only introduces the existence of these pieces.
This section is the detailed operational reference.
Frontend Workflow
The public FOL entrypoint is the fol tool.
fol is implemented by the fol-frontend crate. It sits above:
fol-packagefol-resolverfol-typecheckfol-lowerfol-backend
Its job is orchestration, not semantic analysis.
What The Frontend Owns
The frontend owns:
- CLI parsing with
clap - grouped command structure
- package and workspace discovery
- project scaffolding
- package preparation and fetch/update workflows
- build, run, test, and emit orchestration
- editor command dispatch under
fol tool - shell completions
- user-facing summaries and workflow errors
Compiler truth remains in the compiler crates.
Public Command Groups
The root command groups are:
fol workfol packfol codefol tool
Root aliases are single-letter only:
fol wfol pfol cfol t
Run:
fol --help
fol <group> --help
fol <group> <command> --help
for the relevant usage surface.
Root Discovery
For workflow commands, the frontend discovers roots upward from the current directory or from an explicit path.
It recognizes:
- package roots via
package.yaml - workspace roots via
fol.work.yaml
A package root is one buildable package.
A workspace root is a parent root that owns multiple member packages and frontend-managed roots like build/cache/package-store locations.
Direct Package Use
The normal workflow shape is group-first:
fol work init --bin
fol pack fetch
fol code check
fol code build
fol code run
There is still a frontend-owned direct package path for compile-oriented use, but the grouped commands are the public workflow shape.
Output
Frontend summaries support:
humanplainjson
The frontend also owns workflow-level errors such as:
- missing workspace roots
- fetch/update/store failures
- clean/completion/workflow command failures
Compiler diagnostics stay separate and flow through compiler-owned diagnostic models.
Tool Commands
This chapter lists the current frontend surface by workflow area.
Work
Project and workspace commands:
fol work initfol work newfol work infofol work listfol work depsfol work status
Examples:
fol work init --bin
fol work init --workspace
fol work new demo --lib
fol work info
fol work deps
Use work for:
- creating package/workspace roots
- inspecting workspace structure
- seeing member and dependency state
Pack
Package acquisition commands:
fol pack fetchfol pack update
Examples:
fol pack fetch
fol pack fetch --locked
fol pack fetch --offline
fol pack fetch --refresh
fol pack update
Use pack for:
- materializing dependencies
- writing or honoring
fol.lock - refreshing pinned git dependencies
Code
Build-oriented commands:
fol code checkfol code buildfol code runfol code testfol code emit rustfol code emit lowered
Examples:
fol code check
fol code build --release
fol code run -- --flag value
fol code emit rust
fol code emit lowered
Use code for:
- driving the compile pipeline
- building binaries through the current Rust backend
- running produced binaries
- emitting backend/debug artifacts
Tool
Tooling commands:
fol tool lspfol tool format <PATH>fol tool parse <PATH>fol tool highlight <PATH>fol tool symbols <PATH>fol tool references <PATH> --line <LINE> --character <CHARACTER>fol tool rename <PATH> --line <LINE> --character <CHARACTER> <NEW_NAME>fol tool semantic-tokens <PATH>fol tool tree generate <PATH>fol tool cleanfol tool completion
Examples:
fol tool parse src/main.fol
fol tool format src/main.fol
fol tool highlight src/main.fol
fol tool symbols src/main.fol
fol tool references src/main.fol --line 12 --character 8
fol tool rename src/main.fol --line 12 --character 8 total
fol tool semantic-tokens src/main.fol
fol tool tree generate /tmp/fol
fol tool lsp
fol tool completion bash
Use tool for:
- editor integration
- Tree-sitter debugging
- LSP serving
- generated tool assets
The public editor surface stays under fol tool ....
There is no parallel fol editor ... command group.
Future editor features are not exposed as placeholder commands.
Only the shipped fol tool subcommands above are public.
Artifact Reporting
Frontend commands report explicit artifact roots when applicable, including:
- emitted Rust crate roots
- lowered snapshot roots
- final binary paths
- fetch/store/cache roots where relevant
Editor Tooling
FOL editor support lives in one crate:
fol-editor
That crate owns two related subsystems:
- Tree-sitter assets for syntax-oriented editor work
- the language server for compiler-backed editor services
Public Entry
The public entrypoints are exposed through fol tool:
fol tool lspfol tool format <PATH>fol tool parse <PATH>fol tool highlight <PATH>fol tool symbols <PATH>fol tool references <PATH> --line <LINE> --character <CHARACTER>fol tool rename <PATH> --line <LINE> --character <CHARACTER> <NEW_NAME>fol tool semantic-tokens <PATH>fol tool tree generate <PATH>
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
- 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
- rename for same-file local and current-package top-level symbols
- semantic tokens
- document symbols
- workspace symbols for current open workspace members
- completion
The currently supported v1 LSP surface is:
- diagnostics
- hover
- definition
- formatting
- code actions for exact compiler suggestions
- signature help for plain and qualified routine calls
- references
- rename for same-file local and current-package top-level symbols
- semantic tokens
- document symbols
- workspace symbols for current open workspace members
- completion
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
- lines are trimmed before indentation is re-applied
- leading blank lines are removed and repeated blank lines collapse to one
- trailing blank lines are removed
- output always ends with one final newline when the document is non-empty
- line endings are normalized to
\n build.folfollows the same formatter entrypoint and indentation rules as ordinary source files
Diagnostics refresh on didOpen and didChange.
Semantic requests keep one semantic snapshot per open document version and reuse it for hover, definition, signature help, references, rename, semantic tokens, document symbols, workspace symbols, and completion until the document changes or closes.
Compiler Truth
fol-editor does not create a second semantic engine.
Semantic truth still comes from:
fol-packagefol-resolverfol-typecheckfol-diagnostics
So the model is:
- Tree-sitter answers “what does this text structurally look like?”
- compiler crates answer “what does this code mean?”
For the current maintenance contract between compiler crates, LSP behavior, and Tree-sitter assets, see:
Current Practical Workflow
Use:
fol tool lsp
fol tool format path/to/file.fol
as the language server entrypoint.
Launch it from inside a discovered package or workspace root. The frontend
looks upward for package.yaml 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.
Compiler Integration
This chapter explains how the compiler and editor tooling are connected today, and what should remain the source of truth as FOL grows.
The Three Layers
FOL editor tooling is split into three layers:
- compiler truth
- semantic editor services
- syntax editor services
Compiler truth lives in crates such as:
fol-lexerfol-parserfol-packagefol-resolverfol-typecheckfol-intrinsicsfol-diagnostics
Semantic editor services live in:
fol-editorLSP analysis and semantic code
Syntax editor services live in:
- the Tree-sitter grammar
- Tree-sitter query files
- editor bundle generation
That split matters because not every editor feature should be solved in the same way.
What The LSP Should Trust
For semantic meaning, the LSP should trust the compiler pipeline.
That means:
- diagnostics come from compiler diagnostics
- hover should be derived from resolved or typed compiler state
- definition should be derived from resolved symbol data
- document symbols should prefer compiler symbol ownership where practical
- completion should prefer compiler facts over text heuristics
The LSP should not invent a parallel semantic model.
If a feature needs semantic meaning, the first question should be:
- can this come from
fol-parser/fol-resolver/fol-typecheckinstead of editor-only code?
What Tree-sitter Should Trust
Tree-sitter is not the compiler parser.
It exists for:
- syntax trees while typing
- highlighting
- locals queries
- symbol-style structural captures
- future textobjects and editor movement
So Tree-sitter should stay editor-oriented.
That means:
- the grammar can remain handwritten
- query files can remain handwritten
- but duplicated language facts should not be copied manually forever
Examples of facts that should come from compiler-owned truth:
- builtin type spellings
- implemented intrinsic names
- import source kinds
- keyword families used by syntax tooling
How Diagnostics Flow
Today the intended direction is:
- compiler stage creates a structured
fol_diagnostics::Diagnostic - editor tooling adapts that diagnostic for the active editor protocol
- the editor displays the adapted result
The important rule is:
- the compiler diagnostic object is canonical
So when diagnostic wording, labels, helps, or codes change, the editor should adapt that same structure. It should not create its own free-form diagnosis logic.
What Can Be Generated
Generation is useful when the same language fact appears in multiple places.
Good generation targets:
- keyword manifests
- builtin type manifests
- intrinsic name/surface manifests
- source-kind manifests
- small generated query fragments or validation snapshots
Bad generation targets:
- the entire Tree-sitter grammar
- Tree-sitter precedence/conflict structure
- most highlight policy
- LSP transport behavior
The rule of thumb is:
- generate shared facts
- handwrite editor behavior
What To Update For A New Feature
When you add a new language feature, ask these questions in order:
- Does the lexer need new tokens or token families?
- Does the parser need new syntax or AST nodes?
- Does resolver or typechecker logic need new meaning?
- Does the feature introduce or change diagnostics?
- Does the LSP need hover, completion, definition, or symbol updates?
- Does Tree-sitter need grammar, query, or corpus updates?
- Can any new editor-visible facts be generated from compiler-owned sources?
If the answer to any of those is yes, update that layer in the same change set.
Current Practical Rule
In FOL today:
- compiler crates own meaning
fol-editorowns editor protocol behavior- Tree-sitter owns syntax-oriented editor structure
The safer future direction is:
- move shared contracts closer to compiler-owned crates
- keep protocol/UI code in tooling crates
- generate duplicated facts instead of hand-copying them
Tree-sitter Integration
The Tree-sitter side of FOL is the editor-facing syntax layer.
It is not the compiler parser.
What Is In The Repo
The editor crate carries:
- the grammar source
- corpus fixtures
- query files on disk
Canonical query assets live as real files, not just embedded Rust strings:
queries/fol/highlights.scmqueries/fol/locals.scmqueries/fol/symbols.scm
This is intentional because editors such as Neovim expect query files on disk in the standard Tree-sitter layout.
Generated Bundle
To generate a Neovim-consumable bundle, run:
fol tool tree generate /tmp/fol
That writes a bundle containing the grammar and query assets under the target directory.
The intended consumer path is:
- generate bundle
- point the editor’s Tree-sitter parser configuration at that bundle
- let the editor compile/use the parser from there
File Ownership
Intentionally Handwritten
These files are human-authored and should remain so:
| File | Purpose | Owner |
|---|---|---|
tree-sitter/grammar.js | Grammar rules, precedence, conflicts | Editor/syntax maintainer |
queries/fol/highlights.scm | Highlight capture groups and query patterns | Editor/syntax maintainer |
queries/fol/locals.scm | Scope and definition tracking | Editor/syntax maintainer |
queries/fol/symbols.scm | Symbol navigation captures | Editor/syntax maintainer |
test/corpus/*.txt | Corpus fixtures for grammar validation | Editor/syntax maintainer |
Do not attempt to generate these from the compiler parser. The parsing models are different and auto-generation creates more fragility than value.
Validated Against Compiler Constants
These facts appear in handwritten files but are validated by integration tests to stay in sync with compiler-owned constants:
| Fact | Handwritten Location | Compiler Source |
|---|---|---|
| Builtin type names | highlights.scm regex ^(int|bol|...)$ | BuiltinType::ALL_NAMES in fol-typecheck |
| Dot-call intrinsic names | highlights.scm regex ^(len|echo|...)$ | Implemented DotRootCall entries in fol-intrinsics |
| Container type names | highlights.scm node labels + grammar.js choice | CONTAINER_TYPE_NAMES in fol-parser |
| Shell type names | highlights.scm node labels + grammar.js choice | SHELL_TYPE_NAMES in fol-parser |
| Source kind names | highlights.scm node labels + grammar.js choice | SOURCE_KIND_NAMES in fol-parser |
The sync tests live in test/run_tests.rs under treesitter_sync. If you add
a new builtin type, intrinsic, container, shell, or source kind to the compiler,
these tests fail until the tree-sitter files are updated to match.
When To Update Tree-sitter Files
grammar.js
Update when:
- A new declaration form is added (e.g. a new
segorlabdeclaration) - A new expression or statement form is added
- A new type syntax is added (e.g. a new container or shell family)
- A new source kind is added
- Operator precedence or conflict rules change
Do not update for:
- New diagnostic codes or error messages
- New resolver/typecheck rules that don’t change syntax
- New intrinsics (these use existing
dot_intrinsicgrammar rule)
highlights.scm
Update when:
- A new keyword needs highlighting
- A new builtin type is added to the compiler
- A new implemented dot-call intrinsic is added
- A new container or shell type family is added
- A new source kind is added
- Highlight group policy changes (e.g. moving a keyword from
@keywordto@keyword.function)
locals.scm
Update when:
- Scope rules change (e.g. new block forms that introduce scopes)
- Definition capture patterns change
symbols.scm
Update when:
- New declaration forms should appear in document symbol navigation
Corpus fixtures
Update when:
- Any grammar rule is added or modified
- A new syntax family needs parse-tree validation
Corpus Coverage Expectations
Corpus fixtures live in tree-sitter/test/corpus/ and cover syntax families:
| Corpus File | Covers |
|---|---|
declarations.txt | use, ali, typ, fun, log, var declarations |
expressions.txt | Intrinsic calls, when/loop control flow, break/return |
recoverable.txt | Error propagation (/), report, pipe-or (||) |
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)
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.
When a language feature changes syntax, use the Feature Update Checklist to decide whether the grammar, queries, corpus, or generated language facts also need updates.
Feature Update Checklist
Use this checklist whenever a new language feature, syntax form, intrinsic, or error surface is added.
This chapter is about maintenance discipline, not just implementation order.
Quick Reference
When adding a feature, touch these layers in order:
- Lexer — new keywords, operators, tokens
- Parser — new AST nodes, syntax rules
- Semantics — resolver, typecheck, lowering, intrinsics
- Diagnostics — new error/warning cases
- LSP — hover, completion, definition, symbols
- Tree-sitter — grammar, queries, corpus
- Generated facts — compiler-owned constants
- Docs — language chapters, tooling pages
- Tests — unit, integration, editor, corpus
Automated guards exist for some of these. The treesitter_sync integration
tests verify that highlights.scm matches compiler-owned constants for builtin
types, intrinsic names, container/shell types, and source kinds. Adding a new
constant to the compiler without updating Tree-sitter will fail those tests.
1. Lexical Surface
Check:
- new keywords
- new operators or punctuation
- new literal/token families
- comment or whitespace effects
Update:
fol-lexer- lexical docs under
100_lexical - any generated keyword/facts manifest if one exists
2. Parser Surface
Check:
- new declarations
- new expressions
- new statement forms
- new type forms
- new precedence or ambiguity rules
Update:
fol-parser- parser tests
- Tree-sitter grammar if the syntax is editor-visible
- Tree-sitter corpus fixtures for the new syntax family
3. Semantic Surface
Check:
- name resolution
- type checking
- lowering
- intrinsic availability
- runtime/backend impact
Update:
fol-resolverfol-typecheckfol-lowerfol-intrinsics- runtime/backend crates if needed
4. Diagnostics Surface
Check:
- new error cases
- new warning/info cases
- changed wording for existing rules
- changed labels, notes, helps, or suggestions
Update:
- compiler producer diagnostics
fol-diagnosticscontract tests- editor/LSP diagnostic adapter tests if the visible shape changes
- docs under
650_errorsif behavior changed materially
5. LSP Surface
Check:
- hover content
- go-to-definition behavior
- document symbols
- completion
- open-document analysis behavior under broken code
Update:
fol-editorsemantic analysisfol-editorsemantic display helpers or compiler-owned helpers they consume- LSP tests
Important rule:
- prefer compiler-backed meaning
- use fallback heuristics only when the compiler cannot supply semantic data yet
6. Tree-sitter Surface
Check:
- syntax shape visible while typing
- highlight groups
- locals captures
- symbols captures
- corpus examples
Update:
tree-sitter/grammar.jsqueries/fol/highlights.scmqueries/fol/locals.scmqueries/fol/symbols.scmtree-sitter/test/corpus/*.txt
Important rule:
- Tree-sitter is for syntax-facing editor behavior
- it does not replace compiler semantics
7. Generated Facts
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
If yes:
- update the compiler-owned source
- regenerate editor-facing artifacts from that source
- do not patch multiple copies manually
8. 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
9. Tests
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
Keep the feature test in the same change as the feature.
10. Final Review Questions
Before considering the feature complete, answer:
- Is compiler meaning implemented?
- Are diagnostics correct and structured?
- Does the LSP reflect the new meaning where needed?
- Does Tree-sitter reflect the new syntax where needed?
- Did we generate shared facts instead of duplicating them?
- Did docs explain the intended user-facing behavior?
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
- 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 and current-package top-level symbols
- semantic tokens
- document symbols
- workspace symbols for current open workspace members
- completion
- the same compiler-backed behavior for ordinary source files and
build.fol
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: leading blank lines are removed and repeated blank lines collapse to one.
Analysis Model
The server works like this:
- receive editor request/notification
- map the open document to a package/workspace context
- materialize an analysis overlay for the in-memory document state
- run parse/package/resolve/typecheck as needed
- convert compiler results into LSP responses
Package/workspace root discovery is cached for the current session once a document directory has been mapped.
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, signature help, references, rename, semantic tokens,
document symbols, workspace symbols, and completion 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 deduplicates diagnostics by (line, code) before publishing. This
means a parse cascade that produces many identical errors on the same line will
show at most one diagnostic per line per code in the editor.
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
- 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 and current-package top-level symbols only
- semantic tokens for semantic identifier categories
- document symbols for the current file
- workspace symbols across the current open workspace members
- completion in ordinary source files and
build.fol
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", "package.yaml", ".git" },
})
vim.lsp.enable("fol")
Also ensure Neovim recognizes .fol files:
vim.filetype.add({
extension = { fol = "fol" },
})
Tree-sitter Setup
First generate a bundle:
fol tool tree generate /tmp/fol
Then point Neovim’s Tree-sitter parser config at that bundle:
local parser_config = require("nvim-treesitter.parsers").get_parser_configs()
parser_config.fol = {
install_info = {
url = "/tmp/fol",
files = { "src/parser.c" },
requires_generate_from_grammar = false,
},
filetype = "fol",
}
The query files are expected at:
/tmp/fol/queries/fol/highlights.scm/tmp/fol/queries/fol/locals.scm/tmp/fol/queries/fol/symbols.scm
Neovim also needs that bundle on runtimepath so it can find the queries.
Practical Model
Tree-sitter provides:
- highlight captures
- locals captures
- symbol-style structure queries
LSP provides:
- diagnostics
- hover
- definitions
- references
- rename for same-file local and current-package top-level symbols
- semantic tokens
- document symbols
So the normal editor shape is:
- Neovim opens a
.folfile - Tree-sitter handles syntax/highlighting
- Neovim launches
fol tool lsp - the server provides semantic editor features
Debugging A Setup
Useful checks:
:echo &filetype
:lua print(vim.inspect(vim.lsp.get_clients({ bufnr = 0 })))
And from the shell:
fol tool tree generate /tmp/fol
fol tool lsp
If fol tool lsp prints nothing and waits, that is correct.
It is a stdio server, not an interactive shell command.
If the server refuses to start, check that Neovim opened the file inside a
directory tree that contains package.yaml or fol.work.yaml.
Build System
FOL uses a Zig-style build model. The build specification is a normal FOL
program called build.fol. It goes through the full compiler pipeline and
is executed against a build graph IR instead of emitting backend code.
The build system lives in lang/execution/fol-build. It handles:
- graph IR construction for artifacts, steps, options, modules, and generated files
- full control flow in build programs (when, loop, helper routines)
-DCLI option passing into the build program- named step selection at the command line
Entry Point
Every buildable package must have a build.fol at its root with exactly one
canonical entry:
pro[] build(graph: Graph): non = {
...
}
The graph parameter is the injection surface. All build operations go through
method calls on graph and on the handles it returns.
Minimal Example
pro[] build(graph: Graph): non = {
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
graph.install(app);
graph.add_run(app);
}
This registers an executable, marks it for installation, and binds a default run step.
What fol-build Owns
graph.rs— build graph IR (steps, artifacts, modules, options, generated files)api.rs— Rust-level graph mutation interfacesemantic.rs— method signatures and type info for the resolver and typecheckerstdlib.rs—BuildStdlibScope: the ambient scope injected intobuild.folexecutor.rs— executes the lowered FOL IR against the build grapheval.rs— evaluate abuild.folfrom source; entry point forfol-packageoption.rs— build option kinds, target triples, optimize modesruntime.rs— runtime representation of artifacts, generated files, step bindingsstep.rs— step planning, ordering, cache keys, execution reportscodegen.rs— system tool and codegen request typesartifact.rs— artifact pipeline definitions and output typesdependency.rs— inter-package dependency surfaces
Use this section for:
- understanding the shape of
build.fol - the full graph API reference
- control flow available inside
build.fol - build options and
-Dflags - artifact types, modules, and generated files
build.fol
build.fol is a file-bound FOL compilation unit. It is the build specification
for a package.
File-Bound vs Folder-Bound
Normal FOL packages are folder-bound: every .fol file in the package folder
shares one namespace. build.fol is the one exception.
Rules for build.fol:
- It is its own compilation unit — it does not see sibling
.folfiles - It has one implicit import: the build stdlib (
fol/build), providingGraphand all handle types - It can define local helper
fun[],pro[], andtypdeclarations - Those local declarations are not exported to the package
- It must declare exactly one
pro[] build(graph: Graph): nonentry - Additional
useimports from the FOL stdlib are allowed
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(graph: Graph): non = {
...
}
pro[]— procedure with no receivers- parameter name
graph, typeGraph - return type
non
Missing entry, wrong signature, or duplicate entries are compile errors.
Local Helpers
build.fol can define helper functions visible only within itself:
fun[] make_lib(graph: Graph, name: str, root: str): Artifact = {
return graph.add_static_lib({ name = name, root = root });
}
pro[] build(graph: Graph): non = {
var core = make_lib(graph, "core", "src/core/lib.fol");
var io = make_lib(graph, "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);
}
package.yaml
Every package needs a package.yaml alongside build.fol:
name: my-app
version: 1.0.0
The build system reads name and version from the manifest. Dependencies
declared in the manifest are made available to build.fol via
graph.dependency(...).
Capability Restrictions
The build executor enforces a capability model. Allowed operations:
- graph mutation (adding artifacts, steps, options)
- option reads (
graph.standard_target(),graph.standard_optimize(), etc.) - path joining and normalization (
graph.path_from_root(...)) - basic string and container operations
- controlled file generation (
graph.write_file(...),graph.copy_file(...)) - controlled process execution (
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
The Graph handle is the sole parameter to pro[] build. All build graph
construction goes through method calls on it.
Artifacts
graph.add_exe
Adds an executable artifact.
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
target = target, // optional
optimize = optimize, // optional
});
Returns an Artifact handle.
Required fields: name, root.
Optional fields: target, optimize.
root is the path to the entry-point .fol source file relative to the
package root.
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.
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" });
Returns an Artifact handle.
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.
graph.install_file
Installs a file by path.
graph.install_file("config/defaults.toml");
Returns an Install handle.
graph.install_dir
Installs a directory by path.
graph.install_dir("assets/");
Returns an Install handle.
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.
Steps
graph.step
Creates a named custom step.
var docs = graph.step("docs");
var docs = graph.step("docs", "Generate documentation"); // with description
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
Options
graph.standard_target
Reads the -Dtarget option. Returns a Target handle.
var target = graph.standard_target();
The value is provided at build time via -Dtarget=x86_64-linux-gnu. If no
value is provided, target resolves to the host target.
An optional config record is accepted:
var target = graph.standard_target({ default = "x86_64-linux-gnu" });
graph.standard_optimize
Reads the -Doptimize option. Returns an Optimize handle.
var optimize = graph.standard_optimize();
The value is provided via -Doptimize=release-fast. Defaults to debug if
not set.
Valid values: debug, release-safe, release-fast, release-small.
graph.option
Declares a named user option readable via -D.
var root_opt = graph.option({
name = "root",
kind = "path",
default = "src/main.fol",
});
Returns a UserOption handle.
Required fields: name, kind.
Optional field: default.
Option kinds:
| Kind | Description | CLI Example |
|---|---|---|
bool | Boolean flag | -Dstrip=true |
int | Integer value | -Djobs=4 |
str | Arbitrary string | -Dprefix=/usr/local |
enum | One of a fixed set of strings | -Dbackend=llvm |
path | File or directory path | -Droot=src/main.fol |
target | Target triple | -Dtarget=x86_64-linux-gnu |
optimize | Optimization mode | -Doptimize=release-fast |
Generated Files
graph.write_file
Declares a file to be written with static contents at build time.
var header = graph.write_file({
name = "version.h",
path = "gen/version.h",
contents = "#define VERSION 1\n",
});
Returns a GeneratedFile handle.
graph.copy_file
Declares a file to be copied from a source path.
var cfg = graph.copy_file({
name = "config",
source = "config/template.toml",
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 = "wasm-pack",
args = ["build", "--target", "web"],
output = "gen/app.wasm",
});
Returns a GeneratedFile handle.
The generated file is keyed by the output path. Use this handle with
step.attach(...) or artifact.add_generated(...).
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.path_from_root
Returns an absolute path by joining the package root with a relative subpath.
var cfg = graph.path_from_root("config/default.toml");
Useful when passing file paths to add_run args.
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
graph.dependency
Declares a reference to another package in the workspace or as a fetched
dependency. Returns a Dependency handle.
var deps = graph.dependency("mylib", "local:../mylib");
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.
Artifact
The Artifact handle is returned by add_exe, add_static_lib,
add_shared_lib, and add_test.
artifact.link
Links another artifact as a dependency. The linker will include it.
var core = graph.add_static_lib({ name = "core", root = "src/core/lib.fol" });
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.link(core);
Equivalent to Zig’s artifact.linkLibrary(dep).
artifact.import
Imports a module into this artifact’s compilation scope.
var utils = graph.add_module({ name = "utils", root = "src/utils.fol" });
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.import(utils);
Equivalent to Zig’s artifact.root_module.addImport(name, module).
artifact.add_generated
Declares that this artifact depends on a generated file being produced before it can compile.
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.
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 a generated file as a path argument.
var cfg = graph.copy_file({
name = "config",
source = "config/defaults.toml",
dest = "gen/config.toml",
});
var run = graph.add_run(app);
run.add_file_arg(cfg);
Equivalent to Zig’s run.addFileArg(file).
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 graph.dependency. It exposes the
public surface of another package.
dependency.module
Resolves a named module from the dependency.
var dep = graph.dependency("mylib", "local:../mylib");
var module = dep.module("core");
app.import(module);
dependency.artifact
Resolves a named artifact from the dependency.
var dep = graph.dependency("mylib", "local:../mylib");
var lib = dep.artifact("mylib-static");
app.link(lib);
dependency.step
Resolves a named step from the dependency.
var dep = graph.dependency("mylib", "local:../mylib");
var step = dep.step("codegen");
app_step.depend_on(step);
dependency.generated
Resolves a named generated output from the dependency.
var dep = graph.dependency("mylib", "local:../mylib");
var types = dep.generated("types.fol");
app.add_generated(types);
Build Options
Build options are named values passed from the command line into build.fol.
They follow Zig’s -D convention.
Syntax
fol code build -Dname=value
Multiple options can be passed in one command:
fol code build -Dtarget=x86_64-linux-gnu -Doptimize=release-fast -Dstrip=true
Standard Options
Two options are pre-defined by the build system: target and optimize.
They are read via dedicated graph methods.
Target
var target = graph.standard_target();
CLI: -Dtarget=arch-os-env
Format: arch-os-env triple. Examples:
| Triple | Meaning |
|---|---|
x86_64-linux-gnu | x86-64 Linux with glibc |
x86_64-linux-musl | x86-64 Linux with musl libc |
aarch64-linux-gnu | ARM64 Linux with glibc |
x86_64-macos | x86-64 macOS |
x86_64-windows-msvc | x86-64 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.
To pass the resolved value to an artifact:
var target = graph.standard_target();
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
target = target,
});
Optimize
var optimize = graph.standard_optimize();
CLI: -Doptimize=mode
Valid modes:
| Mode | Meaning |
|---|---|
debug | No optimization, full debug info |
release-safe | Optimized with safety checks |
release-fast | Maximum speed, no safety checks |
release-small | Minimize binary size |
Default: debug.
To pass the resolved value to an artifact:
var optimize = graph.standard_optimize();
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
optimize = optimize,
});
User Options
graph.option(...) declares a named option specific to the package.
var strip = graph.option({
name = "strip",
kind = "bool",
default = false,
});
CLI: -Dstrip=true
Option Kinds
| Kind | Example CLI | Default type |
|---|---|---|
bool | -Dverbose=true | false |
int | -Djobs=8 | 0 |
str | -Dprefix=/usr | "" |
enum | -Dbackend=llvm | first value |
path | -Droot=src/main.fol | "" |
target | -Dtarget=x86_64-linux-gnu | host target |
optimize | -Doptimize=release-fast | debug |
Using Option Values
Option handle values can be interpolated into strings or compared with ==:
var root_opt = graph.option({ name = "root", kind = "path", default = "src/main.fol" });
var app = graph.add_exe({ name = "app", root = root_opt });
Comparing in a when condition:
var strip = graph.option({ name = "strip", kind = "bool", default = false });
when(strip == true) {
{
var strip_step = graph.step("strip");
}
};
Shorthand vs Long Form
Both of these are equivalent:
fol code build -Dtarget=x86_64-linux-gnu
fol code build --build-option target=x86_64-linux-gnu
-D is the shorthand. -Dtarget= and -Doptimize= route to the dedicated
standard option slots. All other -Dname=value pairs route to user-declared
options.
Control Flow in build.fol
build.fol is a real FOL program. It supports when, loop, and user-defined
helper routines.
when
when conditionally executes build operations based on a boolean expression.
The canonical form for a conditional block with no case arms uses a double-brace default body:
when(optimize == "release-fast") {
{
var strip_step = graph.step("strip");
var packed = graph.add_system_tool({
tool = "strip",
output = "gen/app.stripped",
});
strip_step.attach(packed);
}
};
With explicit case arms:
when(target) {
case("x86_64-linux-gnu") {
graph.step("asan", "Enable address sanitizer");
}
case("aarch64-linux-gnu") {
graph.step("tsan", "Enable thread sanitizer");
}
* {
graph.step("default-check");
}
};
The inner double-brace { { ... } } is required for the default arm when no
case clauses are present. This is how the FOL parser distinguishes the
default body from raw statements.
Conditional Expressions
Any boolean or option value comparison is valid:
when(optimize == "release-fast") { { ... } };
when(strip == true) { { ... } };
when(target == "x86_64-linux-gnu") { { ... } };
The comparison is resolved at build evaluation time using the values passed
via -D flags. If no value was provided, the declared default is used.
loop
loop iterates over a list and runs the body for each element.
loop(name in {"core", "io", "utils"}) {
graph.add_static_lib({ name = name, root = name });
};
The loop variable (name) is bound in scope for each iteration. It can be
used anywhere inside the body as a string value.
Loop over a list with multiple fields:
loop(name in {"core", "io"}) {
var lib = graph.add_static_lib({ name = name, root = name });
graph.install(lib);
};
The iterable is a container literal { elem1, elem2, ... }. Currently only
string lists are supported as loop iterables in build.fol.
Helper Routines
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(graph: Graph, name: str, root: str): Artifact = {
return graph.add_static_lib({ name = name, root = root });
}
pro[] build(graph: Graph): non = {
var core = make_lib(graph, "core", "src/core/lib.fol");
var io = make_lib(graph, "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 receives the graph handle as a parameter because
graph is not a global — it is a parameter of build.
Helpers Calling Helpers
Helpers can call other helpers:
fun[] lib_root(name: str): str = {
return "src/" + name + "/lib.fol";
}
fun[] add_lib(graph: Graph, name: str): Artifact = {
return graph.add_static_lib({ name = name, root = lib_root(name) });
}
pro[] build(graph: Graph): non = {
var core = add_lib(graph, "core");
var app = graph.add_exe({ name = "app", root = "src/main.fol" });
app.link(core);
graph.install(app);
}
Combined Example
fun[] make_lib(graph: Graph, name: str): Artifact = {
return graph.add_static_lib({ name = name, root = name });
}
pro[] build(graph: Graph): non = {
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(graph, name);
};
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
target = target,
optimize = optimize,
});
graph.install(app);
graph.add_run(app);
when(strip == true) {
{
var strip_step = graph.step("strip");
var packed = graph.add_system_tool({
tool = "strip",
output = "gen/app.stripped",
});
strip_step.attach(packed);
}
};
}
Artifacts, Modules, and Generated Files
The build graph tracks three kinds of compilable or producible outputs: artifacts, modules, and generated files.
Artifacts
Artifacts are the primary compiled outputs of a package.
| Kind | Method | Output |
|---|---|---|
| Executable | graph.add_exe | Binary |
| Static library | graph.add_static_lib | .a / .lib |
| Shared library | graph.add_shared_lib | .so / .dylib |
| Test bundle | graph.add_test | Runnable test binary |
All artifact constructors accept the same base config record:
var app = graph.add_exe({
name = "app", // required: output name
root = "src/main.fol", // required: entry-point source file
target = target, // optional: Target handle
optimize = optimize, // optional: Optimize handle
});
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);
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:
graph.install_file("config/defaults.toml");
graph.install_dir("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 dep = graph.dependency("mylib", "local:../mylib");
var logger = dep.module("logger");
app.import(logger);
Generated Files
Generated files are outputs produced before compilation. They must be declared in the graph so the build system knows to produce them and what depends on them.
Kinds
| Kind | Method | Description |
|---|---|---|
| Write | graph.write_file | Written with literal string contents |
| Copy | graph.copy_file | Copied from a source path |
| Tool output | graph.add_system_tool | Produced by an external tool |
| Codegen | graph.add_codegen | Produced by the FOL codegen pipeline |
| Captured run | run.capture_stdout() | Stdout of a run step |
Connecting Generated Files
A generated file must be connected to the graph entity that depends on it.
Attach to a step (the step triggers its production):
var gen = graph.add_system_tool({ tool = "protoc", 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);
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).
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
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(graph: Graph): non = {
var target = graph.standard_target();
var app = graph.add_exe({
name = "app",
root = "src/main.fol",
target = target,
});
graph.install(app);
graph.add_run(app);
};
Target precedence is:
--target- artifact target declared in
build.fol - host default
Accepted Target Spellings
The backend accepts both canonical Rust triples and the shorter FOL spellings already used in build code.
Examples:
x86_64-linux-gnu->x86_64-unknown-linux-gnux86_64-linux-musl->x86_64-unknown-linux-muslaarch64-linux-gnu->aarch64-unknown-linux-gnuaarch64-linux-musl->aarch64-unknown-linux-muslx86_64-windows-gnu->x86_64-pc-windows-gnux86_64-windows-msvc->x86_64-pc-windows-msvcaarch64-windows-msvc->aarch64-pc-windows-msvcx86_64-macos-gnu->x86_64-apple-darwinaarch64-macos-gnu->aarch64-apple-darwin
Unknown spellings are rejected before the backend tries to build.
Build vs Run
Cross-building and cross-running are different operations.
fol code buildsupports host and non-host targetsfol code emit ruststays available for source inspectionfol code runis host-onlyfol code testis host-only
If the selected target does not match the current machine, run and test
fail early with a diagnostic instead of trying to execute the foreign binary.
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.
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_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`
BK_GO `go`
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 = "z\n"
var anotherOne: str = "語\n"
Raw characters
Raw character literals do not process any escapes. They are enclosed within single-quotes U+0027 (') with the exception of U+0027 itself:
var aCharacter: chr = 'z'
Strings
A string is a single or a sequence of Unicode elements enclosed within quotes U+0022 (") with the exception of U+0022 itself, which must be escaped by a preceding U+005C character (\).
var hiInEnglish: str = "Hello, world!\n"
var hInCantonese: str = "日本語"
Line-breaks are allowed in strings. A line-break is either a newline (U+000A) or a pair of carriage return and newline (U+000D, U+000A). Both byte sequences are normally translated to U+000A, but as a special exception, when an unescaped U+005C character (\ occurs immediately before the line-break, the U+005C character, the line-break, and all whitespace at the beginning of the next line are ignored. Thus a and b are equal:
var a: str = "foobar";
var b: str = "foo\
bar";
assert(a,b);
Escape sequences
Some additional escapes are available in either character or non-raw string literals.
| code | description |
|---|---|
| \p | platform specific newline: CRLF on Windows, LF on Unix |
| \r, \c | carriage return |
| \n, \l | line feed (often called newline) |
| \f | form feed |
| \t | tabulator |
| \v | vertical tabulator |
| \\ | backslash |
| \“ | quotation mark |
| \’ | apostrophe |
| \ ‘0’..‘9’+ | character with decimal value d; all decimal digits directly following are used for the character |
| \a | alert |
| \b | backspace |
| \e | escape [ESC] |
| \x HH | character with hex value HH; exactly two hex digits are allowed |
| \u HHHH | unicode codepoint with hex value HHHH; exactly four hex digits are allowed |
| \u {H+} | unicode codepoint; all hex digits enclosed in {} are used for the codepoint |
Raw strings
Just like raw characters, raw string literals do not process any escapes either. They are enclosed within single-quotes U+0027 (') with the exception of U+0027 itself:
var hiInEnglish: str = 'Hello, world!'
Booleans
The two values of the boolean type are written true and false:
var isPresent: bol = false;
Numbers
A number in the current front-end is either an integer or a floating-point literal. Imaginary suffix forms are intentionally outside the hardened lexer/parser contract for this phase.
Intigers
An integer has one of four forms:
- A decimal literal starts with a decimal digit and continues with decimal digits and optional separating underscores.
- A hex literal starts with
0xor0Xand then uses hex digits with optional separating underscores. - An octal literal starts with
0oor0Oand then uses octal digits with optional separating underscores. - A binary literal starts with
0bor0Band then uses binary digits with optional separating underscores.
var decimal: int = 45;
var hexadec: int = 0x6HF53BD5;
var octal: int = 0o822371;
var binary: int = 0b010010010;
Underscore
Underscore character U+005F (_) is a special character, that does not represent anything withing the number laterals. An integer lateral containing this character is the same as the one without. It is used only as a syntastc sugar:
var aNumber: int = 540_467;
var bNumber: int = 540467;
assert(aNumber, bNumber)
Floating points
A floating-point has one of two forms:
- A decimal literal followed by a period character
U+002E(.). This is optionally followed by another decimal literal. - A decimal literal that follows a period character
U+002E(.). - A decimal literal followed by a period with no fractional digits is also accepted by the current front-end.
var aFloat: flt = 3.4;
var bFloat: flt = .4;
var cFloat: flt = 1.;
Current front-end note
Imaginary literals such as 5i remain a language-design topic in the book, but they
are not tokenized or lowered by the current hardened stream/lexer/parser pipeline.
Symbols
Operators
Fol allows user defined operators. An operator is any combination of the following characters:
= + - * / > .
@ $ ~ & % < :
! ? ^ # ` \ _
The grammar uses the terminal OP to refer to operator symbols as defined here.
Brackets
Bracket punctuation is used in various parts of the grammar. An open bracket must always be paired with a close bracket. Here are type of brackets used in FOL:
| bracket | type | purpose |
|---|---|---|
{ } | Curly brackets | Code blocks, Namespaces, Containers |
[ ] | Square brackets | Type options, Container acces, Multithreading |
( ) | Round brackets | Calculations, Comparisons, Argument passing |
< > | Angle brackets |
The grammar uses the terminal BR to refer to operator symbols as defined here.
Statements And Expressions
This section covers executable syntax.
FOL separates executable forms into two broad groups:
- statements: forms executed for control flow, side effects, or declaration within a block
- expressions: forms that compute a value
Statements
Statements include:
- local declarations
- assignments
- routine calls used for side effects
- branching
- looping
- nested blocks
Examples:
var x: int = 0;
x = 5;
if (x > 0) { .echo(x) }
for (item in items) { .echo(item) }
Expressions
Expressions include:
- operator expressions
- literal expressions
- range expressions
- access expressions
- call expressions
Expressions can be nested freely and may appear in declarations, assignments, return statements, control-flow headers, and other expressions.
Statements
Statements are executable forms that primarily control behavior, perform side effects, or introduce local declarations inside a block.
This chapter family focuses on:
- declaration statements
- assignment-like statements
- control flow
- block structure
Examples:
var x: int = 0;
x = 1;
if (x > 0) { .echo(x) }
for (item in items) { .echo(item) }
Control
At least two linguistic mechanisms are necessary to make the computations in programs flexible and powerful: some means of selecting among alternative control flow paths (of statement execution) and some means of causing the repeated execution of statements or sequences of statements. Statements that provide these kinds of capabilities are called control statements. A control structure is a control statement and the collection of statements whose execution it controls. This set of statements is in turn generally structured as a block, which in addition to grouping, also defines a lexical scope.
There are two types of control flow mechanisms:
- choice -
when - loop -
loop
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){}; * {}; };
when(type){ on (channel){}; on (channel){}; };
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
when(str) {
on (channel){ // implementation }
on (channel){ // implementation }
* { // default implementation }
}
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
In fol, every calcultaion, needs to be enclosed in rounded brackets ( //to evaluate ) - except in one line evaluating, the curly brackets are allowed too { // to evaluate }:
fun adder(a, b: int): int = {
retun a + b // this will throw an error
}
fun adder(a, b: int): int = {
retun (a + b) // this is the right way to enclose
}
Order of evaluation is strictly left-to-right, inside-out as it is typical for most others imperative programming languages:
.echo((12 / 4 / 8)) // 0.375 (12 / 4 = 3.0, then 3 / 8 = 0.375)
.echo((12 / (4 / 8))) // 24 (4 / 8 = 0.5, then 12 / 0.5 = 24)
Calculation expressions include:
- arithmetics
- comparison
- logical
- compounds
Arithmetics
The behavior of arithmetic operators is only on intiger and floating point primitive types. For other types, there need to be operator overloading implemented.
| symbol | description |
|---|---|
| - | substraction |
| * | multiplication |
| + | addition |
| / | division |
| % | reminder |
| ^ | exponent |
assert((3 + 6), 9);
assert((5.5 - 1.25), 4.25);
assert((-5 * 14), -70);
assert((14 / 3), 4);
assert((100 % 7), 2);
Comparisons
Comparison operators are also defined both for primitive types and many type in the standard library. Parentheses are required when chaining comparison operators. For example, the expression a == b == c is invalid and may be written as ((a == b) == c).
| Symbol | Meaning |
|---|---|
| == | equal |
| != | not equal |
| >> | greater than |
| << | Less than |
| >= | greater than or equal to |
| <= | Less than or equal to |
assert((123 == 123));
assert((23 != -12));
assert((12.5 >> 12.2));
assert(({1, 2, 3} << {1, 3, 4}));
assert(('A' <= 'B'));
assert(("World" >= "Hello"));
Logical
A branch of algebra in which all operations are either true or false, thus operates only on booleans, and all relationships between the operations can be expressed with logical operators such as:
and(conjunction), denoted(x and y), satisfies(x and y) = 1ifx = y = 1, and(x and y) = 0otherwise.or(disjunction), denoted(x or y), satisfies(x or y) = 0ifx = y = 0, and(x or) = 1otherwise.not(negation), denoted(not x), satisfies(not x) = 0ifx = 1and (not x) = 1ifx = 0`.
assert((true and false), (false and true));
assert((true or false), true)
assert((not true), false)
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
There are two range expressions:
- Defined ranges
- Undefined ranges
Defined ranges
Defined ranges represent a group of values that are generated as a sequence based on some predefined rules. Ranges are represented with two dots .. operator.
{ 1..8 } // a range from 1 to 8
{ 1,2,3,4,5,6,7,8 }
{ 8..1 } // a range from 8 to 1
{ 8,7,6,5,4,3,2,1 }
{ 1..8..2 } // a range from 1 to 8 jumping by 2
{ 1,3,5,7 }
{ 3..-3 } // a range from 4 to -4
{ 3,2,1,0,-1,-2,-3 }
{ -3..3 } // a range from -3 to 3
{ -3,-2,-1,0,1,2,3 }
{ ..5 } // a range form 0 to 5
{ 0,1,2,3,4,5 }
{ ..-5 } // a range from 0 to -5
{ 0,-1,-2,-3,-4,-5 }
{ 5.. } // a range from 5 to 0
{ 5,4,3,2,1,0 }
{ -5.. } // a range from -5 to 0
{ -5,-4,-3,-2,-1,0 }
| syntax | meaning |
|---|---|
start..end | from start to end |
..end | from zero to end |
start.. | from start to zero |
Undefined ranges
Undefined ranges represent values that have only one side defined at the definition time, and the compiler defines the other side at compile time. They are represented with three dots ...
{ 2... } // from 2 to infinity
| syntax | meaning |
|---|---|
start... | from start to infinite |
In most of the cases, they are used for variadic parameters passing:
fun calc(number: ...int): int = { return number[0] + number[1] + number[2] * number[3]}
Access
There are four access expresions:
- namespace member access
- routine member access
- container memeber access
- field member access
Subprogram access
One access form is the receiver-style routine call, often called a “method-call expression” in other languages. In FOL this is still procedural syntax: the receiver value is the first routine input, and dot-call form is just sugar at the call site.
A method call consists of an expression (the receiver) followed by a single dot
., an expression path segment, and a parenthesized expression-list:
"3.14".cast(float).pow(2); // casting a numbered string to float, then rising it to power of 2
Read:
value.method(arg1, arg2)
as the procedural idea:
method(value, arg1, arg2)
The syntax does not introduce classes or object-owned method dispatch by itself.
Namespaces access
Accesing namespaces is done through double colon operator :::
use log: std = {"fmt/log"}; // using the log namespace of fmt
io::console::write_out.echo(); // echoing out
Container access
Array, Vectors, Sequences, Sets
Containers can be indexed by writing a square-bracket-enclosed expression of type int[arch] (the index) after them.
var collection: int = { 5, 4, 8, 3, 9, 0, 1, 2, 7, 6 }
collection[5] // get the 5th element staring from front (this case is 0)
collection[-2] // get the 3th element starting from back (this case is 1)
Containers can be accessed with a specified range too, by using colon within a square-bracket-enclosed:
| syntax | meaning |
|---|---|
: | the whole container |
elA:elB | from element elA to element elB |
:elA | from beginning to element elA |
elA: | from element elA to end |
collection[-0] // last item in the array
{ 6 }
collection[-1:] // last two items in the array
{ 7, 6 }
collection[:-2] // everything except the last two items
{ 5, 4, 8, 3, 9, 0, 1, 2 }
If we use double colon within a square-bracket-enclosed then the collection is inversed:
| syntax | meaning |
|---|---|
:: | the whole container in reverse |
elA::elB | from element elA to element elB in reverse |
::elA | from beginning to element elA in reverse |
elA:: | from element elA to end in reverse |
collection[::] // all items in the array, reversed
{ 6, 7, 2, 1, 0, 9, 3, 8, 4, 5 }
collection[2::] // the first two items, reversed
{ 4, 5 }
collection[-2::] // the last two items, reversed
{ 6, 7 }
collection[::-3] // everything except the last three items, reversed
{ 2, 1, 0, 9, 3, 8, 4, 5 }
Matrixes
Matrixes are 2D+ arrays, thus they have a bit more complex acces way:
var aMat = mat[int, int] = { {1,2,3}, {4,5,6}, {7,8,9} };
nMat[[1][0]] // this will return 4
// first [] accesses the first dimension, then second [] accesses the second
All other operations are the same like arrays.
Maps
Accesing maps is donw by using the key within square-bracket-enclosed:
var someMap: map[str, int] = { {"prolog", 1}, {"lisp", 2}, {"c", 3} }
someMap["lisp"] // will return 2
Axioms
Accesing axioms is more or less like accessing maps, but more verbose and matching through backtracing, and the return is always a vector of elements (empty if no elements are found):
var parent: axi[str, str] = { {"albert","bob"}, {"alice","bob"}, {"bob","carl"}, {"bob","tom"} };
parent["albert",*] // this will return strng vector: {"bob"}
parent["bob",*] // this will return strng vector: {"carl","tom"}
parent[*,_] // this will match to {"albert", "alice", "bob"}
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] // will match all possible values that have "tom" ot "bob" as second element
// in this case will be a strng vector: {"albert", "alice", "bob"}
a more complex matching:
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"],_,*,_] // this will return string vector: {"mike", bruce}
// it matches everything that has aClass ad "fri" within
// and ignore ones with meh symbol
Avaliability
To check if an element exists, we add : before accessing with []. Thus this will return true if element exists.
var val: vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
val:[5] // returns true
val:[15] // returns false
var likes: axi[str, str] = { {"bob","alice"} , {"alice","bob"}, {"dan","sally"} };
likes["bob","alice"]: // will return true
likes["sally","dan"]: // will return false
In-Place assignment
One of the features that is very important in arrays is that they can assign variables immediately:
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] // this equals to {2, 4, 6, 8, 10} and same time assign to Y
.echo(Y) // will print {2, 4, 6, 8, 10}
This dows not look very much interesting here, you can just as easy assign the whole filtered array to a variable, but it gets interesting for axioms:
var parent: axi[str, str] = { {"albert","bob"}, {"alice","bob"}, {"bob","carl"}, {"bob","tom"}, {"maggie","bill"} };
parent:[* => Y,"bob"] // this one returns true if we find a parent of "bob"
// same time it assigns parent to a string vector `Y`
Field access
Field access expressoin accesses fields inside constructs. Here is a recorcalled 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 types of fields that can be accesed within constructs:
- methods
- data
Methods
Methods are accessed with the same dot form as routine member access, but they remain receiver-qualified routines rather than object-owned behavior.
user1.getName()
Data
There are multiple ways to acces data within the construct. The easiest one is by dot operator .:
user1.email // accessing the field through dot-accesed-memeber
Another way is by using square bracket enclosed by name:
user1[email] // accessing the field through square-bracket-enclosed by name
And lastly, by square bracket enclosed by index:
user1[0] // accessing the field through square-bracket-enclosed by index
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 three layers separate:
- intrinsics:
compiler-owned operations such as
.eq(...),.len(...),check(...), andpanic(...) core: ordinary foundational library code that should work across targetsstd: broader library code such as filesystem, networking, serialization, and other richer services
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 is
expected to go through fol-runtime where policy matters. In practice that
means:
.len(...)uses the runtime length helper.echo(...)uses the runtime echo hook and formatting contractcheck(...)uses the runtime recoverable-result inspection contract- scalar comparisons and
.not(...)may lower to native target operations
Comparison
.eq(left, right)
.nq(left, right)
.lt(left, right)
.gt(left, right)
.ge(left, right)
.le(left, right)
Current V1 rule:
.eq(...)and.nq(...)work on comparable scalar pairs.lt(...),.gt(...),.ge(...), and.le(...)work on ordered scalar pairs
If you call them with the wrong number of arguments or with unsupported type families, the compiler reports an intrinsic-specific type error.
Boolean
.not(flag)
Current V1 rule:
.not(...)accepts exactly onebol
Query
.len(items)
Current V1 rule:
.len(...)accepts exactly one operand- the operand must currently be one of:
strarr[...]vec[...]seq[...]set[...]map[...]
In the current compiler, .len(...) is the only implemented query intrinsic.
Diagnostic
.echo(value)
Current V1 rule:
.echo(...)accepts exactly one argument- it emits the value through the
fol-runtimedebug hook - it then forwards the same value unchanged
So 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 routine call failed and returnsbolpanic(...)aborts control flow immediately
These are described in more detail in the recoverable-error chapter.
Current V1 deferred intrinsics
The registry already reserves more names than the compiler implements.
That does not mean they work today.
Reserved but deferred for likely V1.x
ascastassert.cap(...).is_empty(...).low(...).high(...)
These are recognized as registry-owned language surfaces, but the current compiler rejects them with explicit milestone-boundary diagnostics.
Reserved for later V2
- bitwise helpers such as
.bit_and(...),.bit_or(...),.shl(...),.shr(...),.rotl(...),.rotr(...),.pop_count(...),.clz(...),.ctz(...),.byte_swap(...),.bit_reverse(...) - overflow-mode helpers such as
.checked_add(...),.wrapping_add(...),.saturating_add(...),.overflowing_add(...), and their subtraction forms
These are intentionally reserved now so the language can grow without
accidental user-space name collisions, but they are not part of the current
V1 compiler.
Reserved for later V3
.de_alloc(...).give_back(...).address_of(...).pointer_value(...).borrow_from(...)
These depend on later ownership, pointer, and low-level systems semantics.
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 postfix unwrap
!.
For example:
ali MaybeText: opt[str]
ali Failure: err[str]
fun[] unwrap_optional(value: MaybeText): str = {
return value!
}
fun[] unwrap_failure(value: Failure): str = {
return 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 = { ... }
are handled with:
check(expr)expr || fallback
not with shell unwrap.
Current compiler truth
The current compiler has one shared intrinsic registry crate:
fol-intrinsics
That registry is the source of truth for:
- canonical intrinsic names and aliases
- milestone availability (
V1/V2/V3) - type-checking selection rules
- lowering mode
- backend/runtime role classification
The current runtime companion for implemented V1 intrinsics is:
fol-runtime
- intrinsic names
- aliases
- categories
- current milestone availability
- deferred-roadmap classification
- lowering mode
- backend-facing role
So the short rule is:
- parser recognizes intrinsic syntax
fol-intrinsicsowns intrinsic identity- type checking validates intrinsic calls
- lowering maps them to explicit IR shapes
This page should describe only the subset that is actually implemented, plus clearly marked deferred surfaces.
Macros
Are a very complicated system, and yet can be used as simply as in-place replacement. A lot of build-in macros exist in the language to make the code more easy to type. Below are some system defined macros.
For example, wherever $ is before any variable name, its replaced with .to_string. Or wherever ! is before bol name, its replaced with .not but when the same ! is placed before ptr it is replaced with .delete_pointer.
def '$'(a: any): mac = '.to_string'
def '!'(a: bol): mac = '.not '
def '!'(a: ptr): mac = '.delete_pointer';
def '*'(a: ptr): mac = '.pointer_value';
def '#'(a: any): mac = '.borrow_from';
def '&'(a: any): mac = '.address_of';
Alternatives
Alternatives are used when we want to simplify code. For example, define an alternative, so whenever you write +var it is the same as var[+].
def '+var': alt = 'var[+]'
def '~var': alt = 'var[~]'
def '.pointer_content': alt = '.pointer_value'
Defaults
Defaults are a way to change the default behaviour of options. Example the default behaviour of str when called without options. By defalt str is it is saved on stack, it is a constant and not public, thus has str[pil,imu,nor], and we want to make it mutable and saved on heap by default:
def 'str': def[] = 'str[new,mut,nor]'
Templates
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
- 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
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 allocate memory on heap, the var[new] is used more about memory, ownreship and pointer :
pro[] main: int = {
var[new] aSequence: arr[str] = { "get", "over", "it" }; // this array is stored in stack
}
Dynamic arrays
Dynamic are similar to arrays but of dynamic length which may change during runtime (like strings). A dynamic array s is always indexed by integers from 0 to .len(s)-1 and its bounds are checked.
Current V1 intrinsic note:
.len(...)is implemented.low(...),.high(...),.cap(...), and.is_empty(...)are registry-owned deferred surfaces, not activeV1intrinsics
So the safe current query surface for containers is .len(...).
Current V1 runtime note:
arr[...]remains the fixed-size container familyvec[...]andseq[...]are lowered onto dedicated runtime container typesset[...]andmap[...]also use runtime-backed container types- runtime-backed
set[...]andmap[...]preserve deterministic ordering for rendering and backend-visible behavior in the current compiler
{{% notice tip %}}
Dynamic arrays are a dynamically allocated (hence the name), thus if not allocated in heap but in stack, the size will be defined automatically in compile time and will be changed to static array.
{{% /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 a growable containers too, thus if not allocated in heap but in stack, the size will be defined automatically in compile time and will be changet to static containers
{{% /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
Strings are a complex type that are made of array of chars with null terminator ‘\0’, and by default is utf8 encoded:
str[]
Number
Number type is an abstraction of integer and float type in the current hardened front-end. Imaginary-number support is still outside the active stream/lexer/parser contract.
num[]
Pointer
ptr[]
Error
err[]
Special
Optional
Either are empty or have a value
opt[]
Never
nev[]
The never type is a type with no values, representing the result of computations that never complete.
Union
Union is a data type that allows different data types to be stored in the same memory locations. Union provides an efficient way of reusing the memory location, as only one of its members can be accessed at a time. It uses a single memory location to hold more than one variables. However, only one of its members can be accessed at a time and all other members will contain garbage values. The memory required to store a union variable is the memory required for the largest element of the union.
We can use the unions in the following locations.
- Share a single memory location for a variable and use the same location for another variable of different data type.
- Use it if you want to use, for example, a long variable as two short type variables.
- We don’t know what type of data is to be passed to a function, and you pass union which contains all the possible data types.
var aUnion: uni[int[8], int, flt];
Any
any[]
Null
nil
In the current V1 compiler milestone, nil is accepted only when type
checking already knows it is flowing into an opt[...] or err[...] shell.
Plain inference from nil alone is not supported.
Current V1 shell note:
opt[...]anderr[...]are shell values.- postfix unwrap
value!applies to those shell values. - routine calls declared with
ResultType / ErrorTypeare noterr[...]shells. - use
check(...)orexpr || fallbackfor those routine calls instead of!. - use
err[...]when you need a storable error value.
Declarations And Items
This section covers the main named program elements of FOL.
The declaration families include:
- bindings:
var,let,con,lab - routines:
fun,pro,log - type and construct declarations:
typ,ali - contract-like declarations:
std
Module-like forms such as use, def, seg, and imp are covered in the modules chapter because they primarily define source layout, namespace, and composition boundaries.
Variables
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;
}
Ownership, borrowing, and pointer-level aliasing are later systems-language
work and are described in the memory chapters as future milestones rather than
as part of the current V1 compiler contract.
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
If we want to reference a variable, the easiest way is to borrow the variable, use inside another scope (or the same) and return it back. If the ownership is not returned manually, by the end of the scope, it gets returned automatically.
pro[] main: int = {
var[~] aVar: int = 55;
{
var[bor] newVar: int = aVar // var[bor] represents borrowing
.echo(newVar) // this return 55
}
.echo(aVar) // here $aVar it not accesible, as the ownership returns at the end of the scope
.echo(newVar) // we cant access the variable because the scope has ended
}
More on borrowing you can find here
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]
Flag options can have symbol aliases eg. var[mut] is the somename as var[~].
| 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 | |
| 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
}
However, when we use two option in varable, only one can use the alternative form, so instead of using var[mut,exp], this can be used +var[mut] or +var[~], or vice varsa ~var[exp] or ~var[+]:
+var[mut] aVar: int = 55
fun[] main(): int = {
.echo(aVar)
return aVar
}
Types
Immutable types (constants)
By default when a variable is defined without options, it is immutable type, for example here an intiger variable:
pro[] main: int = {
var aNumber: int = 5;
aNumber = 54; // reassigning varibale $aNumber thorws an error
}
Mutable types
If we want a variable to be mutable, we have to explicitly pass as an option to the variable var[mut] or var[~]:
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
}
Reactive types
Current milestone note: reactive variables are part of a later milestone, not
the current V1 compiler contract. The syntax may appear in design examples,
but present-day V1 typechecking rejects reactive semantics explicitly.
Reactive types is a types that flows and propagates changes.
For example, in an normal variable setting, var a = b + c would mean that a is being assigned the result of b + c in the instant the expression is evaluated, and later, the values of b and c can be changed with no effect on the value of a. On the other hand, declared as reactive, the value of a is automatically updated whenever the values of b or c change, without the program having to re-execute the statement a = b + c to determine the presently assigned value of a.
pro[] main: int = {
var[mut] b, c = 5, 4;
var[rac] a: int = b + c
.echo(a) // prints 9
c = 10;
.echo(a) // now it prints 10
}
Static types
Current milestone note: static variables are also part of later systems/runtime
work. The current V1 compiler keeps them outside the implemented subset.
Is a variable which allows a value to be retained from one call of the function to another, meaning that its lifetime declaration. and can be used as var[sta] or var[!]. This variable is special, because if it is initialized, it is placed in the data segment (aka: initialized data) of the program memory. If the variable is not set, it is places in .bss segmant (aka: uninitialized data)
pro[] main: int = {
{
var[!] aNumber: int = 5
}
{
.echo(aNumber) // it works as it is a static variable.
}
}
Scope
As discussed before, files in the same package share one package scope. That means package-level functions and variables may be used across sibling files without importing those sibling files one by one.
However, package-private declarations are still different from exported declarations:
- default visibility means the declaration is available inside the same package
exp/+means the declaration may be used through imports from outside the packagehid/-means the declaration is visible only inside its own file
So the visibility model is:
- package scope by default
- exported outside the package with
exp - file-only with
hid
In order for a variable to be accessed by the importer, it needs the exp flag option, so var[exp], or var[+].
package shko, file1.fol
fun[exp] add(a, b: int): int = { return a + b }
fun sub(a, b: int): int = { return a - b }
package vij, file1.fol
use shko: loc = {"../folder/shko"}
fun[] main(): int = {
.echo(add(5, 4)) // this works, `add` is exported
.echo(sub(5, 4)) // this fails, `sub` is not exported
return add(5, 4)
}
There is even the opposite option too. If we want a function or variable to be used only inside its own file, even though the package is shared, then we use the hid option flag: var[hid] or var[-].
file1.fol
var[-] aVar: str = "yo, sup!"
file2.fol
fun[] main(): int = {
.echo(aVar) // this throws, `aVar` is hidden to its own file
return 0
}
Multiple
Many to many
Many variables can be assigned at once, This is especially usefull, if variables have same options but different types eg. variable is mutabe and exported:
~var[exp] oneVar: int[32] = 24, twoVar = 13, threeVar: string = "shko";
Or to assign multiple variables of the same type:
~var[exp] oneVar, twoVar: int[32] = 24, 13;
To assign multiple variables of multiple types, the type is omitted, however, this way we can not put options on the type (obviously, the default type is assign by compiler):
~var[exp] oneVar, twoVar, threeVar = 24, 13, "shko";
Another “shameless plagiarism” from golang can be used by using ( ... ) to group variables:
~var[exp] (
oneVar: int[32] = 13,
twoVar: int[8] = 13,
threeVar: str = "shko",
)
Many to one
Many variables of the same type can be assigned to one output too:
var oneVar, twoVar: int[8] = 2;
However, each of them gets a copy of the variable on a new memory address:
.assert(&oneVar == &twoVar) // this will return false
One to many
And lastly, one variable can be assigned to multiple ones. This by using container types:
oneVar grouppy: seq[int] = { 5, 2, 4, 6 }
Or a more complicated one:
var anothermulti: set[str, seq[num[f32]]] = { "string", {5.5, 4.3, 7, .5, 3.2} }
Or a very simple one:
var simplemulti: any = { 5, 6, {"go", "go", "go"} }
Containers
Containers are of special type, they hold other types within. As described before, there are few of them
Access
To acces container variables, brackets like this [] are use:
var shortvar = anothermulti[1][3] // compiler will copy the value `anothermulti[1][3]` (which is a float) to a new memory location
Routines
Routines are callable declarations.
FOL has three routine families:
pro: procedures, used for effectful workfun: functions, intended for ordinary value-producing computationlog: logical routines and relation-like callable forms
Routine declarations support a shared structural pattern:
fun[options] name(params): return_type = { body }
pro[options] name(params): return_type = { body }
log[options] name(params): return_type = { body }
FOL also allows an alternate header style:
fun[options] name: return_type = (params) { body }
This chapter family covers parameters, calls, defaults, variadics, return values, and routine-specific semantics.
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. The only restriction with this approach is that after a keyword parameter appears in the list, all remaining parameters must be keyworded. This restriction is necessary because a position may no longer be well defined after a keyword parameter has appeared.
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
}
Default arguments
Formal parameters can have default values too. A default value is used if no actual parameter is passed to the formal parameter. The default parameter is assigned directly after the formal parameter declaration. The compiler converts the list of arguments to an array implicitly. The number of parameters needs to be known at compile time.
fun[] calc(el1, el2, el3: 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
}
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. When variadic routine is used, the default arguments can not be used at the same time.
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)
}
... is called unpack operator - just like in Golang. In the routine above, you see ..., which means pack all incoming arguments into seq[int] after the first argument. The sequence then is turned into a list at compile time.
{{% notice warn %}}
Nested procedures don’t have access to the outer scope, while nested function have but can’t change the state of it.
{{% /notice %}}
Return
The return type of the routine has to always be defined, just after the formal parameter definition. Following the general rule of FOL:
fun[] add(el1, el2: int[64]): int[64] = { result = el1 + el2 }
To make it shorter (so we don’t have to type int[64] two times), we can use a short form by omitting the return type. The compiler then will assign the returntype the same as the functions return value.
fun[] add(el1, el2: int[64]) = { result = el1 + el2 }
{{% notice info %}}
Current V1 routine summary:
- routines declare a success type after
: - routines may also declare a recoverable error type after
/ report exprexits through that declared error path- routine call results declared with
/ ErrorTypeare noterr[...]shell values - use
check(...)orexpr || fallbackfor those calls - ordinary plain-value use of
/ ErrorTypecalls is rejected - keep postfix
!foropt[...]anderr[...]shell values
{{% /notice %}}
The implicitly declared variable result is of the same type of the return type. For it top be implicitly declared, the return type of the function shoud be always declared, and not use the short form. The variable is initialized with zero value, and if not changed during the body implementation, the same value will return (so zero).
pro main(): int = {
fun[] add(el1, el2: int[64]): int[64] = { result = el1 + el2 } // using the implicitly declared $result variable
fun[] sub(el1, el2: int[64]) = { return el1 - el2 } // can't access the result variable, thus we use return
}
Recoverable error-aware routines use the current signature form:
fun[] read(path: str): int / str = {
report "missing path"
}
and are handled at the call site with check(...) or || rather than shell
unwrap or plain propagation.
Current intrinsic note:
.echo(...)is a dot-root diagnostic intrinsiccheck(...)is a keyword intrinsic for recoverable-call inspectionpanic(...)is a keyword intrinsic for immediate abortasandcastare registry-owned but still deferred in currentV1
The final expression in the function will be used as return value. For this to be used, the return type of the function needs to be defined (so the function cnat be in the short form)). ver this can be used only in one statement body.
pro main(): int = {
fun[] add(el1, el2: int[64]): int[64] = { el1 + el2 } // This is tha last statement, this will serve as return
fun[] someting(el1,el2: int): int = {
if (condition) {
} else {
}
el1 + el2 // this will throw an error, cand be used in kulti statement body
}
fun[] add(el1, el2: int[64]) = { el1 + el2 } // this will throw an error, we can't use the short form of funciton in this way
Alternatively, return and report can exit a routine early from within
control flow. See the recoverable-error chapter for the full current V1
contract and the shell-vs-routine distinction.
Procedures
Procedures are most common type of routines in Fol. When a procedure is “called” the program “leaves” the current section of code and begins to execute the first line inside the procedure. Thus the procedure “flow of control” is:
- The program comes to a line of code containing a “procedure call”.
- The program enters the procedure (starts at the first line in the procedure code).
- All instructions inside of the procedure are executed from top to bottom.
- The program leaves the procedure and goes back to where it started from.
- Any data computed and RETURNED by the procedure is used in place of the procedure in the original line of code.
Procedures have side-effects, it can modifies some state variable value(s) outside its local environment, that is to say has an observable effect besides returning a value (the main effect) to the invoker of the operation. State data updated “outside” of the operation may be maintained “inside” a stateful object or a wider stateful system within which the operation is performed.
Current milestone note:
- ordinary procedure declarations are part of
V1 - recoverable procedure errors (
Result / Error) are part ofV1 - ownership-, borrowing-, and heap-move-specific calling conventions are later systems-language work
So this chapter describes the routine surface that exists now, while any pointer/borrowing examples should be read as future design rather than current compiler behavior.
Procedures can also declare a custom recoverable error type with / after the result type:
pro[] write(path: str): int / io_err = {
report "permission denied";
}
The first : declares the result type, and / declares the routine error type.
Current V1 note:
- a procedure declared as
pro[] write(...): T / Edoes not produce anerr[E]shell value that can be unwrapped with! - it produces a recoverable routine result with a success path and an error path
- use
check(...)orexpr || fallbackat the call site - keep postfix
!foropt[...]anderr[...]shell values only
Passing values
In the current V1 compiler, procedure parameters are ordinary typed inputs.
You pass values to them exactly as you would pass values to any other routine.
The more ambitious ownership- and borrowing-specific parameter rules described
in older drafts are not part of the current V1 procedure contract yet.
Simple example:
pro[] write_line(text: str): non = {
.echo(text)
}
pro[] main(): int = {
var message: str = "hello"
write_line(message)
return 0
}
Ownership and borrowing
Ownership-, borrowing-, and pointer-specific procedure semantics are part of the
later V3 systems milestone, not the current V1 compiler surface.
Older drafts used several experimental spellings for those ideas, including:
- all-caps borrowable parameter names
.give_back(...)- double-parenthesis routine parameters
Those forms should be read as future design notes, not as current language guarantees.
The current authoritative split is:
- ordinary procedures and functions are part of
V1 - recoverable routine errors are part of
V1 - ownership/borrowing calling conventions are part of
V3
See the memory chapters and 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 }
When a function has a custom recoverable error type, use / after the result type:
fun[] read(path: str): str / io_err = {
report "file not found";
}
The first : declares the result type, and / declares the routine error type.
Current V1 note:
- a function declared as
fun[] read(...): T / Edoes not return anerr[E]shell value - it returns through a recoverable routine error path
- handle that path immediately with
check(...)orexpr || fallback - plain assignment, return, and ordinary expression use are rejected
- postfix
!stays reserved foropt[...]anderr[...]shell values {{% notice warn %}}
Functions in FOL are lazy-initialized.
{{% /notice %}}
So it is an evaluation strategy which delays the evaluation of the function until its value is needed. You call a function passing it some arguments that were expensive to calculate and then the function don’t need all of them due to some other arguments.
Consider a function that logs a message:
log.debug("Called foo() passing it " + .to_string(argument_a) + " and " + .to_string(argument_b));
The log library has various log levels like “debug”, “warning”, “error” etc. This allows you to control how much is actually logged; the above message will only be visible if the log level is set to the “debug” level. However, even when it is not shown the string will still be constructed and then discarded, which is wasteful.
{{% notice tip %}}
Since Fol supports first class functions, it allows functions to be assigned to variables, passed as arguments to other functions and returned from other functions.
{{% /notice %}}
Anonymous functoins
Anonymous function is a function definition that is not bound to an identifier. These are a form of nested function, in allowing access to variables in the scope of the containing function (non-local functions).
Staring by assigning a anonymous function to a vriable:
var f = fun (a, b: int): int = { // assigning a variable to function
return a + b
}
.echo(f(5,6)) // prints 11
var f: int = (a, b: int){ // this is an short alternative of same variable assignmet to function
return a + b
}
It is also possible to call a anonymous function without assigning it to a variable.
`version 1`
fun[] (a, b: int) = { `define anonymous function`
.echo(a + b)
}(5, 6) `calling anonymous function`
`version 2`
(a, b: int){ `define anonymous function`
.echo(a + b)
}(5, 6) `calling anonymous function`
Closures
Functions can appear at the top level in a module as well as inside other scopes, in which case they are called nested functions. A nested function can access local variables from its enclosing scope and if it does so it becomes a closure. Any captured variables are stored in a hidden additional argument to the closure (its environment) and they are accessed by reference by both the closure and its enclosing scope (i.e. any modifications made to them are visible in both places). The closure environment may be allocated on the heap or on the stack if the compiler determines that this would be safe.
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
A generator is very similar to a function that returns an array, in that a generator has parameters, can be called, and generates a sequence of values. However, instead of building an array containing all the values and returning them all at once, a generator yields the values one at a time, which requires less memory and allows the caller to get started processing the first few values immediately. In short, a generator looks like a function but behaves like an iterator.
For a function to be a generator (thus to make the keyword yield accesable), it needs to return a type of container: arr, vec, seq, mat but not set, any.
fun someIter: vec[int] = {
var curInt = 0;
loop(){
yield curInt.inc(1)
}
}
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, not object-oriented. 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)
There is no separate object-method runtime model implied by the syntax. typ
declares data. Receiver-qualified routines are still 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.
Current parser-supported receiver syntax is intentionally broader than a named-only rule.
At parse time, receiver positions accept named, qualified, builtin-scalar, and
bracketed/composite type references. This keeps extension-style examples such as
typ[ext] int: int; pro (int)print(): non = { ... } and dispatch examples on extended
builtin aliases in scope for the front-end.
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.
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 binds an identifier to an existing type. All the properties of the existing type are bound to the alias too.
There are two type of aliasing:
- aliasing
- extending
Aliasing
typ[ali] I5: arr[int, 5];
So now the in the code, instead of writing arr[int, 5] we could use I5:
~var[exp] fiveIntigers: I5 = { 0, 1, 2, 3, 4, 5 }
Another example is creating a rgb type that can have numbers only form 0 to 255:
typ[ali] rgb: int[8][.range(255)] ; // we create a type that holds only number from 0 to 255
typ[ali] rgbSet: set[rgb, rgb, rgb]; // then we create a type holding the `rgb` type
Alias declaration are created because they can simplify using them multiple times,
their identifier (their name) may be expressive in other contexts, and-most
importantly-so that you can define receiver-qualified routines on a named type
surface. Anonymous types still need a named alias or extension surface first,
while built-in or foreign types can be extended explicitly through typ[ext].
Attaching methods does not make a type into an object. It simply gives a routine a receiver-qualified dot-call spelling. You could still think of the operation procedurally as a routine that takes the value as its first input.
Current milestone note:
- aliasing and extension over current
V1built-in and declared types are part of the present language surface - true foreign-type interop remains later work
- C ABI and Rust interop belong to the planned
V4milestone, not the current compiler contract
Extending
Extensions add new functionality to an existing constructs. This includes the ability to extend types for which you do not have access to the original source code (known as retroactive modeling).
typ[ext] type: type;
For example, adding a print function to the default integer type int:
typ[ext] int: int;
pro (int)print(): non = {
.echo(self)
}
pro main: int = {
5.print() // method print on int
}
Or turning a string str into a vector of characters:
typ[ext] str: str;
fun (str)to_array(): vec[chr] = {
loop(x in self){
yield x;
}
}
pro main(): int = {
var characters: vec[chr] = "a random str".to_array();
.echo(characters) // will print: {"a"," ","r","a","n","d","o","m"," ","s","t","r"}
}
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 classes
typ ...: rec = { ... } declares a data type. FOL does not treat records as
classes with hidden object state 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 input.
Current V1 backend/runtime note:
- backends may emit records and entries as plain target-language structs/enums
- that does not change the language model: they are still data plus ordinary receiver-qualified routines
- when runtime-visible formatting is needed, generated backends should preserve
the
fol-runtimeaggregate formatting contract instead of inventing a backend-specific display shape
Entries
Is an a group of constants (identified with ent) consisting of a set of named values called elements.
typ color: ent = {
var BLUE: str = "#0037cd"
var RED str = "#ff0000"
var BLACK str = "#000000"
var WHITE str = "#ffffff"
};
if( something == color.BLUE ) { doathing } else { donothing }
Entries as enums
Unums represent enumerated data. An enumeration type (or enum type) is a value type defined by a set of named constants of the underlying integral numeric type.
typ aUnion: ent = {
var BLUE, RED, BLACK, WHITE: int[8] = {..3}
}
Initializaion
To use a record after we’ve defined it, we create an instance of that record by specifying concrete values for each of the fields. We create an instance by stating the name of the record and then add curly brackets containing key: value pairs, where the keys are the names of the fields and the values are the data we want to store in those fields. We don’t have to specify the fields in the same order in which we declared them in the record. In other words, the record definition is like a general template for the type, and instances fill in that template with particular data to create values of the type.
@var user1: user = {
email = "someone@example.com",
username = "someusername123",
active = true,
sign_in_count = 1,
};
Named initialization:
@var[mut] user1: user = { email = "someone@example.com", username = "someusername123", active = true, sign_in_count = 1 }
Ordered initialization
@var[mut] user1: user = { "someone@example.com", "someusername123", true, 1 }
Accessing
To get a specific value from a record, we can use dot notation or the access brackets. If we wanted just this user’s email address, we could use user1.email or user1[email] wherever we wanted to use this 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[mut] user1: user = {
email = "someone@example.com",
username = "someusername123",
active = true,
sign_in_count = 1,
};
user1.email = "new.mail@example.com"
user1[username] = "anotherusername"
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-oriented type. It only means a routine may use dot-call syntax when its first 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.
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.
var[mut] user1: user = { email = "someone@example.com", username = "someusername123", active = true, sign_in_count = 1 }
.echo(user1.getName());
Standards
This chapter describes V2 contract/conformance design rather than current
V1 compiler behavior.
Current milestone note:
- standards are not part of the implemented
V1typechecker - blueprints and extensions are not part of the implemented
V1typechecker - examples here are semantic design examples for a later milestone
Satndard
A standard is an established norm or requirement for a repeatable technical task. It is usually a formal declaration that establishes uniform technical criteria, methods, processes, and practices.
S, what is a to be considered a standard:
- A standard specification is an explicit set of requirements for an item, object or service. It is often used to formalize the technical aspects of a procurement agreement or contract.
- A standard test method describes a definitive procedure that produces a test result. It may involve making a careful personal observation or conducting a highly technical measurement.
- A standard procedure gives a set of instructions for performing operations or functions.
- A standard guide is general information or options that do not require a specific course of action.
- A standard definition is formally established terminology.
In FOL, standards are named collections of receiver-qualified routine
signatures and/or required data, created with std. They are not class
hierarchies. They are procedural/data contracts:
std geometry: pro = {
fun area(): flt[64];
fun perim(): flt[64];
};
There are three types of standards,
- protocol
pro[]that enforce just function implementation - blueprint
blu[]that enforces just data implementation - extended
ext[], that enforces function and 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
A contract is a legally binding agreement that recognises and governs the rights and duties of the parties to the agreement. A contract is enforceable because it meets the requirements and approval of an higher authority. An agreement typically involves a written declaration given in exchange for something of value that binds the maker to do. Its an specific act which gives to the person to whom the declaration is made the right to expect and enforce performance. In the event of breach of contract, the higher authority will refrain the contract from acting.
In fol contracts are used to bind a type to a standard. If a type declares to use a standard, it is the job of the contract (compiler internally) to see the standard full-filled.
std geo: pro = {
fun area(): flt[64];
fun perim(): flt[64];
};
std rect(geo): rec[] = { // this type makes a contract to use the geometry standard
width: int[64];
heigh: int[64];
}
Now we can make rect records, but we have to respect the contract. If we
don’t implement the required receiver-qualified routines, the compiler should
reject uses that require that contract.
var aRectangle: rect = { width = 5, heigh = 6 } // this throws an error, we haven't fullfill the ocntract
To do so, we need first to create the required rect receiver-qualified
routines, then instantiate a record value:
fun (rect)area(): flt[64] = { result = self.width + self.heigh }
fun (rect)perim(): flt[64] = { result = 2 * self.width + 2 * self.heigh }
var aRectangle: rect = { width = 5, heigh = 6 } // this from here on will work
The benefit of standards is that a routine parameter may require a standard contract, and then any type that satisfies that contract may be used there:
std geo: pro = {
fun area(): flt[64];
fun perim(): flt[64];
};
typ rect(geo): rec[] = { // this type makes a contract to use the geometry standard
width: int[64];
heigh: int[64];
}
fun (rect)area(): flt[64] = { result = self.width + self.heigh }
fun (rect)perim(): flt[64] = { result = 2 * self.width + 2 * self.heigh }
typ circle(geo): rec[] = { // another type makes a contract to use the geometry standard
radius: int[64];
}
fun (circle)area(): flt[64] = { result = math::const.pi * self.radius ** 2 }
fun (circle)perim(): flt[64] = { result = 2 * math::const.pi * self.radius}
typ square: rec[] = { // this type does not make contract with `geo`
heigh: int[64]
}
pro measure( shape: geo) { .echo(shape.area() + "m2") } // a siple method to print the standard's area
// instantiate two record values
var aRectangle: rect = { width = 5, heigh = 6 } // creating a new rectangle
var aCircle: circle = { radius = 5 } // creating a new rectangle
var aSquare: square = { heigh = 6 } // creating a new square
// to call the measure function that rpints the surface
measure(aRectangle) // this prints: 30m2
measure(aSquare) // this throws error, square does not satisfy the contract
measure(aCircle) // this prints: 78m2
Generics
This chapter describes later generic-language design rather than current V1
compiler behavior.
Current milestone note:
- generic routines are not part of the implemented
V1typechecker - generic types are not part of the implemented
V1typechecker - examples here should be read as future
V2design
Types
Generic functions - lifting
The generic programming process focuses on finding commonality among similar implementations of the same algorithm, then providing suitable abstractions so that a single, generic algorithm can cover many concrete implementations. This process, called lifting, is repeated until the generic algorithm has reached a suitable level of abstraction, where it provides maximal reusability while still yielding efficient, concrete implementations. The abstractions themselves are expressed as requirements on the parameters to the generic algorithm.
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 - concepts
Once many algorithms within a given problem domain have been lifted, we start to see patterns among the requirements. It is common for the same set of requirements to be required by several different algorithms. When this occurs, each set of requirements is bundled into a concept. A concept contains a set of requirements that describe a family of abstractions, typically data types. Examples of concepts include Input Iterator, Graph, and Equality Comparable. When the generic programming process is carefully followed, the concepts that emerge tend to describe the abstractions within the problem domain in some logical way.
typ container[T: gen, N: int](): obj = {
var anarray: arr[T,N];
+fun getsize(): num = { result = N; }
};
var aContainer: container[int, 5] = { anarray = {zero, one, two, three, four}; };
Dispach
Static dispatch (or early binding) happens when compiler knows at compile time which function body will be executed when I call a method. In contrast, dynamic dispatch (or run-time dispatch or virtual method call or late binding) happens when compiler defers that decision to run time. This runtime dispatch requires either an indirect call through a function pointer, or a name-based method lookup.
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 are classified by source kind such as
loc,pkg, andstd
Imports
An import declaration states that the source file containing the declaration depends on functionality of the imported package and enables access to exported identifiers of that package.
Syntax to import a library is:
use alias: source_kind = { source }
Current source kinds are:
locfor local directory importsstdfor standard-library directory importspkgfor installed external packages
What use imports
use works against the source-layout model:
- the folder root is the package
- subfolders are namespaces inside that package
- a file is only a source file, not a separate module by itself
So a use target is normally:
- a whole package
- or one namespace inside that package
use does not mean “import another file from the same folder”.
Files in the same package already share package scope.
Imports are for reaching another package or another namespace boundary.
Also note:
usebrings in exported functionalityhiddeclarations remain file-only even inside the same package- importing a package does not erase the original file boundary rules
useis for consuming functionality, not for defining package dependencies
Import kinds
loc
loc is the simplest import kind:
- it points to a local directory
- that directory is scanned as a FOL package / namespace tree
- no
package.yamlis required - no
build.folis required
This makes loc useful for local workspace code, experiments, and monorepo-style sharing.
std
std works like loc, except the directory is resolved from the toolchain’s standard-library root.
So:
stdimports are directory-backed- they are owned by the FOL toolchain
- they do not need user-managed package metadata in source code
pkg
pkg is for formal external packages.
Unlike loc and std, 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:
package.yamlis requiredbuild.folis requiredpackage.yamlstores metadata onlybuild.foldeclares dependencies and exports
Package Metadata And Build Files
Formal packages use two files at the root:
package.yamlbuild.fol
package.yaml
package.yaml is metadata only.
It is intentionally not a normal .fol source file.
Typical metadata belongs here:
- package name
- version
- package kind
- human-oriented description/license/author data
What does not belong here:
use- dependency edges
- export wiring
- build logic
build.fol
build.fol is the package build entry file.
This file is responsible for:
- declaring package build logic
- declaring artifacts, steps, and generated outputs through the build API
- becoming the canonical package entrypoint for
fol code build/run/test/check
build.fol is still an ordinary FOL file.
It is parsed with the same front-end as other .fol sources.
The difference is that the package layer evaluates one canonical build routine inside it.
Today that means:
fol code build/run/test/checkstarts frombuild.fol- the canonical entry is
pro[] build(graph: Graph): non - old
def root: loc = ...anddef build(...)forms are not the build model
So:
defis still a general FOL declaration formbuild.folis not a separate mini-languagebuild.foluses an ordinary routine entrypoint, like Zig’sbuild.zig
That means:
- ordinary source
.folfiles useuseto consume packages/namespaces build.folusespro[] build(graph: Graph): nonto mutate the build graph
So use and the build routine serve different jobs:
use= consume functionalitypro[] build(...)inbuild.fol= define package/build surface
System libraries
This is how including other libraries works, for example include fmt module from standard library:
use fmt: std = {"fmt"};
pro main: ini = {
fmt::log.warn("Last warning!...")
}
To use only the log namespace of fmt module:
use log: std = {"fmt/log"};
pro[] main: int = {
log.warn("Last warning!...")
}
But let’s say you only wanna use ONLY the warn functionality of log namespace from fmt module:
use warn: std = {"fmt/log"};
pro[] main: int = {
warn("Last warning!...")
}
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 package.yaml or 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 pkg:
use space: pkg = {"space"};
pkg imports are different from loc and std:
- the imported root is an installed package root
- that root must contain
package.yaml - that root must contain
build.fol package.yamlprovides metadata onlybuild.folis the package build entry file and currently defines dependencies, exports, and root declarations that package loading depends on- raw transport URLs do not appear in source code; package acquisition and installed-package preparation are separate from ordinary source resolution
Declarations
Each .fol file inside a folder is part of the same package.
The important part is that files are connected, but not merged:
- every physical
.folfile is still its own source file - files in the same folder share one package scope
- declarations are order independent across those files
- a declaration started in one file can not continue into the next file
There is no need to import sibling files from the same package. They already belong to the same package scope.
Package, Namespace, And File Scope
It helps to think about source layout in three layers:
- package scope: all
.folfiles directly inside the package folder - namespace scope: declarations inside subfolders of that package
- file scope: declarations marked
hid, which stay visible only inside their own file
In short:
- same folder = same package
- subfolder = nested namespace
hid= file only- files are never imported directly as standalone modules
Package scope
Files that live directly in the package root share one package scope:
root/
math/
add.fol
sub.fol
Both add.fol and sub.fol belong to package math, so declarations from one file may be used by the other without importing the sibling file.
Namespace scope
Subfolders do not create a new package by themselves. They create nested namespaces inside the same package:
root/
math/
add.fol
stats/
mean.fol
Here:
add.folis in package namespacemathmean.folis in package namespacemath::stats
Code may reach namespace members either by:
- direct
use - or qualified access with
::
File scope
Sometimes a declaration should stay inside one file even though the package is shared.
That is what hid is for.
// file1.fol
var[hid] cache_key: str = "local"
// file2.fol
pro[] main: int = {
.echo(cache_key) // error: hidden declarations are file-only
}
Namespaces
A namespace can be defined in a subfolder of the main folder, and namespaces can be nested.
To acces the namespace there are two ways:
- direct import with
use - or code access with
::
Direct import
use aNS: loc = { "home/folder/printing/logg" }
pro[] main: int = {
logg.warn("something")
}
Code access
use aNS: loc = { "home/folder/printing" }
pro[] main: int = {
printing::logg.warn("something")
}
Mental model
For source layout, the mental model is:
- one folder root gives one package
- each file in that folder is a real source file in that package
- subfolders extend the namespace path
useimports packages or namespaceshidkeeps a declaration private to one filelocimports a local directory tree without package metadatastdimports a toolchain-owned directory treepkgimports a formal external package defined bypackage.yaml+build.fol
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 requiredstd: toolchain standard-library directory importpkg: 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:
package.yamlfor metadatabuild.folas the package build entry file
This keeps the language model clean:
- source files
useother namespaces/packages - package build execution starts from
pro[] build(graph: Graph): noninbuild.fol - package metadata lives in
package.yaml - 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
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
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 code shown in brackets (e.g.
error[R1003]:) - all failures keep exact primary
file:line:columnlocations - human-readable diagnostics render source snippets and underline the primary span
- related sites such as duplicate declarations or ambiguity candidates appear as secondary labels
- JSON diagnostics preserve the same structured information with labels, notes, helps, and stable producer-owned diagnostic codes
- the parser recovers after failed declarations instead of cascading errors
- duplicate diagnostics on the same line are suppressed, with a hard cap at 50
- LSP diagnostics are deduplicated by line and code before reaching the editor
The exact wording of messages is still implementation detail, but the current compiler contract is that locations, codes, and structured diagnostic shape are stable enough to build tests and tooling around them.
For the detailed compiler-facing reporting model, see Compiler Diagnostics.
Breaking errors
panic keyword allows a program to terminate immediately and provide feedback to the caller of the program. It should be used when a program reaches an unrecoverable state. This most commonly occurs when a bug of some kind has been detected and it’s not clear to the programmer how to handle the error.
pro main(): int = {
panic "Hello";
.echo("End of main"); //unreachable statement
}
In the above example, the program will terminate immediately when it encounters the panic keyword.
Output:
main.fol:3
routine 'main' panicked at 'Hello'
-------
Trying to acces an out of bound element of array:
pro main(): int = {
var a: arr[int, 3] = [10,20,30];
a[10]; //invokes a panic since index 10 cannot be reached
}
Output:
main.fol:4
routine 'main' panicked at 'index out of bounds: the len is 3 but the index is 10'
-------
a[10];
^-------- index out of bounds: the len is 3 but the index is 10
A program can invoke panic if business rules are violated, for example: if the value assigned to the variable is odd it throws an error:
pro main(): int = {
var no = 13;
//try with odd and even
if (no % 2 == 0) {
.echo("Thank you , number is even");
} else {
panic "NOT_AN_EVEN";
}
.echo("End of main");
}
Output:
main.fol:9
routine 'main' panicked at 'NOT_AN_EVEN'
-------
Recoverable errors
Recoverable errors are part of the current V1 language contract, but they are
split into two different surfaces:
T / Efor routine-call handlingerr[...]for normal storable values
Those are intentionally not the same thing.
T / E is immediate call-site handling
Use / ErrorType after the success type:
fun read_code(path: str): int / str = {
when(path == "") {
case(true) { report "missing path" }
* { return 7 }
}
}
This means:
- success yields
int report exprexits through the routine error path withstr- the call result is not a storable plain value
report expr must match the declared error type:
fun read_code(path: str): int / str = {
report "missing path"
}
The following is invalid because the reported value is the wrong type:
fun read_code(path: str): int / str = {
report 1
}
No plain propagation
In current V1, / ErrorType routine results do not flow through ordinary
expressions.
These are rejected:
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.
check(...)
check(expr) asks whether a / ErrorType routine call failed.
It returns bol.
fun main(path: str): bol = {
return check(read_code(path))
}
check(...) works on recoverable routine calls, not on err[...] shell values.
||
expr || fallback handles a / ErrorType routine call immediately.
Rules:
- if
exprsucceeds, use its success value - if
exprfails, evaluatefallback fallbackmay:- provide a default value
reportpanic
Examples:
fun with_default(path: str): int = {
return read_code(path) || 0
}
fun with_context(path: str): int / str = {
return read_code(path) || report "read failed"
}
fun must_succeed(path: str): int = {
return read_code(path) || panic "read failed"
}
err[...] is the storable error form
err[...] is a normal value type.
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 value!
}
This is different from:
fun read_code(path: str): int / str = { ... }
A call to read_code(...) is not an err[str] value. If you need a storable
error container, use err[...]. If you use / ErrorType, handle it with
check(...) or ||.
Current V1 boundary
The current compiler supports:
- declared routine error types with
/ report exprcheck(expr)expr || fallbackerr[...]shell/value behavior
The current compiler rejects:
- plain assignment of
/ ErrorTypecall results - direct returns of
/ ErrorTypecall results - implicit conversion from
/ ErrorTypeintoerr[...] - postfix
!on/ ErrorTyperoutine calls
For backend work:
/ ErrorTyperoutine calls lower through the recoverable runtime ABIerr[...]remains a separate shell/value runtime type
Those two categories are intentionally not merged.
Compiler Diagnostics
This chapter is about compiler reporting, not about language-level panic or
report semantics.
In other words:
panicandreportdescribe what your program does- diagnostics describe what the compiler tells you when it cannot continue or when it wants to surface important information
Why this chapter exists
FOL now has a real compiler pipeline:
fol-streamfol-lexerfol-parserfol-packagefol-resolverfol-typecheckfol-lowerfol-runtimefol-backendfol-diagnostics
That means errors are no longer just loose strings printed from one place. Compiler failures now move through a shared diagnostics layer with stable structure.
This matters for three reasons:
- humans need readable compiler output
- tests need stable enough structure to assert against
- future tools need a machine-readable format that is not just a copy of the human renderer
What a diagnostic contains
At the current compiler stage, a diagnostic can carry:
- severity
- main message
- a stable diagnostic code (e.g.
P1001,R1003,T1003) - one primary location
- zero or more related locations
- notes
- helps
- suggestions
The current compiler mostly emits Error, but the reporting layer also supports
Warning and Info.
Diagnostic codes
Every diagnostic carries a stable producer-owned code. The code identifies the error family and specific failure without relying on message text.
Current code families:
| Prefix | Producer | Examples |
|---|---|---|
P1xxx | parser | P1001 syntax, P1002 file root |
K1xxx | package loading | K1001 metadata, K1002 layout |
R1xxx | resolver | R1003 unresolved, R1005 ambiguous |
T1xxx | type checker | T1003 type mismatch |
L1xxx | lowering | L1001 unsupported surface |
F1xxx | frontend | F1001 invalid input, F1002 workspace not found |
K11xx | build evaluator | K1101 build failure |
Codes are structurally assigned. The parser carries an explicit ParseErrorKind
field on each error rather than deriving the code from message text. This means
message wording can change without breaking code identity.
Human output shows codes in brackets:
error[R1003]: could not resolve name 'answer'
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
- a lowering error at the typed surface that has no current
V1lowering rule
Related locations
Some compiler failures are not well described by one location alone.
For example:
- duplicate declarations
- ambiguous references
- duplicate package metadata fields
In those cases the compiler keeps one primary site and can also attach related sites as secondary labels.
That allows the compiler to say things like:
- this declaration conflicts with an earlier declaration
- this name could refer to either of these two candidates
- this metadata field was already defined elsewhere
Notes, helps, and suggestions
FOL diagnostics separate extra guidance into different buckets instead of forcing everything into one long message.
The current contract is:
- the main message says what went wrong
- notes add technical context
- helps add actionable guidance
- suggestions describe a possible replacement or next step when the producer can express one
This split matters because tooling and tests can preserve structure instead of trying to parse intent back out of prose.
Error recovery
The parser implements error recovery so that a single syntax mistake does not cascade into dozens of unrelated errors.
When a declaration parse fails, the parser calls sync_to_next_declaration to
skip forward to the next declaration-start keyword (fun, var, def, typ,
pro, log, seg, ali, imp, 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:
- same-code, same-line dedup: if the most recently added diagnostic has the same code and same line as a new one, the new one is suppressed
- 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
By default the CLI prints human-readable diagnostics.
The current renderer is designed around:
- a severity prefix with a diagnostic code bracket (e.g.
error[R1003]:) - an arrow line with
file:line:column - a source snippet when the file and line can be loaded
- an underline for the primary span
- note-style summaries for related labels
- note/help lines after the main snippet
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
Messages are clean human-readable text. The compiler does not prepend internal
kind labels like ResolverUnresolvedName: to messages. The diagnostic code in
brackets is the stable identifier.
Source fallbacks
Sometimes the compiler knows the location but cannot render the source line itself.
Examples:
- the file is no longer readable
- the file path is missing
- the requested line is outside the current file contents
In those cases the compiler still keeps the location and falls back cleanly instead of crashing the renderer.
So the priority order is:
- exact location
- source snippet when available
- explicit fallback note when the snippet cannot be shown
JSON diagnostics
When the CLI is invoked with --json, diagnostics are emitted as structured
JSON instead of human-readable text.
This output is meant for scripts, tests, editor tooling, and future integration layers.
Important rule:
- JSON is not a lossy summary of human output
Instead, both human and JSON outputs are generated from the same structured diagnostic model.
The editor/LSP layer should follow that same rule too: editor diagnostics should be adapted from the shared structured diagnostic model rather than rebuilt from free-form strings.
That means JSON can preserve:
- severity
- code
- message
- primary location
- related labels
- notes
- helps
- suggestions
Illustrative shape:
{
"severity": "Error",
"code": "R1003",
"message": "could not resolve name 'answer'",
"location": {
"file": "app/main.fol",
"line": 3,
"column": 12,
"length": 6
},
"labels": [
{
"kind": "Primary",
"message": "unresolved name",
"location": {
"file": "app/main.fol",
"line": 3,
"column": 12,
"length": 6
}
}
],
"notes": [
"no visible declaration with that name was found in the current scope chain"
],
"helps": [
"check imports or declare the name before use"
],
"suggestions": []
}
Again, the exact payload can evolve, but the important guarantee is that the structured fields are first-class rather than reverse-engineered from text.
Which compiler phases currently participate
At head, the main producers that lower into the shared diagnostics layer are:
- parser
- package loading
- resolver
- type checking
- lowering
- build evaluator
- backend
- frontend (workspace discovery and input validation)
That means diagnostics are already strong across:
- syntax errors (with error recovery so cascades are contained)
- package metadata and package-root errors
- import-loading failures
- unresolved names
- duplicate names
- ambiguous references
- type mismatches and unsupported semantic surfaces inside
V1 - unsupported lowered
V1surfaces before target emission - backend emission and build failures when lowered
V1workspaces cannot become runnable artifacts - build graph evaluation failures
This is the important boundary for the current compiler stage:
- the compiler can now parse, resolve, type-check, and lower the supported
V1subset - diagnostics already cover failures from each of those stages plus backend emission/build failures
- the project now does promise a finished first backend for the current
V1subset, while later targets, optimizations, C ABI work, and Rust interop work remain outside this chapter
What diagnostics do not guarantee
Diagnostics are strong, but they are not a substitute for the later semantic phases.
Current limits still matter:
- parser diagnostics do not imply type checking has happened
reportcompatibility still belongs to later semantic work- type mismatch, coercion, and conversion diagnostics are future type-checker work
- ownership and borrowing diagnostics are future semantic work
- C ABI diagnostics are future
V4package/type/backend work - Rust interop diagnostics are future
V4package/type/backend work
So the current guarantee is:
- if stream, lexer, parser, package loading, resolver, typechecker, or lowering can identify the problem now, diagnostics should be structured and exact
But not:
- all language-semantic errors already exist today
Practical rule of thumb
When reading compiler output, think in this order:
- look at the diagnostic code in brackets to identify the error family
- trust the primary location
- use related labels to understand competing or earlier sites
- read notes for technical context
- read helps for the most actionable next step
That mental model matches how the compiler currently structures reporting.
Syntactic Sugar
This section collects forms that are primarily about expressiveness, convenience, or alternate notation.
These chapters should be read after the core statement, expression, and declaration rules, because most sugar expands on top of those more fundamental forms.
The current sugar chapters cover:
- silent/discard forms
- pipes
- 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
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 routine call failed.
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 V1 inspection surface for recoverable calls.
|| 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"
}
What plain | does not mean in current V1
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 in V1, use check(expr) or expr || fallback, and
treat ordinary | as value piping rather than the main recoverable-error
mechanism.
Mixture
Optional
var someMixtureInt: ?int = 45;
Never
var someNverType: !int = panic();
Limits
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
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
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
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
Inquiries are inline unit tests and are a part of the basic syntax sugar. In other words, we don’t have to import any libraries or build up any suites to run tests.
Instead, FOL includes a couple of clauses for testing within the source code:
fun sum(l: ...?int): int = {
when(l.length()) {
is 0 => 0;
is 1 => l[0];
$ => l[0] + sum(l[1:]);
}
where(self) {
sum() is 0;
sum(8) is 8;
sum(1, 2, 3) is 6;
}
}
Here, we can see an awesome list sum function. Within the function, there are two basic cases: empty and not empty. In the empty case, the function returns 0. Otherwise, the function performs the sum.
At that point, most languages would be done, and testing would be an afterthought. Well, that’s not true in FOL. To add tests, we just include a where clause. In this case, we test an empty list and a list with an expected sum of 6.
When the code is executed, the tests run. However, the tests are non-blocking, so code will continue to run barring any catastrophic issues.
Chaining
Optional chaining is a process for querying and calling properties, methods, and subscripts on an optional that might currently be nil. If the optional contains a value, the property, method, or subscript call succeeds; if the optional is nil, the property, method, or subscript call returns nil. Multiple queries can be chained together, and the entire chain fails gracefully if any link in the chain is nil.
Before I can really explain optional chaining, we have to get a grasp of what an optional value is. In FOL, variables cannot be empty. In other words, variables cannot store a value of NIL, at least not directly. This is a great feature because we can assume that all variables contain some value. Of course, sometimes variables need to be NIL. Fortunately, FOL provides that through a boxing feature called optionals. Optionals allow a user to wrap a value in a container which can be unwrapped to reveal either a value or NIL:
var printString: ?str;
printString = "Hello, World!"
.echo(printString!)
In this example, we declare an optional string and give it a value of “Hello, World!” Since we know that the variable stores a str, we can unconditionally unwrap the value and echo it. Of course, unconditional unwrapping is typically bad practice, so I’m only showing it for the purposes of showing off optionals.
Current V1 compiler note:
nilis type-checked only when the surrounding context already expects anopt[...]orerr[...]shell.- A standalone
nilwith no expected shell type is rejected during typechecking. - Postfix unwrap
value!is currently type-checked foropt[T]anderr[T]and yieldsT. - Bare
err[]does not currently support postfix unwrap because there is no payload value to recover. - This
!surface is shell-only. It does not unwrap routine call results declared withResultType / ErrorType.
At any rate, optional chaining takes this concept of optionals and applies it to method calls and fields. For instance, imagine we have some long chain of method calls:
important_char = commandline_input.split('-').get(5).charAt(7)
In this example, we take some command line input and split it by hyphen. Then, we grab the fifth token and pull out the seventh character. If at any point, one of these method calls fails, our program will crash.
With optional chaining, we can actually catch the NIL return values at any point in the chain and fail gracefully. Instead of a crash, we get an important_char value of NIL. Now, that’s quite a bit more desirable than dealing with the pyramid of doom.
The current V1 compiler milestone only implements the plain nil/!
surfaces described above. Full optional chaining remains later work.
Value Conversion
This section covers conversions between types.
FOL distinguishes:
- coercion: conversions considered safe and unambiguous
- casting: conversions that should be explicit in source
Coercion
The book-level idea of coercion is simple:
- coercion is implicit
- it should only happen when the conversion is safe and unambiguous
For the current V1 compiler milestone, that contract is intentionally narrow.
V1 coercion currently allows:
- exact type matches
- alias-wrapped values whose apparent type matches the expected type
neverflowing into any expected type- the current optional/error shell lifting used by ordinary
V1surfaces
V1 coercion currently does not allow:
- implicit
int -> flt - implicit
flt -> int - implicit width or signedness changes
- implicit container reshaping
- implicit string/character/container conversions
So today the rule is:
- if two values are not already the same semantic family, the compiler should reject the implicit conversion
This is deliberate.
The conversion chapter in the language design is broader than the current
compiler guarantee, and V1 chooses explicit rejection over silent guessing.
Examples that are accepted in V1:
var count: int = 1
var ratio: flt = 1.5
fun[] take_int(value: int): int = {
return value;
}
fun[] take_float(value: flt): flt = {
return value;
}
Examples that are rejected in V1:
var count: int = 1.5
var ratio: flt = 1
fun[] take_float(value: flt): flt = {
return value;
}
fun[] bad(): flt = {
return take_float(1);
}
Later versions may widen this contract, but V1 keeps coercion intentionally
small so type behavior stays predictable.
Casting
Casting is the explicit side of value conversion.
The long-term language direction is:
- coercion stays implicit and narrow
- casting stays explicit and source-visible
For the current V1 compiler milestone, casting syntax is parsed, but casting
semantics are not implemented yet.
That means:
value as targetvalue cast target
are both valid syntax surfaces, but they are not part of the supported V1
type system.
The current compiler behavior is explicit:
- it does not silently reinterpret these expressions
- it does not treat them as ordinary coercions
- it reports them as unsupported
V1typecheck surfaces
Example:
fun[] bad_as(value: int): int = {
return value as text;
}
fun[] bad_cast(value: int): int = {
return value cast target;
}
Both forms currently fail during typechecking.
This boundary is intentional. Before FOL can support casting for real, the compiler needs a stable legality contract answering questions such as:
- which scalar casts are allowed
- whether lossy casts are permitted
- whether container casts exist
- how aliases interact with explicit conversion
- how future foreign/ABI types participate in conversion
That last point is deliberately later work:
- C ABI and Rust interop are planned
V4features - casting rules for foreign or ABI-facing types should be specified together
with that
V4interop contract, not guessed earlier
Until that contract exists, V1 treats cast syntax as parsed-but-unsupported
instead of guessing semantics.
Memory Model
This section explains how FOL talks about storage, ownership, allocation, and pointer-like behavior.
The main topics are:
- ownership
- pointers
- stack vs heap intuition
- allocation lifetime
- cross-thread memory concerns
The detailed chapters should be treated as the normative source. This index only frames the area.
Ownership
This chapter describes the planned systems-language ownership model.
Current milestone note:
- ownership is not part of the current
V1compiler contract - borrowing is not part of the current
V1compiler contract - these semantics belong to the later
V3systems milestone
So the syntax and examples here should be read as future design, not as current compiler behavior.
Much like C++ and Rust, in Fol every variable declared, by default is created in stack unless explicitly specified othervise. Using option [new] or [@] in a variable, it allocates memory in the heap. The size of the allocation is defined by the type. Internally this creates a pointer to heap address, and dereferences it to the type you are having. Usually those behind the scene pointers here are unique pointers. This means that when the scope ends, the memory that the pointer used is freed.
var[new] intOnHeap: int[64];
@var intOnHeap: int[64];
Assignments
As discussed before, declaring a new variable is like this:
var[exp] aVar: int[32] = 64
{{% notice warn %}}
However, when new variable is created and uses an old variable as value, the value is always cloned for “stack” declared values, but moved for “heap” declared values.
{{% /notice %}}
@var aVar: int[32] = 64
{
var bVar = aVar // this moves the content from $aVar to $bVar
}
.echo(aVar) // this will throw n error, since the $aVar is not anymore owner of any value
When the variable is moved, the owner is changed. In example above, the value 64 (saved in stack) is owned my aVar and then the ownership is moved to bVar. Now bVar is the new owner of the variable, making the aVar useless and can’t be refered anymore. Since the bVar now controls the value, it’s lifetime lasts until the end of the scope. When the scope ends, the variable is destroyed with .de_alloc() function. This because when the ovnership is moved, the attributes are moved too, so the @ of aVar is now part of the bVar even if not implicitly specified. To avoid destruction, the bVar needs to return the ownership back to aVar before the scope ends with .give_back(bVar) or !bVar.
@var aVar: int[32] = 64
{
var bVar = aVar // this moves the content from $aVar to $bVar
!bvar // return ownership
}
.echo(aVar) // this now will print 64
This can be done automatically by using borrowing.
Borrowing
Borrowing does as the name says, it borrows a value from another variable, and at the end of the scope it automatically returns to the owner.
pro[] main: int = {
var[~] aVar: int = 55;
{
var[bor] newVar: int = aVar // represents borrowing
.echo(newVar) // this return 55
}
.echo(aVar) // here $aVar it not accesible, as the ownership returns at the end of the scope
.echo(newVar) // we cant access the variable because the scope has ended
}
Borrowing uses a predefined option [bor], which is not conventional like other languages that use & or *. This because you can get away just with “borrowing” without using pointers (so, symbols like * and & are strictly related to pointers)
However, while the value is being borrowed, we can’t use the old variable while is being borrowed but we still can lend to another variable:
pro[] main: int = {
var[~] aVar: int = 55;
{
var[bor] newVar = aVar // represents borrowing
.echo(newVar) // this prints 55
.echo(aVar) // this throws an error, cos we already have borrowd the value from $aVar
var[bor] anotherVar = aVar // $anotherVar again borrows from a $aVar
}
}
{{% notice warn %}}
When borrowed, a the value is read-only (it’s immutable). To make it muttable, firtsly, the owner needs to be muttable, secondly the borrower needs to declarare that it intends to change.
{{% /notice %}}
To do so, the borrower uses var[mut, bor]. However, when the value is declared mutable by owner, only one borrower within one scope can declare to modify it:
pro[] main: int = {
var[~] aVar: int = 55;
{
var[mut, bor] newVar = aVar // [mut, bor] represents a mutable borrowing
var[mut, bor] anotherVar = aVar // this throws an error, cos we already have borrowed the muttable value before
}
{
var[mut, bor] anotherVar = aVar // this is okay, s it is in another scope
}
}
Pointers
This chapter is future systems-language design.
Current milestone note:
- pointer semantics are not part of the implemented
V1compiler contract - pointer-specific intrinsics such as
.pointer_value(...),.address_of(...), and.borrow_from(...)are deferred - the material here belongs to the later
V3systems milestone
So the examples in this chapter should be read as planned language direction,
not as currently supported V1 code.
The only way to access the same memory with different variable is by using pointers. In example below, we create a pointer, and when we want to dereference it to modify the content of the address that the pointer is pointing to, we use *ptrname or .pointer_value(ptrname).
@var aContainer: arr[int, 5]; //allocating memory on the heap
var contPoint: ptr[] = aContainer;
*contPoint = { zero, one, two, three, four }; //dereferencing and then assigning values
Bare in mind, that the pointer (so, the address itself) can’t be changes, unless when created is marked as var[mut]. To see tha address of a pointer we use &ptrname or .address_of(ptrname)
@var aContainer: arr[int, 5]; //allocating memory on the heap
var contPoint: ptr[] = aContainer;
var anotherPoint: ptr[] = &contPoint; //assigning the same adress to another pointer
Unique pointer
Ponter of a pointer is very simimilar to RUST move pointer, it actually, deletes the first pointer and references the new one to the location of deleted one. However this works only when the pointer is unique (all pointers by default all unique). This is like borrowing, but does not invalidate the source variable:
var aContainer: arr[int, 5] = { zero, one, two, three, four };
var contPoint: ptr[] = aContainer;
var anotherPoint: ptr[] = &contPoint;
with borrowing, we use #varname or .borrow_from(varname)
var aContainer: arr[int, 5] = { zero, one, two, three, four };
{
var borrowVar = #aContainer; //this makes a new var form the old var, but makes the old invalid (until out of scope)
}
Shred pointer
Ponter can be shared too. They can get referenced by another pointer, and they don’t get destroyed until the last reference’s scope is finished. This is exacly like smart shared_ptr in C++. Pointer to this pointer makes a reference not a copy as unique pointers. Dereferencing is a bit complicated here, as when you dereference a pointer pointer you get a pointer, so you need to dereference it too to get the value.
@var aContainer: arr[int, 5] = { zero, one, two, three, four };
var contPoint: ptr[] = aContainer;
var pointerPoint: ptr[shared] = &contPoint;
Dereferencing (step-by-step):
var apointerValue = *pointerPoint
var lastpointerValue = *apointer
Dereferencing (all-in-one):
var lastpointer = *(*pointerPoint)
Raw pointer
Lastly, pointers can be raw too. This is the base of ALL POINTERS AND VARIABLES. Pointers of this type need to MANUALLY GET DELETED. If a pointer gets deleted before the new pointer that points at it, we get can get memory corruptions:
var aContainer: arr[int, 5] = { zero, one, two, three, four };
var contPoint: ptr[raw] = aContainer;
var pointerPoint: ptr[raw] = &contPoint;
Deleting:
!(pointerPoint)
!(contPoint)
Concurrency
This section covers FOL’s concurrency and asynchronous execution model.
The current chapter split is:
- eventuals
- coroutines
Together they define the language-level model for deferred work, coordination, and concurrent execution structure.
Eventuals
This chapter is future concurrency/runtime design.
Current milestone note:
- async/eventual semantics are not part of the implemented
V1compiler - this material belongs to the later
V3systems milestone
Eventuals describe an object that acts as a proxy for a result that is initially unknown, usually because the computation of its value is not yet complete.
Async/Await
Async methods are intended to be non-blocking operations. An await expression in an async routine doesn’t block the current thread while the awaited task is running. Instead, the expression signs up the rest of the routine as a continuation and returns control to the caller of the async routine and it means “Once this is done, execute this function”. It’s basically a “when done” hook for your code, and what is happening here is an async routine, when executed, returns a coroutine which can then be awaited. This is done usually in one thread, but can be done in multiple threads too, but thread invocations are invisible to the programmer in this case.
pro main(): int = {
doItFast() | async // compiler knows that this routine has an await routine, thus continue when await rises
.echo("dosomething to echo")
// the main program does not exit until the await is resolved
}
fun doItFast(): str = {
result = client.get(address).send() | await // this tells the routine that it might take time
.echo(result)
}
Coroutines
This chapter is future concurrency/runtime design.
Current milestone note:
- coroutine, channel, and mutex semantics are not part of the implemented
V1compiler - this material belongs to the later
V3systems milestone
A coroutine is a task given form the main thread, similar to a routine, that can be in concurrent execution with other tasks of the same program though other routines. A worker takes the task and runs it, concurrently. Each task in a program can be assigned to one or multiple workers.
Three characteristics of coroutine distinguish them from normal routines:
- First, a task may be implicitly started, whereas a routine must be explicitly called.
- Second, when a program unit invokes a task, in some cases it need not wait for the task to complete its execution before continuing its own.
- Third, when the execution of a task is completed, control may or may not return to the unit that started that execution.
- Fourth and most importantly, the execution of the routine is entirely independent from main thread.
In fol to assign a task to a worker, we use the symbols [>]
Channels
FOL provides asynchronous channels for communication between threads. Channels allow a unidirectional flow of information between two end-points: the Transmitter and the Receiver. It creates a new asynchronous channel, returning the tx/tx halves. All data sent on the Tx (transmitter) will become available on the Rx (receiver) in the same order as it was sent. The data is sent in a sequence of a specifies type seq[type]. tx will not block the calling thread while rx will block until a message is available.
pro main(): int = {
var channel: chn[str];
for (0 ... 4) {
[>]doItFast() | channel[tx] // sending the output of four routines to a channel transmitter
// each transmitter at the end sends the close signal
}
var fromCh1 = channel[rx][0] // reciveing data from one transmitter, `0`
}
fun doItFast(i: int; found: bol): str = {
return "hello"
}
If we want to use the channel within the function, we have to clone the channel’s tx and capture with an ananymus routine: Once the channels transmitter goes out of scope, it gets disconnected too.
pro main(): int = {
var channel: chn[str]; // a channel with four buffer transmitters
var sequence: seq[str];
for (0 ... 4) {
[>]fun()[channel[tx]] = { // capturin gthe pipe tx from four coroutines
for(0 ... 4){
"hello" | channel[tx] // the result are sent fom withing the funciton eight times
}
} // when out of scope a signal to close the `tx` is sent
}
select(channel as c){
sequence.push(channel[rx][c]) // select statement will check for errors and check which routine is sending data
}
}
Locks - Mutex
Mutex is a locking mechanism that makes sure only one task can acquire the mutexed varaible at a time and enter the critical section. This task only releases the mutex when it exits the critical section. It is a mutual exclusion object that synchronizes access to a resource.
In FOL mutexes can be passed only through a routine. When declaring a routine, instead of using the borrow form with ( // borrowing variable ), we use double brackets (( // mutex )). When we expect a mutex, then that variable, in turn has two method more:
- the
lock()which unwraps the variable from mutex and locks it for writing and - the
unlock()which releases the lock and makes the file avaliable to other tasks
fun loadMesh(path: str, ((meshes)): vec[mesh]) = { // declaring a *mutex and *atomic reference counter with double "(( //declaration ))"
var aMesh: mesh = mesh.loadMesh(path)
meshes.lock()
meshes.push(aMesh) // there is no need to unlock(), FOL automatically drops at the end of funciton
// if the function is longer, then we can unlock to not keep other tasks waiting
}
pro main(): int = {
~var meshPath: vec[str];
~var meshes: vec[mesh];
var aFile = file.readfile(filepath) || .panic("cant open the file")
each( line in aFile.line() ) { meshPath.push(line) };
for(m in meshPath) { [>]loadMesh(m, meshes) };
}