Introduction
Welcome to The Flint Book, the official guide to the Flint programming language.
Flint is a systems programming language that starts where most languages give up: on a microcontroller with limited RAM, no operating system, and no safety net. If your code works there, it should not fall apart the moment you move to a different board, a different chip family, or eventually a Linux target. That is the standard Flint is trying to meet.
use micro/gpio
use std/time
const LED_PIN: u32 = 25
fn main() -> never {
let mut led = gpio.pin(LED_PIN).into_output()
loop {
led.toggle()
time.sleep_ms(1000)
}
}
That is a complete, working blink program for a Raspberry Pi Pico. No linker scripts. No C runtime setup. No GCC, no LLVM, no Makefiles. Just flint build and a UF2 file ready to drop on your board.
Why Flint Exists
The practical problem is simple: embedded development still makes ordinary work harder than it should be.
In MCU land, developer struggle is treated as normal. You fight an SDK before you touch your application logic. You inherit a vendor IDE because that is the only path someone tested. You switch chips and suddenly half your mental model no longer transfers. The language might be familiar, but the actual experience is not portable. The tools are not coherent. The abstractions do not line up. A lot of the work is not building firmware, it is surviving the environment around it.
Flint exists to fix that.
The goal is a language and toolchain that take embedded constraints seriously without making developers miserable in the process. Safe by default. Fast enough for real firmware. Small enough for MCUs. Pleasant enough that writing a blink program does not feel like an initiation ritual.
How We Got Here
Flint did not appear because there was a gap in the market and someone decided to fill it. It came out of the same frustration most embedded developers eventually hit: too much incidental pain, too much fragmented tooling, too much time spent learning the preferences of a build system or a vendor SDK instead of working on the device in front of you.
That backstory matters because it shaped the project from the start. Flint is not trying to be clever for its own sake. It is trying to remove classes of friction that developers have been told to accept for years.
The name matters too. A small piece of flint can kindle a fire. Fire is not only heat and force, it is also something cozy that brings people together. That is the idea here too: a language simple enough to pick up for a small project, capable enough to carry real firmware without collapsing under its own weight, and grounded enough to become a shared tool people gather around and improve together. Small is not the opposite of serious. A tool can be compact, sharp, and still do real work.
What Flint Optimizes For
Several ideas shape every decision in the language, the compiler, and the standard library:
- MCU-first design. Flint starts from the hardest environment first: bare-metal systems with tight memory, no OS, and no room for hand-wavy abstractions.
- A self-contained toolchain.
flintparses, checks, formats, generates code, and packages the final image. There is no hidden external compiler story underneath. - Memory safety without ceremony. Ownership is real. Lifetime annotations are not part of the user-facing language.
- A language you can hold in your head. Flint is deliberately narrow. No macros, no user-defined generics, no async/await, no operator overloading.
- One official library stack. The standard library, MCU APIs, HALs, and board packages are meant to grow together in one place.
This is also why developer experience is a first-day concern, not a someday concern. Flint is still young, but the specification is already solid enough to keep the compiler, documentation, syntax highlighting, and language server moving in lockstep. LSP support, autocomplete, diagnostics, formatting, and editor integrations are part of the foundation because a new language still has to feel good to use while it is becoming real.
For Us, By Us
Flint is not a product from a company. It is a language for the people actually building things with microcontrollers: hobbyists, professionals, tinkerers, and anyone else tired of being told that bad tooling is just part of embedded work.
The whole stack is open source. The compiler, the standard library, the HAL, the board packages, the language server, the editor extensions, the docs. All of it. If a driver is missing, the answer should be that the community can add it. If board support is incomplete, the path to fixing it should be clear. If the editor story is rough, that is part of the product, not an optional extra.
That is the point of the project. The people closest to the hardware should be able to improve the actual language and ecosystem they use, not maintain a pile of side projects around it forever.
How This Book Is Organized
- Getting Started: install the compiler, write your first program, and learn the core CLI workflow.
- Language Reference: types, ownership, error handling, pattern matching, traits, and the rest of the language surface.
- HAL and Micro Library: GPIO, UART, ADC, PWM, I2C, SPI, DMA, and PIO through Flint’s official MCU packages.
- Standard Library:
core/*andstd/*, the cross-target facilities that work everywhere Flint runs. - Targets and Boards: supported targets, support tiers, and board overlays.
- Compiler Internals: how the pipeline works for readers who want the implementation story.
- Tools and Editor Support: the language server, editor integrations, and the real-time developer workflow.
What Flint Is Not
Flint is not trying to be everything.
It is not a maximalist language with a feature for every taste. It is not a scripting language. It is not a borrow-checker-heavy research project. It is a focused language for writing clean, safe, fast firmware, with a clear path toward broader systems targets later.
That narrowness is deliberate. The aim is not to win every language argument. It is to build a tool that makes embedded development feel sane.
Flint is still under active development. The
rp2040target is the current primary platform and is real and usable today. The language surface, tooling, and target support will keep expanding from there.
Purpose and Design
Why Flint Exists
Every embedded developer eventually hits the same wall. You are deep in build flags, linker scripts, startup code, SDK glue, and whatever odd ritual your current board requires, and you still have not reached the part you actually care about.
That is the problem Flint is trying to solve.
Embedded development has accepted too much avoidable struggle as normal. Bad tooling is normal. Relearning the world every time you switch chips is normal. Vendor lock-in is normal. Losing a weekend to get a UART or timer configured is normal. Flint starts from the position that none of this should be normal.
The project is built around a simple idea: firmware developers deserve a language and toolchain that are as intentional, coherent, and pleasant to use as the tools enjoyed in the rest of software engineering.
The Story Behind It
Flint came from accumulated frustration, not from theory. The shape of the language makes more sense if you understand that.
Most MCU developers learn by moving through a sequence of compromises. Maybe you start with Arduino because it gets you to blinking lights fast. Then you outgrow it and move to MicroPython because it is friendlier than C. Then you need more performance, less runtime overhead, or tighter hardware control, and suddenly you are standing in front of a vendor SDK, an IDE from another era, and a build pipeline that feels like it was assembled from leftovers.
At each step, you gain capability and lose comfort. That trade is treated like a fact of life. Flint exists because it should not be.
The name carries that history. Flint is small, direct, and practical. A small piece of flint can start something much larger than itself, and the fire that follows is not only useful, it is also something people gather around. That is the aspiration here too: a language compact enough to stay understandable, strong enough to carry real firmware, and welcoming enough to become a real shared ecosystem instead of a solitary tool.
The Problem Flint Is Actually Solving
Flint is not just trying to be “a better C” or “Rust but easier.” The real target is the developer experience of embedded work as a whole.
The hardest part of firmware development is often not the firmware. It is the fragmented stack around it:
- a language that was not designed for the constraints you are working under
- a toolchain assembled from several unrelated projects
- a vendor SDK with its own conventions and assumptions
- editor support that arrives late, if it arrives at all
- board support and drivers scattered across blog posts, examples, forks, and abandoned repos
That fragmentation makes people less curious. You stop trying a new chip because it means starting over. You stop swapping boards because you do not want to re-learn a whole stack. You pick the familiar part instead of the right part because the learning tax is too high.
Flint is meant to lower that tax. The same flint build command, the same language, the same standard library conventions, the same editor workflow, and the same mental model should travel with you across supported targets. The hardware-specific details should live in the official hal/* and board/* layers, not bleed into every application.
Why Existing Paths Still Hurt
C
C is still the default language of embedded systems because it maps closely to the hardware and has decades of tooling behind it. But the lived experience of embedded C is not the clean, portable story people like to tell.
The moment you target a real MCU, you are not working in some abstract world of portable C. You are working in a vendor ecosystem with its own startup code, peripheral headers, build flags, linker story, and HAL conventions. Switching targets often means re-learning most of the environment around the language.
Then there is the language itself. Undefined behavior, pointer mistakes, aliasing traps, signedness bugs, and weak abstraction boundaries are not edge cases in firmware, they are common sources of real bugs. C gives you control, but it also makes you pay for every sharp edge yourself.
Rust
Rust brought serious safety and discipline to systems programming, and Flint borrows from it with respect. Ownership, Result, Option, and pattern matching all matter.
But Rust was not designed around MCU ergonomics first. The borrow checker is powerful, but it can make ordinary embedded patterns feel more adversarial than they need to. no_std setup, target triples, linker configuration, panic strategy, crate compatibility, and trait-heavy APIs all add weight before you get to the part where your firmware actually does something interesting.
Rust proves that strong safety guarantees are possible. Flint takes that as encouragement, then asks whether those guarantees can come with less ceremony and a more coherent embedded-first experience.
MicroPython
MicroPython deserves credit for making microcontrollers more approachable. It is friendly, fast to start with, and genuinely fun.
The problem is that an interpreter and runtime come with hard limits. Code size, execution speed, and hardware-level control eventually become the ceiling. When you outgrow it, you often fall off a cliff into a much rougher toolchain.
Flint wants to keep the feeling of approachability while removing that cliff.
Vendor IDEs and Manufacturer Tooling
A lot of embedded tooling still assumes you are willing to accept an opaque GUI, a proprietary project format, and a vendor-controlled workflow as the price of admission.
That is not acceptable anymore. Modern developer tooling should be open, scriptable, cross-platform, editor-friendly, and CI-friendly. Firmware developers should not be stuck with a worse tooling baseline than everyone else just because they happen to work closer to hardware.
What Flint Is Building Instead
Flint is intentionally opinionated about the alternative.
A Self-Contained Toolchain
flint is the toolchain. Parsing, checking, formatting, code generation, diagnostics, and output packaging all live in one place. There is no hidden external compiler stack underneath.
That is extra work for the project, but it means users get one coherent workflow instead of a pile of moving parts. It also means unusual or underserved targets are not blocked on upstream approval from several other projects before work can even begin.
MCU-First Language Design
Flint starts with the constraints that most languages treat as special cases: no OS, limited RAM, limited flash, tight timing, and no appetite for runtime surprises.
That changes the design in practical ways. Ownership matters. Cleanup is deterministic. Errors are explicit. The standard library is split so hardware-specific functionality lives in micro/*, hal/*, and board/*, while cross-target functionality lives in core/* and std/*.
The result is that MCU support is not a reduced mode of the language. It is the center of gravity.
Developer Experience From Day One
This part matters enough to say plainly: Flint is not waiting until later to care about tooling.
The language is still under active development, but the specification is already strong enough to anchor the compiler, the documentation, the grammar, syntax highlighting, and the language server together. LSP support, autocomplete, diagnostics, formatting, and editor integrations are not polish for some future release. They are part of the project from the onset because embedded developers should not have to choose between serious systems work and a decent editing experience.
One Official Ecosystem
Flint is betting on a rich official library stack instead of a fragmented package registry.
That includes the standard library, MCU APIs, HAL layers, board support, and the surrounding tooling. When an important capability is missing, the preferred answer is to add it upstream in the shared ecosystem rather than scatter five inconsistent third-party versions around the internet and hope one survives.
That approach is not about central control. It is about coherence, auditability, and reducing the amount of ecosystem archaeology every user has to do before they can build something real.
For Us, By Us
Flint is not a company product searching for a market. It is a community-owned language for people who actually build firmware and want to own the tools they depend on.
That includes the compiler, the library stack, the board support, the editor experience, and the documentation. If the community is going to own the ecosystem, then the ecosystem has to be shaped so people can meaningfully contribute to it.
That is why the standard library is Flint source, not a sealed Rust implementation hidden behind a boundary ordinary contributors cannot cross. It is why the HAL and board layers are supposed to be understandable and extendable. It is why editor support is treated as part of the product. It is why the documentation has to stay aligned with reality.
The aim is not just to make a language people can use. It is to make a language ecosystem people can help build.
Design Principles
Several ideas keep showing up across the language and toolchain because they are the real center of the project:
- Self-contained over stitched-together. One toolchain, one workflow, one place to understand what is happening.
- MCU-first over retrofitted. Start with the hardest environment and let everything else grow from there.
- Safe by default, without user-facing ceremony. Keep the ownership model, lose the unnecessary friction.
- Readable source and predictable tooling. Code should be easy for humans to follow and easy for tools to reason about.
- Official libraries over dependency sprawl. Prefer a coherent shared ecosystem to a registry full of overlapping reinventions.
- Developer experience is a feature. Formatting, diagnostics, autocomplete, syntax highlighting, and editor support are part of the language experience, not extras.
- Community ownership has to be real. The project should be structured so contributors can extend the stack they depend on.
What Flint Takes From Other Languages
Flint borrows ideas freely from languages that got important things right:
| Language | What Flint borrows |
|---|---|
| Rust | Ownership, move semantics, Result/Option, no null, pattern matching, deterministic cleanup |
| Go | Readable syntax, error-as-value discipline, simple module system, defer, channels |
| Python | Clean keywords, English-like operators, code that reads like what it does |
The goal is not to imitate those languages wholesale. It is to keep what helps firmware work stay clear and reliable, and leave behind what adds weight without helping enough.
What Flint Is Not Trying to Be
Flint is not trying to be the most powerful language in every dimension. It is not trying to win by accumulating features.
It is not a macro-heavy language. It is not a borrow-checker puzzle box. It is not a dependency ecosystem first and a language second. It is not a scripting environment with a tiny path to native code.
It is a focused language and toolchain for building firmware without accepting the usual amount of pain as inevitable. If it does that well, the path to broader systems targets follows naturally from a solid foundation.
Installation
Flint is distributed as source. You build the compiler from the repository, copy the binary to your PATH, and use flint directly from there.
Prerequisites
You need a working Rust toolchain. Flint itself has no other dependencies.
# Install Rust if you haven't already
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Build from Source
Clone the repository and build in release mode:
git clone https://codeberg.org/ewrogers/flint
cd flint
cargo build --release -p flint
Install
The compiled binary lands at target/release/flint. Copy it to somewhere on your PATH:
cp target/release/flint ~/.local/bin/ # Linux / macOS
Make sure ~/.local/bin (or wherever you placed it) is in your PATH. After this, flint works like any other command.
Verify the Installation
flint --version
flint --help
You should see the Flint version and command summary.
Supported Host Platforms
Flint’s compiler runs on:
- macOS (Apple Silicon and Intel)
- Linux (x86_64, aarch64, riscv64)
- Windows (x86_64, experimental)
The compiler targets microcontrollers. The host you build on does not need to match the device you build for.
Flashing Tools (Optional)
Flint itself produces UF2, ELF, and BIN artifacts. To get code onto your board you can:
- UF2 drag-and-drop: hold BOOTSEL on your Pico and copy the
.uf2file. No extra tools needed. picotool: Raspberry Pi’s official tool for RP2040/RP2350 flashing.probe-rs: cross-platform SWD/JTAG flashing and debugging, works with Pico Debug Probe.- OpenOCD: classic embedded debugging interface.
flint flash is planned as a built-in flashing command for future releases.
Your First Program
Let’s write a complete Flint program: an LED blink on a Raspberry Pi Pico.
Create a New Project
flint new blink
This creates:
blink/
flint.toml
src/
main.fl
The Manifest
Open blink/flint.toml:
name = "blink"
version = "0.1.0"
edition = "2026"
target = "rp2040"
board = "pico"
The manifest tells the compiler what chip to target (rp2040) and which board package to use (pico). The board package knows the default pin layout for your Pico.
The Source
Open blink/src/main.fl:
use std/time
use micro/gpio
const LED_PIN: u32 = 25
fn main() -> never {
let mut led = gpio.pin(LED_PIN).into_output()
loop {
led.toggle()
time.sleep_ms(1000)
}
}
Let’s walk through it.
Imports
use std/time
use micro/gpio
Imports are namespaces, not wildcard includes. use micro/gpio gives you the gpio namespace, so you call gpio.pin(...) rather than a bare pin(...). Only the functions your program calls end up in the binary.
Constants
const LED_PIN: u32 = 25
A compile-time constant for the Pico’s onboard LED pin. Constants always require an explicit type.
Entry point
fn main() -> never {
-> never means this function never returns. On MCU firmware, main runs forever. The compiler enforces this: a main that could return is an error on embedded targets.
Pin setup
let mut led = gpio.pin(LED_PIN).into_output()
Configures GPIO pin 25 as a digital output and binds it to led. mut is required because led.toggle() takes a mutable receiver. Mutability is explicit and tracked by the compiler.
The loop
loop {
led.toggle()
time.sleep_ms(1000)
}
loop is an infinite loop. led.toggle() flips the pin state. time.sleep_ms(1000) blocks for one second. That is the whole program.
Build
cd blink
flint build --output-format uf2
You will see output like:
compiling project `blink` (rp2040-thumb-pico)
compiled successfully in 0.01s
binary image: 1,108 bytes (1.08KB)
output: `build/blink.uf2` 2,560 bytes (2.5KB)
Flash
Hold BOOTSEL on your Pico while connecting USB. It appears as a USB drive. Copy the UF2:
cp build/blink.uf2 /Volumes/RPI-RP2/ # macOS
# or drag-and-drop in your file manager
The LED blinks. You just wrote and deployed your first Flint program.
Build Outputs
flint build writes artifacts into build/:
| Format | Flag | Description |
|---|---|---|
| UF2 | --output-format uf2 | For drag-and-drop flashing |
| ELF | --output-format elf | For debugging with probe-rs or OpenOCD |
| BIN | --output-format bin | Raw binary image |
Next Steps
- Try the other examples in the repository.
- Read Project Layout to understand how larger projects are structured.
- Jump into the Language Reference when you are ready for the full picture.
Project Layout
A Flint project is simple by design.
my_project/
flint.toml ← manifest
src/
main.fl ← entry point
drivers/
display.fl ← local module
sensor.fl ← local module
build/ ← generated by flint build (git-ignore this)
The Manifest (flint.toml)
flint.toml is TOML and lives at the project root. The minimal required fields are:
name = "my_project"
version = "0.1.0"
edition = "2026"
target = "rp2040"
board = "pico"
| Field | Description |
|---|---|
name | Project name. Used as the artifact filename. |
version | Semver project version. |
edition | Source compatibility year. Use 2026. |
target | The chip to compile for (e.g., rp2040). |
board | The board package (e.g., pico). |
Optional Manifest Fields
# Allow recursive function calls on MCU targets.
# Disabled by default on MCU targets to catch stack-risk bugs.
allow-recursion = true
Source Files
All source lives under src/. Files use the .fl extension. Each file is exactly one module.
The entry point is always src/main.fl. Modules are imported with use using their path relative to src/:
// src/main.fl
use drivers/display
use drivers/sensor
fn main() -> never {
display.init()
let temp = sensor.read_celsius()
display.show_temp(temp)
loop {}
}
// src/drivers/display.fl
pub fn init() -> () {
// ...
}
pub fn show_temp(temp_c: i32) -> () {
// ...
}
The file src/drivers/display.fl maps to the module path drivers/display. Its exported functions are accessed as display.init(), display.show_temp(...).
Module Rules
- One file, one module. No
moduledeclarations in source. - Import paths use
/, not.or::. - The last path segment is the default module name:
use drivers/displayimports asdisplay. - Use
asto rename:use drivers/display as screen. - No wildcard imports (
use foo/*is not supported). - No relative imports (
./fooor../fooare not supported).
Build Output
flint build writes artifacts into build/ inside your project:
build/
my_project.uf2
my_project.elf
my_project.bin
The build/ directory is generated. Add it to your .gitignore.
PIO Files
RP2040 PIO programs live anywhere under src/ with a .pio extension:
src/
main.fl
drivers/
blink.pio
Reference them at compile time through micro/pio:
use micro/pio
let program = pio.program_file("drivers/blink.pio")?
The compiler assembles the PIO program during the build.
Manifestless Builds
You can build a single file or directory without a flint.toml, but you must specify the target explicitly:
flint build src/main.fl --target rp2040
flint build src/ --target rp2040
Manifestless builds are useful for quick experiments. For real projects, always use a manifest.
Toolchain Commands
The flint CLI has six commands. That is the entire toolchain.
flint new
Scaffold a new project:
flint new blink
Creates:
blink/
flint.toml
src/
main.fl
The generated project targets rp2040 with board = "pico" by default.
flint fmt
Format your source into canonical Flint style:
flint fmt # format project in current directory
flint fmt --path my_project # format a specific project path
The formatter is opinionated and non-negotiable, like gofmt. Run it before committing. There is no configuration.
Key formatting rules:
- 4-space indentation
- 100-character line limit
- One blank line between top-level items
- Brace on the same line as the construct
usedeclarations at the top of the file, sorted
flint check
Type-check and semantically validate your project without building:
flint check # check project in current directory
flint check --path my_project # check a specific project
flint check --json # emit diagnostics as JSON
check runs the full frontend: lex, parse, resolve, and type-check. It is fast, so use it often. No artifacts are written.
The --json flag is useful for editor integrations and CI scripts:
flint check --json | jq '.diagnostics[] | select(.severity == "error")'
flint build
Compile and produce artifacts:
flint build # build project in current directory
flint build my_project # build a project by path
flint build --output-format uf2 # produce a UF2
flint build --output-format elf # produce an ELF
flint build --output-format bin # produce a raw binary
flint build --show-layout # show section layout and symbol table
flint build --show-layout --json # same, as structured JSON
Successful output:
compiling project `blink` (rp2040-thumb-pico)
compiled successfully in 0.01s
binary image: 1,108 bytes (1.08KB)
output: `build/blink.uf2` 2,560 bytes (2.5KB)
With --show-layout:
compiling project `blink` (rp2040-thumb-pico)
compiled successfully in 0.01s
binary image: 1,108 bytes (1.08KB)
output: `build/blink.uf2` 2,560 bytes (2.5KB)
layout:
total size: 1,108 bytes (1.08KB)
base address: 0x10000000
entry address: 0x100001C1
output: uf2 (2,560 bytes (2.5KB)) -> build/blink.uf2
sections:
0x10000000 +0x000000 256 bytes boot2 (second-stage bootloader)
0x10000100 +0x000100 192 bytes vector_table (48 vectors)
0x100001C0 +0x0001C0 660 bytes text (reset handler and reachable functions)
0x100001C0 +0x0001C0 504 bytes reset_handler (startup and clock bring-up)
symbols:
0x100001C0 +0x0001C0 504 bytes reset_handler (entry)
0x100003B8 +0x0003B8 40 bytes src/main.fl::main (src/main.fl)
0x100003E0 +0x0003E0 44 bytes std/time.fl::sleep_ms (std/time.fl)
0x1000040C +0x00040C 44 bytes micro/gpio.fl::into_output (micro/gpio.fl)
0x10000438 +0x000438 28 bytes micro/gpio.fl::toggle (micro/gpio.fl)
--show-layout breaks down exactly what landed in your binary. Sections show how flash is organized. Symbols show every function that made it into the image, its address, and its size. Notice only the functions your program actually calls are listed. Everything else was discarded. Useful for understanding your flash budget or tracking down an unexpected size increase.
MCU Recursion
On MCU targets, recursive function calls are an error by default. Stack overflow is unrecoverable on most MCUs, and recursion makes stack usage unbounded and impossible to analyze statically.
This is not a novel restriction. The JSF C++ Coding Standards, used for safety-critical avionics software on platforms like the F-35, explicitly prohibit recursion for exactly the same reason: if you cannot bound the stack at compile time, you cannot guarantee the system will not corrupt itself at runtime. Flint enforces this by default and lets you opt out only when you know what you are doing.
To opt into recursion intentionally:
flint build --allow-recursion
Or set it in flint.toml:
allow-recursion = true
flint lsp
Start the language server for editor integration:
flint lsp # start the server (stdio transport)
flint lsp --log-file /tmp/lsp.log # write debug log to a file
flint lsp --trace protocol # trace protocol messages to stderr
Editors launch this automatically. You normally do not run it yourself. See Language Server for supported features and editor setup.
flint flash
Reserved for direct device flashing. Not yet implemented.
In the meantime, use one of these approaches:
- UF2 drag-and-drop: BOOTSEL mode, copy
.uf2to the drive. picotool:picotool load build/blink.uf2 --forceprobe-rs:probe-rs run --chip RP2040 build/blink.elf- OpenOCD:
openocd -f interface/cmsis-dap.cfg -f target/rp2040.cfg ...
Common Workflows
# Quick check during development
flint check
# Build and flash (UF2 method)
flint build --output-format uf2
cp build/blink.uf2 /Volumes/RPI-RP2/
# CI check
flint check --json
# Debug build with verbose layout
flint build --output-format elf --show-layout
Diagnostics
Flint errors include file, line, and column. Errors are reported to stderr. The --json flag on check and build emits structured diagnostic objects for tooling:
{
"diagnostics": [
{
"severity": "error",
"message": "use of moved value: `packet`",
"file": "src/main.fl",
"line": 14,
"column": 5
}
]
}
Syntax Style
Flint’s syntax is brace-based, semicolon-free, and deliberately small. If you know Go, Python, or Rust, most of it will feel immediately familiar.
General Rules
- Braces delimit blocks. Indentation is style, not syntax.
- No semicolons in normal code. A newline ends the current statement.
- No semicolons means no ambiguity workarounds: open delimiters (
(,[,{) extend a statement across lines naturally. - Expression-oriented only where it genuinely simplifies things.
Automatic Statement Termination
A newline ends the current statement unless:
- The parser is inside an open
(,[, or{ - The line ends with an infix operator or incomplete assignment
- The line ends with
.
This means multiline calls and literals work without any special syntax:
let result = some_function(
first_arg,
second_arg,
third_arg,
)
let point = Point {
x: 10,
y: 20,
}
Naming Conventions
Flint has four naming styles. Violations are reported as errors by flint check and corrected automatically by flint fmt:
Ordinary identifiers are ASCII-only. They start with a letter, may use digits or _ after the first character, and may not end with _. Bare _ is reserved for discard and wildcard use.
| Item | Style | Example |
|---|---|---|
| Functions, methods, variables, fields, modules | snake_case | read_bytes, retry_count |
| Types, traits, type aliases, enum variants | CamelCase | SerialPort, ParseError, Some |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES, LED_PIN |
| Import paths | snake_case with / | micro/gpio, std/time |
Even when a convention uses internal _ separators, ordinary names still must begin with a letter and may not end with _.
use micro/gpio
use std/time
const LED_PIN: u32 = 25
struct SerialPort {
baud: u32
tx_pin: u32
}
fn read_bytes(mut port: SerialPort) -> Result<List<u8>, Error> {
// ...
}
Comments
// Single-line comment
/// Doc comment (used for documentation generation)
/// @deprecated use=std/time.sleep_ms since=1.2
pub fn delay_ms(ms: u32) { ... }
Block comments are not part of Flint source syntax. Use // on multiple lines.
Operators
Boolean
Flint uses English-like boolean operators instead of symbols:
| Operator | Meaning |
|---|---|
and | logical AND (short-circuits) |
or | logical OR (short-circuits) |
not | logical NOT |
in | membership test |
if is_ready and not is_busy {
send(packet)
}
if value in valid_range {
process(value)
}
There is no &&, ||, !, or ?: ternary.
Arithmetic
| Operator | Meaning |
|---|---|
+ | addition |
- | subtraction |
* | multiplication |
/ | division |
% | remainder |
let cycles = freq * duration / 1000
let offset = (index % buffer_size) + base
Integer overflow is a compile-time error where detectable, and a checked trap at runtime otherwise. There is no silent wraparound.
Bitwise
| Operator | Meaning |
|---|---|
& | bitwise AND |
| | bitwise OR |
^ | bitwise XOR |
~ | bitwise NOT |
<< | left shift |
>> | right shift |
let mask: u32 = 0b0000_1111
let flags = status & mask
let shifted = value << 4
let inverted = ~flags
These operators work on integer types only. Bit manipulation is common in firmware and Flint treats it as a first-class operation with no surprises.
Assignment
| Operator | Meaning |
|---|---|
= | assign |
+= | add and assign |
-= | subtract and assign |
*= | multiply and assign |
/= | divide and assign |
%= | remainder and assign |
&= | bitwise AND and assign |
|= | bitwise OR and assign |
^= | bitwise XOR and assign |
<<= | left shift and assign |
>>= | right shift and assign |
count += 1
flags |= ENABLE_BIT
buffer_pos %= buffer_size
There is no ++ or --. Use += 1 and -= 1 instead. Increment and decrement operators have a long history of subtle bugs around sequencing and expression context. The explicit form is unambiguous.
No Operator Overloading
Operators always mean exactly what they say. There is no way to redefine +, -, or == for custom types. Use named methods instead:
// Do this
let result = vec.add(other)
// Not this (not possible in Flint)
let result = vec + other
Types
Primitive Types
| Type | Description |
|---|---|
bool | Boolean: true or false |
u8, u16, u32, u64 | Unsigned integers |
i8, i16, i32, i64 | Signed integers |
usize, isize | Pointer-sized integers |
f32 | 32-bit float |
char | Unicode scalar value |
string | Immutable UTF-8 text |
never | The type of expressions that do not return |
() | Unit type (nothing / void) |
There is no
null. Absence is represented withOption<T>.
f64is intentionally excluded from Flint 1.0. Usef32for floating-point work.
Numeric Literals
let a: u32 = 1_000_000 // decimal with separator
let b: u8 = 0xFF // hex
let c: u8 = 0b1111_0000 // binary
let d: u8 = 0o177 // octal
let e: f32 = 1.5e-3 // scientific notation
let f: u32 = 100u32 // explicit type suffix
Integer literals without a suffix are typed from context. If there is no context, they default to i32. Float literals default to f32.
Integer Rules
Signed and unsigned integers are distinct types. The compiler never implicitly converts between them.
let a: u8 = 200
let b: i8 = a // error: type mismatch
// Explicit conversion:
let b = i16.from(a) // infallible (u8 -> i16 is always safe)
let b = i8.try_from(a) // fallible -> Option<i8>
Overflow behavior:
- Debug builds: overflow traps
- Release builds: overflow wraps
wrap_add,wrap_sub,wrap_mulexist incore/*for explicit wrapping
Division by zero always traps.
Compiler-Known Generic Types
These types are built into the language. You cannot define new generic types, but you can use these:
| Type | Description |
|---|---|
Option<T> | Optional value: Some(T) or None |
Result<T, E> | Success or failure: Ok(T) or Err(E) |
List<T> | Growable contiguous sequence |
Deque<T> | Double-ended queue |
Map<K, V> | Key-value map |
Set<T> | Unique value set |
slice<T> | Read-only view over contiguous data |
mut slice<T> | Exclusive mutable view over contiguous data |
These cover the overwhelming majority of what user-defined generics are used for in other languages. Go added generics after a decade and the community still largely reaches for concrete types and interfaces instead. You will probably be fine.
Arrays and Slices
Fixed-size arrays:
let buf: [u8; 8] = [0; 8] // 8 zeros
let rgb: [u8; 3] = [255, 0, 128]
buf[0] = 42 // indexing requires usize
let view = buf[1..4] // produces slice<u8>
Rules:
- Array length must be a compile-time constant.
- Out-of-bounds access at runtime triggers a hard fault trap on MCU targets.
slice<T>is a read-only view;mut slice<T>is an exclusive mutable view.- Slices do not own data.
Strings
let greeting: string = "Hello, Flint!"
let newline = '\n' // char literal
let byte: u8 = b'A' // byte char literal -> u8
let raw = """
This is a raw multiline string.
Escapes like \n are not processed here.
"""
let bytes: slice<u8> = b"binary data\x00" // byte string
Rules:
stringis immutable. You cannot modify it in place.- No integer indexing into
string. Use iterators or string APIs. charis a Unicode scalar value (one code point).string.len()returns the UTF-8 byte length.string.bytes()returns an immutableslice<u8>view.- Read-only and view-returning text methods live on
string. - Mutable text lives in
std/text.Buffer[N].
See String and Char for the dedicated text chapters.
Type Aliases
alias Bytes = slice<u8>
alias BytesMut = mut slice<u8>
alias Handler = (i32, i32) -> bool
Aliases are not new types. They are just shorthand for an existing type. Alias names follow CamelCase.
Collection Algorithms
Flint 1.0 uses explicit eager collection methods on sequential collections. These helpers are available on fixed arrays, slice<T>, and List<T>.
Searching
Search helpers return indices, not element values:
let temps: List<i32> = [68, 72, 81, 79, 81]
let first_hot = temps.find(|value| {
return value > 80
}) // Option<usize>, Some(2)
let first_81 = temps.index_of(81) // Option<usize>, Some(2)
let last_81 = temps.last_index_of(81) // Option<usize>, Some(4)
let last_hot = temps.find_last(|value| {
return value > 80
}) // Option<usize>, Some(4)
Transforming
map and filter allocate a new List and leave the source collection unchanged:
let nums: List<i32> = [1, 2, 3, 4, 5]
let squares = nums.map(|value| {
return value * value
}) // List<i32>
let evens = nums.filter(|value| {
return value % 2 == 0
}) // List<i32>
Reducing
reduce combines the collection into a single value:
let nums: List<i32> = [1, 2, 3, 4, 5]
let total = nums.reduce(0, |sum, value| {
return sum + value
}) // 15
Sorting
sort_by sorts a mutable List<T> in place:
use core/cmp
let mut nums: List<i32> = [5, 3, 1, 4, 2]
nums.sort_by(|a, b| {
return cmp.compare(a, b)
})
Membership Operator
let has_three = 3 in nums // true
let has_key = "PORT" in config // Map key lookup
if "admin" in roles {
grant_access()
}
Supported for fixed arrays, slice<T>, List<T>, Set<T>, and Map<K, V> key lookup. String membership is also supported for both char in string and string in string.
String
string is Flint’s built-in immutable UTF-8 text view.
It is the normal text type for literals, API parameters, and view-returning text operations. The same source-level model is used on MCU and host targets.
Core Rules
stringvalues are always valid UTF-8.stringis immutable. You do not modify it in place.string.len()returns the UTF-8 byte length, not a character count.string.bytes()returns an immutableslice<u8>view over the underlying bytes.- Direct integer indexing into
stringis not allowed. - Read-only and view-returning methods are the default string surface.
- Portable baseline string methods must not hide allocation.
Basic Usage
let text: string = " key?=value\r\n"
let bytes: slice<u8> = text.bytes()
let len: usize = text.len()
let trimmed: string = text.trim()
let found: bool = "?=" in text
Search and Membership
String search works on UTF-8 text and returns byte indices where applicable.
let text = "mode?=auto?=backup"
let has_delimiter = text.contains("?=")
let first = text.find("?=")
let last = text.find_last("?=")
let has_a = text.contains_char('a')
let first_a = text.find_char('a')
let last_a = text.find_char_last('a')
Flint also supports string membership syntax:
let has_text = "auto" in text
let has_char = 'a' in text
let missing = "uart" not in text
Prefix, Suffix, and Trimming
These methods return booleans or narrower string views. They do not allocate.
let text = " board/pico\r\n"
let is_board = text.trim_start().starts_with("board/")
let has_suffix = text.trim_end().ends_with("pico")
let trimmed = text.trim()
Current shipped trim behavior uses ASCII whitespace semantics.
Subviews and Splitting
string.slice_bytes(start, end) creates a validated subview. The byte range must describe valid UTF-8 boundaries.
let text = "board/pico"
let head = text.slice_bytes(0, 5) // "board"
For common parsing cases, use split_once and split_once_last:
let text = "key?=value?=tail"
let split = text.split_once("?=")
let before = split.before()
let after = split.after()
let last_split = text.split_once_last("?=")
let tail = last_split.after()
These split methods take a full string delimiter, not just a char, and return text.Split. See Text for that type.
ASCII Classification
The portable baseline includes conservative ASCII-focused helpers:
let text = "UART0"
let ascii = text.is_ascii()
let letters = text.is_ascii_letters()
let digits = text.is_ascii_digits()
let alpha_num = text.is_ascii_alphanumeric()
let ws = text.is_ascii_whitespace()
The explicit ASCII naming matters. Flint’s baseline text model is UTF-8 everywhere, but richer Unicode classification and transforms are separate future work.
Mutation
Mutable text does not live on string.
Use std/text.Buffer[N] when you need explicit fixed-capacity text building or editing:
use std/text as text
let mut buffer = text.buffer(32)
let pushed = buffer.push_str("pico")
let view = buffer.as_string()
See Text for the current mutable text surface.
Char
char represents one Unicode scalar value.
It is not a one-character string. A char can encode to one to four UTF-8 bytes, and Flint keeps that distinction explicit so text APIs stay predictable across MCU and host targets.
Basic Usage
let newline: char = '\n'
let letter: char = 'A'
let accent: char = 'é'
UTF-8 Helpers
The built-in char substrate exposes the minimum helpers needed for portable text code:
let ch = 'é'
let width: usize = ch.utf8_len()
let first: u8 = ch.utf8_byte(0)
utf8_len()returns how many UTF-8 bytes the scalar usesutf8_byte(index)returns one encoded UTF-8 byte
These helpers are what allow std/text to stay mostly Flint code instead of pushing text algorithms into each backend.
ASCII Classification
Current shipped character classification is explicit about being ASCII-only:
let ch = 'A'
let letter = ch.is_ascii_letter()
let digit = ch.is_ascii_digit()
let alpha_num = ch.is_ascii_alphanumeric()
let whitespace = ch.is_ascii_whitespace()
let symbol = ch.is_ascii_symbol()
let control = ch.is_ascii_control()
let upper = ch.is_ascii_upper()
let lower = ch.is_ascii_lower()
The explicit naming is intentional. Flint’s baseline text model is UTF-8 everywhere, but full Unicode classification is not part of the portable core text surface in 1.0.
Escape Forms
Chars support the standard Flint escape forms:
let newline = '\n'
let tab = '\t'
let nul = '\0'
let quote = '\''
let slash = '\\'
let hex = '\x41'
let smile = '\u{1F642}'
Byte chars use b'...' syntax and produce a u8:
let byte_a: u8 = b'A'
let byte_newline: u8 = b'\n'
Variables and Constants
let
let declares a local binding. Bindings are immutable by default:
let timeout_ms = 250
let name: string = "flint"
Use let mut for a binding you intend to modify:
let mut count: u32 = 0
count = count + 1 // ok
let x = 5
x = 6 // error: cannot assign to immutable binding
Type annotations are optional when the type is obvious from context. Function signatures, struct fields, const, and static always require explicit types.
const
Constants are compile-time values. They always require a type:
const MAX_RETRIES: u32 = 3
const DEFAULT_BAUD: u32 = 115_200
const BANNER: string = "Flint 1.0-dev"
Rules:
- Always immutable. There is no
const mut. - Initializer must be computable at compile time.
- Usable anywhere a value of that type is needed.
- Naming convention:
UPPER_SNAKE_CASE.
static
static is for module-scope storage with a fixed program lifetime:
static BOOT_BANNER: string = "Flint"
Rules:
staticis immutable. There is nostatic mut.- Must have an explicit type.
- Initializer should be compile-time evaluable.
- Lives in read-only image storage on MCU targets.
Use static when a value must exist for the entire program lifetime, not just at compile time.
Mutability
Mutability in Flint attaches to the binding, not the value. A mutable binding lets you reassign it and call mutating methods:
let point = Point { x: 1, y: 2 }
point.x = 3 // error: immutable binding
let mut point = Point { x: 1, y: 2 }
point.x = 3 // ok
point.translate(1, 1) // ok (translate takes mut self)
Assignment operators work as expected on mutable bindings:
let mut n: u32 = 10
n += 5 // ok
n -= 2 // ok
n *= 3 // ok
There is no ++ or --. Use += 1 and -= 1.
Type Inference
Local let bindings may omit the type when it is clear from context:
let x = 42 // i32 (default for unsuffixed integer)
let y = 1.5 // f32 (default for unsuffixed float)
let s = "hello" // string
let pin = gpio.pin(25) // inferred from gpio.pin return type
When inference is ambiguous, you must annotate:
let buf = [0; 64] // error: what is the element type?
let buf: [u8; 64] = [0; 64] // ok
Shadowing
Nested scopes may shadow outer bindings:
let value = 10
{
let value = value * 2 // shadows outer `value` in this scope
log.info(value) // 20
}
log.info(value) // 10 (outer binding restored)
Same-scope shadowing is a compile error:
let port = 8080
let port = 9090 // error: redeclaration in the same scope
let mut x = 5
let x = 10 // error: redeclaration in the same scope
Use if let and match for the common pattern of binding an inner refined value:
let raw = env.lookup("PORT")
if let Some(raw) = raw {
let port = u16.try_from(parse_i32(raw)?)?
use_port(port)
}
Option
Option<T> is how Flint represents a value that might not be there. There is no null in Flint. A function that might return nothing says so explicitly in its return type.
fn find_device(id: u32) -> Option<Device> {
// returns Some(device) or None
}
let device = find_device(42)
// Pattern match to unwrap safely
match device {
Some(d) => d.init(),
None => log.warn("device not found"),
}
// Or use if let for the happy path
if let Some(d) = find_device(42) {
d.init()
}
If you only need to know whether a value is present and do not need to bind it, use is_some() or is_none() instead of if let Some(...).
None can never be dereferenced. The compiler forces you to handle it before using the value. No null pointer exceptions, no segfaults from a forgotten check.
See Error Handling for how Option composes with ? and Result.
Result
Result<T, E> is how Flint represents operations that can fail. There are no exceptions and no try/catch. A function that can fail says so in its return type, and the caller decides what to do.
fn read_sensor() -> Result<u16, Error> {
// returns Ok(reading) or Err(reason)
}
// Handle explicitly
match read_sensor() {
Ok(v) => process(v),
Err(e) => log.error(e),
}
// Or propagate with ?
fn run() -> Result<(), Error> {
let reading = read_sensor()? // returns Err early if it failed
process(reading)
return Ok()
}
? is the idiomatic way to propagate errors up the call stack without nesting. Control flow is never interrupted invisibly. Every exit path is visible in the source.
When the success type is (), Ok() is enough. The unit value is inferred.
See Error Handling for the full picture.
Global Mutable State
Raw mutable globals do not exist in Flint. For global initialization patterns, use core/sync:
use core/sync
// Single-assignment cell, safe to access from any code after init
static DEVICE_ID: sync.OnceCell<u32> = sync.OnceCell.new()
fn init_device() {
DEVICE_ID.set(read_unique_id())
}
core/sync.OnceCell<T> and core/sync.Lazy<T> are the safe patterns for globals that need runtime initialization.
Ownership and Borrowing
Flint uses ownership and borrowing to control moves, mutation, and aliasing, but keeps the source language surface explicit and lightweight. You do not write borrow operators, dereference operators, or lifetime annotations in ordinary Flint code. Instead, you express intent through parameter modes such as default read-only access, mut for mutable access, and owned for consuming a value. If you are coming from Rust, the underlying discipline is familiar, but Flint handles the reference mechanics for you.
Core Rules
- Every non-copy value has exactly one owner.
- Assigning a non-copy value moves ownership.
- Returning a non-copy value moves ownership.
- Use-after-move is a compile error.
- Mutable aliasing is not allowed.
Copy Types
These types are automatically duplicated on assignment, with no move:
- All integer types (
u8,u16,u32,u64,i8,i16,i32,i64,usize,isize) bool,char,f32- Structs and enums whose fields are all copy types
Everything else is move-only. If you need a duplicate of a non-Copy value, call .clone() explicitly.
Built-In Traits and Clone
Flint keeps ownership-related duplication rules at the language and standard-library level, not as ad hoc method-name conventions.
Copyis a compiler-known structural property, not a user-defined trait.Cloneis the standard built-in trait for explicit duplication of non-Copyvalues.Clonelives incore/cloneand is available through the global prelude, soimpl Clone for MyTypeworks withoutuse core/clone.
struct Buffer {
data: [u8; 1024]
}
impl Clone for Buffer {
fn clone(self) -> Self {
return Self { data: self.data }
}
}
let a = Buffer { data: [0; 1024] }
let b = a.clone() // explicit duplicate
Use Clone when the type has meaningful duplication semantics and a plain move would be too restrictive. If a type is already Copy, you do not need Clone for ordinary assignment or passing by value.
A common example is a multi-producer channel sender. In an MPSC setup, you often want several parts of the program to send into the same channel. That is a good fit for Clone: cloning the sender gives you another handle to the same channel endpoint, rather than moving the only sender away from the original owner.
use std/sync
let ends = sync.channel<u32>(16)?
let tx1 = ends.tx
let tx2 = tx1.clone()
tx1.send(1)?
tx2.send(2)?
Here tx1 and tx2 both refer to the same channel’s sending side. Clone is the right model because the duplication is intentional and semantically meaningful.
Move Semantics
fn send(owned packet: Packet) -> Result<(), Error> {
// packet is consumed here
}
let packet = Packet.new(data)
send(packet) // packet is moved into send()
log.info(packet) // error: use of moved value
let packet2 = Packet.new(data)
let saved = packet2 // packet2 is moved into saved
log.info(packet2) // error: use of moved value
Assigning over a live value drops the previous value first:
let mut buf = Buffer.new(64)
buf = Buffer.new(128) // previous buf is dropped, then new value stored
Parameter Modes
Flint puts ownership intent on the function signature, not the call site. There are three parameter modes:
Default (Read-Only Borrow)
Non-copy values are passed by implicit read-only reference. The caller retains ownership:
fn measure(data: List<u8>) -> usize {
return data.len()
}
let samples = read_adc()
let n = measure(samples) // samples is still live after this call
Copy types are passed by value.
mut (Mutable Borrow)
The callee may mutate the value. The caller retains ownership:
fn append(mut buf: List<u8>, byte: u8) {
buf.push(byte)
}
let mut data = List.new()
append(data, 0xFF)
log.info(data.len()) // 1 (mutation is visible to the caller)
owned (Consume / Move)
The callee takes ownership. The caller loses the value:
fn send(owned packet: Packet) -> Result<(), Error> {
// packet is consumed here
}
let p = Packet.new(payload)
send(p)? // p is moved into send
// p is no longer accessible here
Self Receivers
The same modes apply to method receivers:
impl Buffer {
fn len(self) -> usize { ... } // read-only
fn push(mut self, byte: u8) { ... } // mutating
fn consume(owned self) -> slice<u8> { ... } // consuming
}
let mut buf = Buffer.new(32)
let n = buf.len() // ok, immutable method
buf.push(0x01) // ok, mutable method
let data = buf.consume() // buf is moved into consume, no longer accessible
Partial Moves
You can move an individual field out of an owned struct without moving every field at once. When that happens, the original struct becomes partially moved.
This can feel unusual at first, but the rule is straightforward:
- the field you moved now has a new owner
- fields you did not move are still accessible on their own
- the original struct can no longer be used as a complete value
The reason is safety and consistency. Once one field has been moved away, the compiler can no longer treat the original struct as fully intact. Allowing you to use the whole struct again would mean pretending that all of its parts are still present, which is no longer true.
Basic Example
struct Request {
headers: Map<string, string>
body: List<u8>
}
let req = Request { headers: ..., body: ... }
let body = req.body // move body out
process(body)
// req is now partially moved; req.headers is still accessible:
let auth = req.headers.get("Authorization")
// but cannot use req as a whole:
let r = req // error: partial move
req.body moved into body, so req is no longer a complete Request. The compiler still lets you use req.headers because that field was never moved.
What Is Still Allowed
You can keep working with the fields that remain in place:
struct Response {
status: u16
payload: List<u8>
}
let response = Response { status: 200, payload: bytes }
let payload = response.payload
let code = response.status // ok, status is still present
use_payload(payload)
log.info(code)
This is valid because status and payload are tracked independently. Moving payload does not erase status.
What Is Not Allowed
You cannot pass, return, assign, or pattern-match the original struct as though it were still complete:
struct Job {
id: u32
data: List<u8>
}
let job = Job { id: 7, data: bytes }
let data = job.data
submit(job) // error: job is partially moved
let saved = job // error: job is partially moved
In both lines, the operation needs the whole Job, but the data field has already been moved out.
Why This Is Useful
Partial moves let you take ownership of exactly the part you need without forcing unnecessary cloning or awkward restructuring.
For example, if you only need the request body for a parser, moving just that field is more direct than duplicating the entire request:
let body = req.body
parse(body)
That is efficient, but the tradeoff is that req can no longer be treated as a complete value afterward.
If You Still Need the Whole Value
If you need to keep using the whole struct later, do not move the field out directly. Instead:
- borrow the struct through a read-only or
mutparameter - clone just the field you need
- reorganize the code so the whole struct is no longer needed after the move
let body = req.body.clone()
archive(req) // ok, req is still complete
parse(body)
The key idea is simple: moving a field splits ownership of the struct into pieces, and once that happens, the compiler stops treating the original binding as one complete object.
Interior Mutability
Normally, mutation in Flint requires a mut binding or a mut parameter. Interior mutability is the deliberate exception: a type may allow mutation through an immutable outer binding because the type itself enforces the safety rule.
This does not weaken ownership. It moves the rule from “the binding must be mutable” to “this API is responsible for making mutation safe”. In practice, Flint uses this pattern for synchronization and one-time initialization primitives such as core/sync.OnceCell<T>, core/sync.Lazy<T>, atomics, and std/sync.Mutex<T>.
Why It Exists
Some values need to change even when the outer handle should stay fixed:
- global one-time initialization
- shared state behind a lock
- atomic counters updated from multiple places
The outer binding stays immutable, but the wrapper type controls when mutation is legal.
Example: One-Time Initialization
OnceCell<T> is interior mutability in a simple form. The static binding is immutable, but the cell can transition from “empty” to “initialized” exactly once:
use core/sync
static DEVICE_ID: sync.OnceCell<u32> = sync.OnceCell.new()
fn init() -> () {
DEVICE_ID.set(read_chip_id())
}
fn device_id() -> u32 {
return DEVICE_ID.get().unwrap_or(0)
}
DEVICE_ID is not declared with mut, and that is correct. The mutation happens inside OnceCell, which only permits a single successful write.
Example: Shared Mutable State Behind a Lock
With Mutex<T>, the immutable outer handle gives controlled mutable access to the inner value:
use std/sync
let queue: sync.Mutex<List<u8>> = sync.Mutex.new(List.new())?
fn push_byte(byte: u8) -> Result<(), Error> {
let mut guard = queue.lock()?
defer guard.release()
guard.value.push(byte)?
return Ok()
}
The binding queue is immutable. The mutable access appears only after lock() returns a guard. That guard is the proof that mutation is currently exclusive, so guard.value.push(...) is allowed without violating the ownership model.
Mental Model
Interior mutability means “immutable handle, controlled mutable interior”. Use it when the wrapper type is specifically designed for that job. Do not treat it as a general escape hatch around borrowing rules.
No Borrow Syntax
There are no &, &mut, *, or lifetime annotations in Flint source. The compiler handles all of this internally based on parameter modes. The ABI details (hidden address vs. value register) are implementation concerns, not source language concerns.
If you are coming from Rust: think of Flint as a language where the borrow checker exists but is invisible to you. The rules are real; you just don’t write them by hand.
Defer and Cleanup
Flint provides two cleanup mechanisms:
- Implicit drop: values are automatically cleaned up when they go out of scope.
defer: explicit cleanup statements that run at scope exit.
defer
defer schedules a call to run when the enclosing scope exits. It pairs naturally with resource acquisition:
let mut file = io.open("log.txt")?
defer file.close()
// ... use file ...
// file.close() runs here, even on early return or error propagation
let lock = mutex.lock()
defer lock.release()
shared_count += 1
// lock.release() runs here
Execution Order
Deferred calls run in LIFO order (last deferred, first run):
defer cleanup_a()
defer cleanup_b()
defer cleanup_c()
// runs: cleanup_c(), cleanup_b(), cleanup_a()
When Deferred Calls Run
- Normal scope exit
returnbreak- Error propagation via
?
fn process(path: string) -> Result<(), Error> {
let mut file = io.open(path)?
defer file.close() // runs whether we return Ok or Err
let data = file.read_all()? // if this returns Err, file.close() still runs
return parse(data)
}
Arguments Are Captured at the defer Site
The arguments to a deferred call are evaluated immediately when defer is reached, not when it runs:
let pin_num: u32 = 25
defer log.info(pin_num) // captures 25 now
let pin_num: u32 = 26 // error: same-scope redeclaration
defer and Scope
defer is scoped to the block it appears in, not the function:
{
let lock = mutex.lock()
defer lock.release()
write_shared_state()
}
// lock.release() ran when the block exited
do_other_work() // lock is already released
This makes defer useful for short-lived critical sections without a helper function.
Implicit Drop
Values are automatically dropped at the end of their scope in reverse initialization order:
{
let a = Resource.acquire("a")
let b = Resource.acquire("b")
let c = Resource.acquire("c")
}
// c drops first, then b, then a
Struct fields drop in reverse declaration order. Array elements drop from highest to lowest index.
Flint does not support user-defined Drop traits. Drop glue is generated automatically by the compiler for any type that holds owned resources.
Deferred Calls vs. Implicit Drop
defer runs before implicit drop of remaining locals in the same scope:
let conn = db.connect()?
defer conn.close() // 1. runs first
let cache = Cache.new()
// 2. cache drops here (implicit)
// 3. conn drops after defer
Conventions
- Use
deferto pair cleanup with acquisition: open/close, lock/release, start/stop. - Keep deferred calls simple and infallible when possible.
- Do not use
deferas a general exception or error-handling mechanism. That is whatResultis for.
// Good: symmetric resource management
let mut port = uart.open(0)?
defer port.close()
// Good: scoped lock
{
let guard = mutex.lock()
defer guard.release()
modify_shared()
}
Defer Blocks
When you need to group multiple cleanup steps together, defer accepts a block:
let lock = mutex.lock()
let handle = resource.acquire()
defer {
handle.release()
lock.unlock()
log.info("cleanup complete")
}
Statements inside the block run together in the order they appear, as a single deferred action. This is cleaner than multiple separate defer statements when the cleanup steps are logically related and their ordering within the group matters more than LIFO sequencing.
The block form and the expression form follow the same rules: the block executes at scope exit, on return, break, and error propagation via ?.
Error Handling
Flint treats errors as values. There are no exceptions, no stack unwinding, and no throw. Functions that can fail say so in their return type, and you handle the failure at the call site.
Result<T, E> and Option<T>
These two types cover the error model:
// A function that might fail
fn read_config(path: string) -> Result<Config, Error> { ... }
// A function that might not find a value
fn lookup(key: string) -> Option<string> { ... }
Constructors:
return Ok(value) // success
return Ok() // success with unit value
return Err("message") // failure with Error
return Some(value) // present
return None // absent
The ? Operator
? is shorthand for: “if this is Err or None, return it immediately from the enclosing function.”
With Result
fn process(path: string) -> Result<(), Error> {
let text = io.read_text(path)? // propagates Err automatically
let config = Config.parse(text)? // propagates Err automatically
apply(config)
return Ok()
}
Without ? it would be:
fn process(path: string) -> Result<(), Error> {
let text = match io.read_text(path) {
Ok(t) => t
Err(e) => return Err(e)
}
// ...
}
With Option
? also works with Option<T>. In that case, None returns early from the enclosing function:
fn read_port() -> Option<u16> {
let raw = env.lookup("PORT")?
let parsed = i32.parse(raw).ok()?
return u16.try_from(parsed).ok()
}
This example returns:
NoneifPORTis missingNoneif parsing failsNoneif the parsed value does not fit inu16Some(port)on success
The enclosing function must return a compatible Result or Option.
The Error Type
Error is Flint’s standard general-purpose error type. It is always in scope with no import needed. It is equivalent to an immutable string message:
fn connect(addr: string) -> Result<Connection, Error> {
if addr.is_empty() {
return Err("address cannot be empty")
}
// ...
}
You can use domain-specific error enums for richer modeling:
enum ParseError {
UnexpectedEof
UnexpectedToken { found: string, expected: string }
InvalidNumber { text: string }
}
fn parse(src: string) -> Result<Ast, ParseError> { ... }
Result and Option Methods
You do not always need a full match. Option<T> and Result<T, E> have a small set of built-in helpers for the most common cases.
Check the State
Use these when you only need to ask whether a value is present or successful:
let found = some_option.is_some()
let missing = some_option.is_none()
let ok = some_result.is_ok()
let failed = some_result.is_err()
Provide a Default
Use unwrap_or when you want a fallback value instead of branching:
let port = maybe_port.unwrap_or(8080)
let retries = parsed_retries.unwrap_or(3)
Transform the Success Value
Use map when you want to keep the container shape and transform only the success or present value:
let upper = username.map(|name| {
return name.upper()
}) // Option<string>
let doubled = reading.map(|value| {
return value * 2
}) // Result<u16, Error>
Chain Another Fallible Step
Use and_then when the closure itself returns another Option or Result:
let home_dir = env.lookup("HOME").and_then(|path| {
return normalize_path(path)
}) // Option<string>
let config = io.read_text("flint.toml").and_then(|text| {
return Config.parse(text)
}) // Result<Config, Error>
Convert Option Into Result
Use ok_or when absence should become an error:
let port = env.lookup("PORT").ok_or("PORT is required")
Transform the Error Value
Use map_err when the success case is fine but you want a different error type:
let reading = read_sensor().map_err(|err| -> SensorError {
return SensorError.ReadFailed { message: err }
})
Recover From an Error
Use or_else when you want to replace one failure with another attempt:
let config = load_primary_config().or_else(|_| {
return load_backup_config()
})
These methods cover most day-to-day cases:
- inspect:
is_some,is_none,is_ok,is_err - default:
unwrap_or - transform success:
map - chain another step:
and_then - convert absence into failure:
ok_or - transform failure:
map_err - recover from failure:
or_else
If the logic starts branching in several directions, switch back to match. The methods are for simple pipelines, not for hiding complex control flow.
Fatal Errors
For bugs and unrecoverable states, not for normal error handling:
// Crash with a message (never returns)
fatal("invariant violated: queue empty")
// Crash with an Error value
fatal_error(err)
// Assert a condition (crashes if false)
assert(buffer.len() <= MAX_SIZE)
// Mark unreachable code paths
match direction {
Direction.Left => turn_left()
Direction.Right => turn_right()
_ => unreachable() // tells compiler this cannot happen
}
Fatal errors do not unwind. On MCU targets they emit a compact diagnostic record and then trap. They are a last-resort debugging path, not a substitute for Result.
Conventions
- Functions that can fail at runtime return
Result<T, Error>or a domain-specificResult<T, MyError>. - Functions that return optional data return
Option<T>. - Do not return
Resultfor operations that cannot fail. Unnecessary noise makes code harder to read. - Use
?liberally to propagate errors up the call stack without boilerplate. - Use
fatalandassertfor bug detection, not for expected runtime conditions.
// Good
fn parse_port(s: string) -> Result<u16, Error> {
let n = i32.parse(s)?
return u16.try_from(n).ok_or("port out of range")
}
// Avoid: defensive Result for operations that cannot fail
fn add(a: i32, b: i32) -> Result<i32, Error> { // unnecessary
return Ok(a + b)
}
Control Flow
if / else
if temp_c > 80 {
fan.high()
} else if temp_c > 60 {
fan.medium()
} else {
fan.low()
}
No parentheses around conditions. Braces are always required.
if as an Expression
if can produce a value when both branches are present:
// Postfix form: for short single-expression cases
let label = "hot" if temp > 80 else "ok"
// Block form: when branches need statements
let delay_ms = if fast_mode {
let base = 10
base
} else {
let base = 100
base
}
Both branches must produce the same type. The expression if always requires an else.
if let
Lightweight alternative to match when you only care about one pattern:
if let Some(port) = config.get("port") {
log.info(port)
} else {
log.info("using default: 8080")
}
if let Ok(data) = file.read() {
process(data)
}
Works with Option, Result, and enum variants.
If you only care whether an Option has a value and do not need the inner binding, prefer is_some() or is_none().
If you only care whether a Result succeeded and do not need the success value, prefer is_ok() or is_err().
for
for iterates over collections, ranges, or any iterable value using the in keyword.
Collection iteration: iterate over every element in a list, array, or slice:
for item in items {
process(item)
}
Half-open range: iterates from the start up to but not including the end:
for i in 0..10 { // 0, 1, 2, ..., 9
sum += i
}
Inclusive range: iterates from start through and including the end:
for i in 0..=10 { // 0, 1, 2, ..., 10
sum += i
}
Discard the loop variable: use _ when you only need the iteration count:
for _ in 0..4 {
pulse()
}
Map iteration uses explicit view methods:
for key in settings.keys() { ... }
for value in settings.values() { ... }
for entry in settings.entries() {
log.info(entry.key)
apply(entry.value)
}
while
while not done {
step()
}
while bytes_read < target {
bytes_read += port.read(buf)?
}
loop
An explicit infinite loop. Used for MCU main loops and spin-wait patterns:
fn main() -> never {
loop {
let event = wait_for_event()
handle(event)
}
}
break and continue
for item in items {
if item == 0 {
continue // skip zeros
}
if item > MAX {
break // stop at first oversize item
}
process(item)
}
match
Covered in detail in the Match chapter. Brief overview:
match status {
Status.Ok => run()
Status.Pending => wait()
Status.Failed(e) => log.error(e)
}
match must be exhaustive. Every possible variant must be covered.
Operators and Precedence
Full precedence table from highest to lowest:
| Level | Operators |
|---|---|
| Postfix | (), [], ., ? |
| Prefix | unary -, not, ~ |
| Multiplicative | *, /, % |
| Additive | +, - |
| Shift | <<, >> |
| Bitwise AND | & |
| Bitwise XOR | ^ |
| Bitwise OR | | |
| Relational / membership | <, <=, >, >=, in |
| Equality | ==, != |
| Logical AND | and |
| Logical OR | or |
| Conditional | a if cond else b |
| Block if expression | if cond { ... } else { ... } |
match expression |
Assignment is always a statement, never an expression.
Standalone Blocks
Plain { ... } blocks create a new lexical scope. Useful for defer-scoped cleanup:
{
let guard = mutex.lock()
defer guard.release()
update_shared()
}
// guard released here
do_other_work()
return, break, and continue inside a block still target the enclosing function or loop.
Match
match is Flint’s multi-branch pattern dispatch construct and one of the most powerful tools in the language. It replaces switch, unwraps Option and Result, destructures enums and structs, binds inner values, and guards branches with conditions, all in one place. There is no switch; match is the whole story. It earns its own chapter. Learn it well and you will use it constantly.
match token {
Token.Ident { text } => return Ok(text)
Token.Number { value } => return Ok(str.from_int(value))
Token.Eof => return Err(ParseError.unexpected_eof)
_ => return Err(ParseError.unexpected_token)
}
Rules
matchis exhaustive. Every possible variant must be covered.- Arms are evaluated top to bottom.
- The first matching arm wins.
_is the wildcard catch-all.
Patterns
Literal patterns:
match code {
200 => process_ok(body)
404 => log.warn("not found")
500 => log.error("server error")
_ => log.info("unknown")
}
Enum variant patterns:
match event {
Event.ButtonPress => handle_press()
Event.ButtonRelease => handle_release()
Event.Timeout => handle_timeout()
}
Named-field payload binding:
match token {
Token.Ident { text } => use_ident(text)
Token.Error { message, line } => report(message, line)
Token.Eof => return
}
Positional payload binding:
match message {
Message.Data(kind, len) => process(kind, len)
Message.Ready => start()
}
Wildcard payload (_):
match result {
Ok(_) => log.info("ok")
Err(e) => log.error(e)
}
Guards
Arms can have a when guard for additional conditions:
match rising {
true when level >= BRIGHT_MAX => {
level = BRIGHT_MAX
rising = false
}
true => level += BRIGHT_STEP
false when level <= BRIGHT_STEP => {
level = 0
rising = true
}
_ => level -= BRIGHT_STEP
}
The guard runs only after the pattern matches. A guarded arm does not count toward exhaustiveness; you must still cover the unguarded case.
match as an Expression
When every arm produces a value of the same type, match can be used as an expression:
let next_rising = match rising {
true when level >= BRIGHT_TURN => false
_ => rising
}
if let as a Lightweight Alternative
When you only care about one pattern, if let is often cleaner than match:
// Prefer this for simple single-branch cases
if let Some(port) = config.port {
open(port)
}
// Over this
match config.port {
Some(port) => open(port)
None => ()
}
If you only need a presence check and do not need port itself, use config.port.is_some() or config.port.is_none() instead.
What Is Not Supported
- Nested destructuring (
Point { x: 0, y }patterns inside other patterns) - Range patterns (
1..=10 =>) - Tuple patterns
- Or patterns (
A | B =>)
These may be added in future versions.
Functions and Closures
Named Functions
fn add(a: i32, b: i32) -> i32 {
return a + b
}
fn greet(name: string) {
log.info(name)
}
// Entry point; never returns
fn main() -> never {
loop { ... }
}
- All parameters require explicit types.
- Return type follows
->and may be omitted for functions that return nothing. The compiler defaults to(). returnis explicit. Flint does not use implicit last-expression returns.-> nevermarks functions that do not return (infinite loops,fatal, etc.).
Recursion
Named functions and methods may be directly or mutually recursive:
fn factorial(n: u32) -> u32 {
if n == 0 {
return 1
}
return n * factorial(n - 1)
}
On MCU targets, recursive calls are a compile error by default because stack overflow is unrecoverable. Opt in explicitly:
# flint.toml
allow-recursion = true
or
flint build --allow-recursion
On non-MCU targets, recursion is allowed unconditionally.
Callable Types and Values
Functions are first-class values:
let predicate: (i32) -> bool
let handler: (string, u32) -> Result<(), Error>
Store or pass functions by name:
fn is_even(n: i32) -> bool {
return n % 2 == 0
}
let check: (i32) -> bool = is_even
let evens = numbers.filter(is_even)
Anonymous Functions
let add_one = |x: i32| {
return x + 1
}
let evens = numbers.filter(|n| {
return n % 2 == 0
})
- Closures use
|...|, notfn(...). - Closure return types are inferred unless you write
-> T. - Closure parameter types may be omitted only when the surrounding callable type already determines them.
Anonymous functions can be passed directly to higher-order functions:
let squares = numbers.map(|v| {
return v * v
})
let total = numbers.reduce(0, |acc, v| {
return acc + v
})
Closures
Closures capture values from their enclosing scope:
let threshold = 80
let is_hot = |temp: i32| {
return temp > threshold // captures threshold
}
Closure rules:
- Closures may only be used in non-escaping contexts: local bindings and direct call arguments.
- Closures may not be returned from functions, stored in struct fields, or placed in collections.
- Copy values are copied into the closure.
- Non-copy values are captured as read-only borrowed views.
- Mutable capture of outer locals is not supported.
- Consuming a captured value from inside the closure is not supported.
You can still use mutable values inside a closure. The restriction is on capturing a mutable outer local, not on declaring or mutating locals inside the closure body:
let build_packet = |id: u8| {
let mut bytes: List<u8> = List.new()
bytes.push(0xAA)
bytes.push(id)
return bytes
}
Here bytes is a normal mutable local created inside the closure, so mutating it is fine. What Flint 1.0 does not allow is mutating an outer local by capture.
// Ok: local binding
let multiplier = 3
let triple = |x: i32| {
return x * multiplier
}
// Ok: pass as argument
let big = nums.filter(|x| { return x > threshold })
// Error: cannot store closure in struct
struct Processor {
handler: (i32) -> bool // error: closures cannot be stored in structs
}
// Error: cannot return a capturing closure
fn make_filter(n: i32) -> (i32) -> bool {
return |x| { return x > n } // error: would escape
}
For logic that needs to escape, use named functions:
fn over_threshold(x: i32) -> bool {
return x > THRESHOLD
}
fn make_filter() -> (i32) -> bool {
return over_threshold // ok: named function, no capture
}
Type Aliases for Callables
alias SensorHandler = (i32, i32) -> bool
alias Callback = () -> ()
fn register(handler: SensorHandler) { ... }
Methods
Methods are functions with a self receiver. See Structs and Enums for the full picture.
Structs and Enums
Structs
pub struct Point {
x: i32
y: i32
}
Fields are declared one per line (no commas in declarations). Use pub to export the struct. Struct fields are private by default.
Construction
let p = Point { x: 10, y: 20 }
Field shorthand: when a local binding name matches the field name:
let x = 10
let y = 20
let p = Point { x, y } // equivalent to Point { x: x, y: y }
Field Access and Mutation
let n = p.x // read field
p.x = 15 // error: p is immutable
let mut p2 = Point { x: 10, y: 20 }
p2.x = 15 // ok
Methods
Methods are defined in impl blocks. The receiver mode controls mutability:
impl Point {
// Read-only method
fn distance_from_origin(self) -> i32 {
return self.x * self.x + self.y * self.y
}
// Mutating method
fn translate(mut self, dx: i32, dy: i32) {
self.x = self.x + dx
self.y = self.y + dy
}
// Consuming method
fn into_array(owned self) -> [i32; 2] {
return [self.x, self.y]
}
}
Call a mutable method on a mut binding:
let mut p = Point { x: 0, y: 0 }
p.translate(5, 3) // ok
p.distance_from_origin() // ok, read-only
let q = Point { x: 0, y: 0 }
q.translate(5, 3) // error: q is immutable
Within an impl block, Self is a stable alias for the implementing type. It can be used in method signatures, return types, struct construction, and enum variant paths:
impl Point {
fn origin() -> Self {
return Self { x: 0, y: 0 }
}
fn clone(self) -> Self {
return Self { x: self.x, y: self.y }
}
}
Using Self instead of the concrete type name means that if the type is renamed, all the method signatures remain valid without changes.
Associated Functions (“Static Methods”)
Functions in impl blocks without self are called with Type.function():
impl Point {
fn origin() -> Point {
return Point { x: 0, y: 0 }
}
fn new(x: i32, y: i32) -> Point {
return Point { x, y }
}
}
let p = Point.origin()
let q = Point.new(3, 4)
Enums
Enums are sum types: a value is exactly one of the listed variants.
Unit Variants
enum Direction {
North
South
East
West
}
let d = Direction.North
Named-Field Variants
enum Token {
Ident { text: string }
Number { value: i64 }
Eof
}
let t = Token.Ident { text: "gpio" }
let t = Token.Ident { text } // shorthand
Positional Variants
enum Message {
Ready
Data(u8, u16)
Error(string)
}
let m = Message.Data(1, 512)
Use positional variants for compact, obvious payloads. Prefer named fields when the meaning of each field benefits from a name.
Pattern Matching Enums
match token {
Token.Ident { text } => process_ident(text)
Token.Number { value } => process_number(value)
Token.Eof => return
}
match message {
Message.Ready => start()
Message.Data(kind, len) => handle(kind, len)
Message.Error(msg) => log.error(msg)
}
Positional payload values are accessed through pattern binding, not .0, .1 or similar.
Backed Enums
Unit-only enums may declare an explicit primitive backing type. Variants auto-increment from 0 unless given an explicit value:
enum Direction: u8 {
North // 0
South = 4 // 4
East // 5
West // 6
}
Supported backing types are integers, floats, and char. char-backed enums require an explicit value on every variant. Backed enums may only contain unit variants.
Enum Methods
Enums can also have impl blocks. Self refers to the enum type and can be used to construct variants:
impl Direction {
fn opposite(self) -> Self {
match self {
Direction.North => Self.South
Direction.South => Self.North
Direction.East => Self.West
Direction.West => Self.East
}
}
}
impl Token {
fn eof() -> Self {
return Self.Eof
}
fn ident(text: string) -> Self {
return Self.Ident { text }
}
}
Conventions
- Use structs for data with named fields that carry meaning.
- Use enums for values that can be one of several distinct shapes.
- Prefer named-field enum variants unless the payload is obviously ordered (like
Some(T)orOk(T)). - Associated functions named
new,zero,default, ororiginare conventional constructors. - There is no struct update syntax or spread syntax (
..otheris not supported). - There is no inheritance. Compose structs or use traits for shared behavior.
Traits
Traits define shared interfaces. They are intentionally simple in Flint: method signatures only, used primarily for library and MCU abstraction.
Declaring a Trait
trait Writer {
fn write(mut self, bytes: slice<u8>) -> Result<usize, Error>
fn flush(mut self) -> Result<(), Error>
}
Rules:
- Traits contain method signatures only, with no default implementations.
- Methods may use
selformut self. Selfin a trait method signature names the eventual implementing type.owned selfis not supported on trait methods.- No associated types, associated constants, or trait inheritance.
Implementing a Trait
impl Writer for UartPort {
fn write(mut self, bytes: slice<u8>) -> Result<usize, Error> {
return self.write_bytes(bytes)
}
fn flush(mut self) -> Result<(), Error> {
return self.flush_tx()
}
}
A type may have both inherent methods (impl Type { ... }) and trait implementations (impl Trait for Type { ... }). At most one implementation of a given trait per type is allowed.
Using Traits as Parameters
Trait types may appear in parameter position only:
use core/io
fn write_line(mut out: io.Writer, text: string) -> Result<(), Error> {
out.write(text.bytes())?
return out.flush()
}
The caller passes any concrete type that implements io.Writer:
let mut port = uart.open(0)?
write_line(port, "Hello, Flint!")
let mut buf = io.Buffer.new()
write_line(buf, "buffered output")
Current Limitations
- Trait types are not valid in return position, local variable annotations, struct fields, or collections. Use concrete types there.
- No generic traits or generic
implblocks. - No trait bounds on functions (
fn foo<T: Trait>is not supported). - No
ownedtrait parameters.
// Ok: trait in parameter position
fn print_all(mut out: io.Writer, items: List<string>) -> Result<(), Error> { ... }
// Error: trait in return position
fn make_writer() -> io.Writer { ... }
// Error: trait in struct field
struct Printer {
out: io.Writer // not supported
}
When to Use Traits
Traits are most useful when:
- An official library needs a stable interface that multiple types can implement (e.g.,
io.Writer). - A peripheral driver needs to be interchangeable (e.g., different UART implementations sharing a
Uarttrait). - You want to write one function that works with any type satisfying an interface.
For most application code, concrete types are simpler and preferred. Do not reach for traits to solve problems that named functions or composition solve better.
Built-In Traits
Flint 1.0 has a small set of standard traits that the language and official packages rely on directly.
Clone is the important ownership-related one:
struct Buffer {
data: [u8; 1024]
}
impl Clone for Buffer {
fn clone(self) -> Self {
return Self { data: self.data }
}
}
Cloneis defined bycore/clone.Cloneis in the global prelude, so bareCloneresolves without an import.Cloneis the standard interface for explicit duplication of non-Copyvalues.Copyremains a compiler-known structural property, not a normal trait authors implement.
Standard Traits
Official packages provide canonical trait interfaces. Notable ones:
| Trait | Package | Purpose |
|---|---|---|
Clone | core/clone | Explicit duplication of non-Copy values |
io.Writer | core/io | Writeable sink |
io.Reader | core/io | Readable source |
Flint 1.0 does not expose user-defined Eq, Hash, or Ord traits. Clone is the standard built-in duplication trait, while Copy stays structural and compiler-known.
Modules and Visibility
Modules Are Files
Each .fl file is one module. There are no module declarations in source. The file path determines the module path:
| File | Module path |
|---|---|
src/main.fl | main |
src/drivers/uart.fl | drivers/uart |
src/http/client.fl | http/client |
Importing Modules
Use use to import a module:
use micro/gpio
use std/time
use drivers/uart
The last path segment is the default module name. Access exported items with dot syntax:
let led = gpio.pin(25)
time.sleep_ms(250)
uart.init()?
Aliasing with as:
use drivers/uart as debug_uart
debug_uart.write("hello")?
Use aliasing to resolve name conflicts or shorten long paths.
Visibility
Items are private by default. Use pub to export:
// src/math/bits.fl
pub fn rotl(x: u32, by: u32) -> u32 {
return (x << by) or (x >> (32 - by))
}
fn internal_helper() { ... } // not exported
The importer accesses exported items:
use math/bits
let n = bits.rotl(value, 4)
Private items are only accessible within their own module.
Official Packages
All packages ship with the toolchain. There is no external package manager. See Builtin Package Registry for the full list.
Rules
- Import paths use
/, not.or::. - Relative imports (
./foo,../foo) are not supported. - Selective imports (
use foo.{a, b}) are not supported. - Wildcard imports (
use foo/*) are not supported. - Importing a module does not inject its items into scope; you always access them through the module name.
- Unused imports are dead code. The compiler does not include unused modules in the final binary.
Deprecation
Use /// @deprecated to mark APIs that should not be used in new code:
use std/time
/// @deprecated use=std/time.sleep_ms since=1.2
pub fn delay_ms(ms: u32) {
time.sleep_ms(ms)
}
Using a deprecated item emits a lint diagnostic at the use site pointing at both the call and the deprecated declaration. The since= field records when the deprecation was introduced.
HAL and Micro Library
The micro/* packages are Flint’s cross-chip peripheral APIs. They provide a stable, typed interface to hardware without exposing registers, memory-mapped IO addresses, or vendor-specific SDK calls.
The Four Layers
Flint’s hardware support is organized into four layers:
┌─────────────────────────────────────────────┐
│ Application Code │
├─────────────────────────────────────────────┤
│ micro/* board/* │ ← What you use
├─────────────────────────────────────────────┤
│ hal/* │ ← Target-selected implementation
└─────────────────────────────────────────────┘
| Package | What it is |
|---|---|
hal/* | Target-selected runtime, boot/startup, peripheral implementation. Internal. |
micro/* | Cross-chip peripheral interfaces. This is what you import. |
board/* | Board pin maps, default clocks, convenience constructors. |
| Application | Your code. |
You import micro/* and board/*. You do not import hal/* directly.
Startup
When your main runs, the target hal/* package has already:
- Released resets
- Brought up the clock tree and PLLs
- Configured the watchdog
- Initialized the timer/timebase
- Configured XIP/flash for normal operation
You do not write startup code. You do not call SystemInit(). You write application code.
Builder-Style APIs
Peripherals with many options use builder-style initialization:
use micro/i2c
let mut bus = i2c.bus(0)
.sda(gpio.pin(4))
.scl(gpio.pin(5))
.freq(400_000)
.init()?
Builder methods shape configuration. The terminal init() (or open(), attach()) is the one fallible step. Once initialized, steady-state operations are infallible:
bus.write(0x68, data)? // fallible: I2C can NACK
bus.flush()? // fallible: flush can timeout
led.high() // infallible: GPIO state change always works
led.low() // infallible
Capability Discovery
Optional features are exposed through Option<T>. Use standard control flow to adapt:
use board/pico
use micro/dma
if pico.capabilities().dma_channels > 0 {
if let Some(mut ch) = dma.channel(0) {
ch.start_transfer()?
}
}
Because the selected target and board are known at compile time, constant-folding removes dead branches for unsupported capabilities.
What Is Available
Required:
- GPIO: digital I/O
- UART: serial communication
- ADC: analog-to-digital conversion
- PWM: pulse-width modulation
- I2C and SPI: bus protocols
- DMA: direct memory access
- PIO: RP2040 programmable IO state machines
micro/cpu: CPU hints (WFI, breakpoint, spin-loop)micro/multicore: secondary core launch (RP2040)- USB device (CDC, HID, MSC)
- XIP/SPI flash access
- SD card over SPI
- FAT filesystem
- Interrupt registration
GPIO
micro/gpio provides digital I/O pin control.
use micro/gpio
use std/time
fn main() -> never {
let mut led = gpio.pin(25).into_output()
defer led.low()
loop {
led.high()
time.sleep_ms(250)
led.low()
time.sleep_ms(250)
}
}
Getting a Pin
let pin = gpio.pin(25) // get pin handle by number
Configuring Direction
let mut out = gpio.pin(25).into_output() // digital output
let inp = gpio.pin(14).into_input() // digital input (floating)
let inp = gpio.pin(14).into_input_pullup() // input with pull-up
let inp = gpio.pin(14).into_input_pulldown() // input with pull-down
Output Operations
led.high() // drive high
led.low() // drive low
led.toggle() // flip current state
These are infallible. GPIO state changes always succeed once the pin is configured.
Input Operations
let state = button.read() // true = high, false = low
if button.read() {
handle_press()
}
Common Patterns
LED toggle with defer for safe cleanup:
let mut led = gpio.pin(25).into_output()
defer led.low() // ensure LED off on any exit
loop {
led.toggle()
time.sleep_ms(500)
}
Button polling:
use micro/gpio
use std/time
fn main() -> never {
let mut led = gpio.pin(25).into_output()
let button = gpio.pin(14).into_input_pullup()
loop {
if not button.read() { // active low (pulled up)
led.high()
} else {
led.low()
}
time.sleep_ms(10)
}
}
Board package pin names:
When using a board package, you can use named pins instead of numbers:
use board/pico
use micro/gpio
fn main() -> never {
let mut led = gpio.pin(pico.pin.led).into_output()
loop {
led.toggle()
time.sleep_ms(1000)
}
}
Do and Don’t
// Do: use defer to ensure cleanup
let mut pin = gpio.pin(25).into_output()
defer pin.low()
// Do: call toggle() instead of tracking state manually
led.toggle()
// Avoid: reading pin state immediately after write without delay
// (hardware may need settling time depending on load)
// Avoid: configuring direction multiple times; configure once at startup
UART
micro/uart provides serial communication. UART is the most common way to get debug output off an MCU.
Basic Usage
use board/pico as board
use core/fmt
use std/time
fn main() -> never {
let port = board.default_uart().init()
loop {
fmt.write(port, "hello from Flint\r\n")
time.sleep_ms(1000)
}
}
Initialization
Current executable surface:
use micro/uart
let port = uart.port(0).init()
For board-default debug output, prefer:
use board/pico as board
let port = board.default_uart().init()
Writing
port.write_byte(0x0A)
port.write_char('A')
// Formatted output through core/fmt
use core/fmt
fmt.write(port, "temp={}\r\n", temp_c)
fmt.write(port, "x={} y={}\r\n", x, y)
uart.Port participates in core/io.Writer, and core/fmt.write(...) uses that concrete writer implementation on the current executable path. General runtime dispatch through a trait-typed io.Writer parameter is still separate backend work.
Reading
let has_data = port.has_data()
let byte = port.read_byte()
Board Package Convenience
The board package provides a pre-configured default UART:
use board/pico
fn main() -> never {
let console = pico.default_uart().init()
loop {
fmt.write(console, "tick\r\n")
time.sleep_ms(1000)
}
}
Example: Temperature Over UART
From examples/rp2040/temp_sensor:
use board/pico
use core/fmt
use std/time
fn main() -> never {
let port = pico.default_uart().init()
loop {
let temp_c: f32 = pico.temp_sensor().read_c()
fmt.write(port, "temp={}C\r\n", temp_c)
time.sleep_ms(1000)
}
}
Do and Don’t
// Do: use core/fmt for checked no-heap formatting
fmt.write(port, "value={}\r\n", n)
// Do: use the board-default UART when you want the standard debug port
let port = pico.default_uart().init()
// Avoid: writing docs or examples against unimplemented builder APIs
let port = uart.port(0).init()
// Avoid: using UART for timing-critical code; UART TX has variable latency
ADC
micro/adc provides analog-to-digital conversion.
Basic Usage
use micro/adc
use micro/gpio
fn main() -> never {
let mut channel = adc.channel(0)
.pin(gpio.pin(26))
.init()?
loop {
let raw = channel.read() // raw ADC count
process(raw)
time.sleep_ms(100)
}
}
Initialization
let mut ch = adc.channel(0)
.pin(gpio.pin(26)) // assign GPIO pin to ADC
.init()?
On the RP2040, ADC channels 0-3 correspond to GPIO 26-29. Channel 4 is the internal temperature sensor.
Reading Values
let raw: u16 = ch.read() // raw count (0..=4095 on RP2040)
Raw values are unsigned counts. Convert to voltage or engineering units in your application.
Board Temperature Sensor
The RP2040 has an internal temperature sensor on ADC channel 4. The board package wraps it conveniently:
use board/pico
let sensor = pico.temp_sensor()
let temp_mc = sensor.read_milli_celsius() // milli-Celsius, integer
let temp_c = temp_mc / 1000
From examples/rp2040/temp_sensor:
use board/pico
use core/fmt
use std/time
fn main() -> never {
let port = pico.default_uart().init()
loop {
let temp_c: f32 = pico.temp_sensor().read_c()
fmt.write(port, "temp={}C\r\n", temp_c)
time.sleep_ms(1000)
}
}
Voltage Conversion
The board package may provide conversion helpers. For raw conversions:
// RP2040: 3.3V reference, 12-bit ADC (0..=4095)
fn raw_to_mv(raw: u16) -> u32 {
return u32.from(raw) * 3300 / 4095
}
Do and Don’t
// Do: use board.temp_sensor() for the internal temperature sensor
let sensor = pico.temp_sensor()
let temp_mc = sensor.read_milli_celsius()
// Do: use integer milli-Celsius for portable temperature math on MCUs
// (avoids floating point where possible)
// Avoid: using raw ADC counts directly in application logic
// Convert to physical units near the hardware boundary
// Avoid: reading ADC in a tight loop without delay
// ADC conversion takes time; poll at reasonable intervals
PWM
micro/pwm provides pulse-width modulation for motor control, LED dimming, and tone generation.
Basic Usage
use micro/pwm
use micro/gpio
use std/time
fn main() -> never {
let mut ch = pwm.channel(0)
.pin(gpio.pin(25))
.init()?
// Fade LED up then down
loop {
for duty in 0..=100 {
ch.set_duty_percent(duty)
time.sleep_ms(10)
}
for i in 0..=100 {
ch.set_duty_percent(100 - i)
time.sleep_ms(10)
}
}
}
Initialization
let mut ch = pwm.channel(0)
.pin(gpio.pin(25)) // output pin
.freq(1000) // frequency in Hz (optional, target default if omitted)
.init()?
The terminal init() is fallible. After initialization, duty-cycle updates are infallible.
Setting Duty Cycle
ch.set_duty_percent(50) // 50% duty (0..=100)
ch.set_duty(2048) // raw 16-bit duty value
ch.off() // 0% duty (output low)
ch.full() // 100% duty (output high)
The default full-scale is a 16-bit duty range (0..=65535), matching the standard RP2040 PWM wrap value. set_duty_percent maps 0-100 to that range.
LED Fade Example
From examples/rp2040/fade_led:
use micro/pwm
use micro/gpio
use std/time
const LED_PIN: u32 = 25
fn main() -> never {
let mut led = pwm.channel(0)
.pin(gpio.pin(LED_PIN))
.init()
let mut rising = true
let mut level: u16 = 0
const BRIGHT_STEP: u16 = 512
const BRIGHT_MAX: u16 = 65535
const BRIGHT_TURN: u16 = 65535
loop {
match rising {
true when level >= BRIGHT_TURN => {
level = BRIGHT_MAX
rising = false
}
true => level += BRIGHT_STEP
false when level <= BRIGHT_STEP => {
level = 0
rising = true
}
_ => level -= BRIGHT_STEP
}
led.set_duty(level)
time.sleep_ms(10)
}
}
Do and Don’t
// Do: use set_duty_percent for simple 0-100% control
ch.set_duty_percent(75)
// Do: use off() and full() for clean extremes
ch.off()
ch.full()
// Avoid: computing raw duty values with magic numbers
// ch.set_duty(49151) // what is this?
// Prefer:
ch.set_duty_percent(75)
// Avoid: reinitializing PWM in a loop; configure once at startup
I2C and SPI
I2C
micro/i2c provides I2C bus master support for sensors, displays, and other peripherals.
Initialization
use micro/i2c
use micro/gpio
let mut bus = i2c.bus(0)
.sda(gpio.pin(4))
.scl(gpio.pin(5))
.freq(400_000) // 400 kHz fast mode
.init()?
Bus 0 and Bus 1 correspond to the hardware I2C peripherals. init() is the fallible step.
Reading and Writing
// Write to device address 0x48
bus.write(0x48, b"\x00")? // write register address
// Read 2 bytes from device
let mut buf: [u8; 2] = [0; 2]
bus.read(0x48, buf)? // read into buf
// Write then read (common for register reads)
bus.write_read(0x68, b"\x3B", buf)?
I2C transactions are fallible; the device may NACK or the bus may not acknowledge.
Common Pattern: Sensor Register Read
use micro/i2c
use micro/gpio
use std/time
fn read_temperature(mut bus: i2c.Bus) -> Result<i16, Error> {
// Write register address
bus.write(0x48, b"\x00")?
// Read 2 bytes
let mut buf: [u8; 2] = [0; 2]
bus.read(0x48, buf)?
// Combine bytes (big-endian)
let raw = i16.from(u16.from(buf[0]) << 8 | u16.from(buf[1]))
return Ok(raw >> 4) // TMP102 12-bit temperature
}
SPI
micro/spi provides SPI bus master support for displays, flash chips, SD cards, and RF modules.
Initialization
use micro/spi
use micro/gpio
let mut bus = spi.bus(0)
.mosi(gpio.pin(19))
.miso(gpio.pin(16))
.sck(gpio.pin(18))
.freq(10_000_000) // 10 MHz
.mode(spi.Mode.Mode0)
.init()?
// Chip select is managed manually or through a device handle
let mut cs = gpio.pin(17).into_output()
cs.high() // deselect by default
Reading and Writing
// Transfer: write and read simultaneously
let mut rx: [u8; 4] = [0; 4]
let tx: [u8; 4] = [0x03, 0x00, 0x00, 0x00]
cs.low() // select device
bus.transfer(tx, rx)? // send tx, receive into rx
cs.high() // deselect device
// Write only
cs.low()
bus.write(b"\x02\x00\x00\x00")?
cs.high()
// Read only (sends zeros)
cs.low()
bus.read(buf)?
cs.high()
Do and Don’t
// Do: handle I2C/SPI errors; bus transactions can fail
bus.write(addr, data)?
// Do: use write_read for register reads in one operation
bus.write_read(addr, &[reg], result_buf)?
// Do: drive CS manually and use defer for safe deselect
cs.low()
defer cs.high()
bus.transfer(tx, rx)?
// Avoid: leaving CS asserted if an error occurs
// The defer pattern above ensures cs.high() even on error paths
// Avoid: using I2C/SPI in ISRs; use DMA or buffered transfers instead
DMA
micro/dma provides direct memory access for high-throughput data transfers without CPU involvement.
DMA lets you transfer data between peripherals and memory (or between memory regions) while the CPU does other work.
Basic Usage
use micro/dma
if let Some(mut ch) = dma.channel(0) {
let src: [u8; 256] = [0xAA; 256]
let mut dst: [u8; 256] = [0; 256]
ch.mem_to_mem(src, dst)?
ch.wait()? // block until transfer completes
}
Getting a Channel
DMA channels are optional; the hardware may have a fixed number, and some may already be in use:
if let Some(mut ch) = dma.channel(0) {
// use channel
} else {
// no channel 0 available; fall back to CPU transfer
}
On RP2040, there are 12 DMA channels (0-11).
Transfer Types
// Memory to memory
ch.mem_to_mem(src, dst)?
// Peripheral to memory (e.g., SPI RX FIFO to buffer)
ch.periph_to_mem(periph_dreq, dst)?
// Memory to peripheral (e.g., buffer to SPI TX FIFO)
ch.mem_to_periph(src, periph_dreq)?
Waiting for Completion
ch.start_transfer()? // start and return immediately
// ... CPU does other work ...
ch.wait()? // block until complete
Or chain transfers for continuous operation.
Capability Check
use board/pico
let caps = pico.capabilities()
if caps.dma_channels > 0 {
// DMA is available
}
Do and Don’t
// Do: check channel availability with if let Some
if let Some(mut ch) = dma.channel(0) {
ch.mem_to_mem(src, dst)?
}
// Do: use ch.wait() before accessing the destination buffer
ch.start_transfer()?
do_other_work()
ch.wait()? // ensure completion before reading dst
// Avoid: accessing the destination buffer while DMA is still running
// This is a race condition that produces corrupted data
// Avoid: using DMA for very small transfers (< ~32 bytes)
// CPU overhead of setup often exceeds the transfer time
PIO
micro/pio provides access to the RP2040’s Programmable IO (PIO) state machines. PIO is a small, fixed-instruction-set processor for bit-banging custom protocols at high speed without CPU involvement.
Overview
PIO is unique to the RP2040 (and RP2350). It lets you implement custom protocols (WS2812 LEDs, stepper motor control, custom serial protocols) at deterministic speeds without relying on the CPU.
Flint compiles PIO programs from .pio files during the build, alongside your Flint source.
PIO Source Files
PIO programs are written in PIO assembly and live anywhere under src/ with a .pio extension:
src/
main.fl
drivers/
ws2812.pio
stepper.pio
Flint assembles these during the build. PIO errors (syntax, constraint violations) appear as normal Flint diagnostics pointing at the .pio file.
Loading a PIO Program
use micro/pio
use micro/gpio
let program = pio.program_file("drivers/ws2812.pio")?
let loaded = pio.block(0).load(program)?
program_file(...) takes a compile-time string literal path under src/. The compiler resolves and assembles the PIO file during the build.
Configuring a State Machine
let mut sm = pio.block(0)
.state_machine(0)
.program(loaded)
.set_pin(gpio.pin(25))
.set_pin_count(1)
.clock_div(256) // slow clock for visible blink
.init()?
sm.start()
Builder methods configure pin routing, clock divisor, FIFO behavior, and shift settings. init() is the fallible step. start() and stop() are infallible once initialized.
Inline PIO Programs
For small examples or tests, you can write PIO inline as a raw string:
use micro/pio
let program = pio.program("""
.program blink
set pins, 1 [31]
set pins, 0 [31]
""")?
The string is assembled at compile time, not runtime.
Writing to the TX FIFO
sm.put(0xFF_FF_FF_00u32)? // write word to TX FIFO
Reading from the RX FIFO:
let word = sm.get()? // blocking read from RX FIFO
Example: WS2812 LED Strip
use micro/gpio
use micro/pio
use std/time
fn main() -> never {
let prog = pio.program_file("drivers/ws2812.pio")?
let loaded = pio.block(0).load(prog)?
let mut sm = pio.block(0)
.state_machine(0)
.program(loaded)
.out_pin(gpio.pin(0))
.out_pin_count(1)
.clock_div(10)
.init()?
sm.start()
loop {
// GRB format for WS2812
sm.put(0x00_FF_00_00u32)? // green
time.sleep_ms(500)
sm.put(0xFF_00_00_00u32)? // red
time.sleep_ms(500)
}
}
Do and Don’t
// Do: use pio.program_file() for production code (compile-time path checking)
let prog = pio.program_file("drivers/ws2812.pio")?
// Do: use pio.program() with raw strings for quick experiments
let prog = pio.program("""...""")?
// Avoid: runtime-assembled PIO programs; not supported
// Avoid: assuming PIO block or state machine is available
// load() is fallible because instruction memory has limited capacity
// Avoid: tight-looping on sm.put() without backpressure
// The TX FIFO has limited depth; handle the Result from put()
Standard Library Overview
Flint’s standard library is organized into four layers:
| Prefix | Purpose |
|---|---|
core/* | Always available. No heap requirement. Works on bare-metal. |
std/* | Higher-level. May require heap. Cross-target where the concept applies. |
micro/* | MCU peripheral APIs. See HAL and Micro Library. |
board/* | Board-specific pin maps, defaults, and convenience constructors. |
The split matters for MCU targets: core/* packages never drag in allocator support. If your program only uses core/* packages, it has no heap dependency.
What Lives Where
core/*
| Package | Contents |
|---|---|
core/clone | Standard Clone trait for explicit duplication of non-Copy values |
core/fmt | No-heap, writer-first formatting. fmt.write(out, "val={}", n) |
core/io | Reader and Writer traits |
core/sync | OnceCell<T>, Lazy<T>, atomics, CAS helpers, critical sections |
core/error | The Error type (available in prologue without import) |
core/cmp | Ordering enum and compare() helper for custom sort APIs |
core/embed | Compile-time asset embedding: embed.bytes(...), embed.string(...) |
std/*
| Package | Contents |
|---|---|
std/time | Duration, Instant, sleep_ms, sleep_us (cross-target) |
std/fmt | Owned-string formatting: fmt.format("val={}", n) -> Result<string, Error> |
std/sync | Channels, Mutex<T>, Semaphore |
std/text | Portable text views, text.Split, and text.Buffer[N] |
std/io | Buffered IO, file handles (host targets) |
std/fs | Filesystem APIs (host targets / SD card) |
std/embed | Higher-level asset loading on top of core/embed |
The Prologue
These names are always in scope without any use:
- Types:
bool,u8throughu64,i8throughi64,usize,isize,f32,char,string,never,() - Generic types:
Option<T>,Result<T, E>,List<T>,Deque<T>,Map<K, V>,Set<T>,slice<T>,mut slice<T> - Variants:
Some,None,Ok,Err - Literals:
true,false - Functions:
assert,fatal,fatal_error,unreachable - Types:
Error - Traits:
Clone
Everything else requires a use.
Heap Policy
Heap-backed types (List<T>, Map<K, V>, Set<T>, string, etc.) only link allocator support when actually used. Programs that stay in core/* and use only fixed arrays, slices, and stack values pay no heap cost.
When heap support is needed, the compiler links one official allocator for the selected target. On MCUs, the heap is a fixed RAM region configured in the target profile.
Allocation failure returns Err(...), not a silent crash. APIs that may allocate return Result.
Text Today
Flint’s portable text model is already split cleanly:
- built-in
stringfor immutable UTF-8 text views - built-in
charfor Unicode scalar values std/textfor split results and fixed-capacity mutable text viatext.Buffer[N]
See Text, String, and Char for the current shipped surface.
Builtin Package Registry
All packages ship with the toolchain. There is no external package manager.
core/*
No-alloc. Works on bare-metal. Always safe to use on MCU targets.
See core/* for full API details.
| Package | Purpose |
|---|---|
core/clone | Standard Clone trait for explicit duplication |
core/fmt | No-heap, writer-first formatting |
core/io | Reader and Writer traits |
core/sync | Atomics, OnceCell<T>, Lazy<T>, critical sections |
core/cmp | Ordering enum and compare() for custom sort APIs |
core/embed | Compile-time asset embedding |
std/*
Higher-level. May require heap. Cross-target where the concept applies.
See std/* for full API details.
| Package | Purpose |
|---|---|
std/time | Duration, Instant, sleep_ms, sleep_us |
std/fmt | Owned-string formatting |
std/sync | Channels, Mutex<T>, Semaphore |
std/text | Portable text views, text.Split, and text.Buffer[N] |
std/io | Buffered IO, file handles (host targets) |
std/fs | Filesystem APIs (host targets / SD card) |
micro/*
MCU peripheral APIs. See HAL and Micro Library for full API details.
| Package | Purpose |
|---|---|
micro/gpio | GPIO peripheral API |
micro/uart | UART peripheral API |
micro/adc | ADC peripheral API |
micro/pwm | PWM peripheral API |
micro/i2c | I2C bus API |
micro/spi | SPI bus API |
micro/pio | PIO state machine API |
micro/dma | DMA controller API |
board/*
Board-specific pin maps, defaults, and convenience constructors. Available packages depend on the selected board. See Targets and Boards.
| Package | Purpose |
|---|---|
board/pico | Raspberry Pi Pico (RP2040) |
board/pico2 | Raspberry Pi Pico 2 (RP2350) |
core/*
core/* packages are always available and never require heap allocation. They form the baseline for bare-metal and MCU code.
core/clone: Explicit Duplication
core/clone defines the standard Clone trait used for explicit duplication of non-Copy values.
trait Clone {
fn clone(self) -> Self
}
Clone is also part of the global prelude, so you normally write impl Clone for Buffer without importing core/clone.
core/fmt: No-Heap Formatting
Format values without allocating:
use board/pico as board
use core/fmt
fn main() -> never {
let uart = board.default_uart().init()
let temp_c: f32 = board.temp_sensor().read_c()
fmt.write(uart, "temp={}C\r\n", temp_c)
loop {}
}
The compiler recognizes fmt.write(...) as a checked formatting call and validates the compile-time format string, including placeholder count.
Placeholder syntax:
{}: format one argument{{: literal{}}: literal}
Current executable path:
- sink: a concrete output handle whose type implements
core/io.Writer, such as a UART port - arguments: unit,
bool, integers,f32,char, string literals, and string locals
fmt.write(uart, "ok: {}", true)
fmt.write(uart, "n: {}", 42u32)
core/io: Reader and Writer Traits
use core/io
trait Writer {
fn write(mut self, bytes: slice<u8>) -> Result<usize, Error>
fn flush(mut self) -> Result<(), Error>
}
trait Reader {
fn read(mut self, buf: mut slice<u8>) -> Result<usize, Error>
}
These traits define the long-term sink/source abstraction surface:
fn dump_state(mut out: io.Writer) -> Result<(), Error> {
fmt.write(out, "state: running\r\n")?
return out.flush()
}
The current executable backend slice does not yet execute general runtime trait-dispatch through io.Writer and io.Reader parameters. Today, core/fmt.write(...) works by taking a concrete sink whose type implements core/io.Writer, then lowering the concrete sink path selected by that implementation.
core/sync: Atomics and One-Time Init
use core/sync
// Single-assignment global (safe for firmware globals)
static DEVICE_ID: sync.OnceCell<u32> = sync.OnceCell.new()
fn setup() -> () {
DEVICE_ID.set(read_chip_id())
}
fn get_id() -> u32 {
return DEVICE_ID.get().unwrap_or(0)
}
// Lazy one-time initialization
static CONFIG: sync.Lazy<Config> = sync.Lazy.new(|| -> Config {
return Config.load_from_flash()
})
fn use_config() -> () {
let c = CONFIG.get() // initializes on first access
apply(c)
}
Atomic operations for lock-free code:
use core/sync
static COUNTER: sync.AtomicU32 = sync.AtomicU32.new(0)
fn increment() -> () {
COUNTER.fetch_add(1, sync.Ordering.Relaxed)
}
fn get_count() -> u32 {
return COUNTER.load(sync.Ordering.Acquire)
}
core/cmp: Comparison
use core/cmp
let order = cmp.compare(a, b) // Ordering.Less, Ordering.Equal, Ordering.Greater
let mut items = [3, 1, 4, 1, 5, 9]
items.sort_by(|a, b| {
return cmp.compare(a, b)
})
core/embed: Compile-Time Assets
use core/embed
const FONT_DATA: slice<u8> = embed.bytes("assets/font.bin")
const INDEX_PAGE: string = embed.string("assets/index.html")
The path argument must be a string literal. The file is read at compile time and embedded in read-only image data. Invalid paths and invalid UTF-8 for embed.string are compile errors.
Dead code elimination removes unused embedded assets.
core/error
The Error type is from core/error and is always in scope without an import. You do not need to write use core/error.
fn validate(input: string) -> Result<(), Error> {
if input.is_empty() {
return Err("input cannot be empty")
}
return Ok()
}
std/*
std/* packages are the higher-level, cross-target library. They may depend on heap allocation or target-specific runtime services.
std/text: Portable Text
std/text is the portable text package that complements the built-in string and char types.
Today it provides:
- read-only string behavior through the
stringmethod surface - allocation-free split results with
text.Split - fixed-capacity mutable text with
text.Buffer[N]
use std/text as text
let text_value = " key?=value\r\n"
let split = text_value.trim().split_once("?=")
let mut buffer = text.buffer(32)
let first = buffer.push_str(split.before())
let middle = buffer.push('=')
let last = buffer.push_str(split.after())
See Text for the actual current std/text API surface.
std/io: Buffered IO
use std/io
let mut buf_writer = io.BufferedWriter.new(uart_port, 256)?
buf_writer.write(data)?
buf_writer.flush()? // flush accumulated bytes to underlying writer
std/fs: Filesystem
Available on host targets and MCU targets with SD card / FAT support:
use std/fs
let text = fs.read_text("config.txt")?
let bytes = fs.read_bytes("firmware.bin")?
let mut file = fs.open("log.txt")?
defer file.close()
file.write(b"log entry\n")?
Collections
List<T>, Deque<T>, Map<K, V>, and Set<T> are always available without an explicit use. They are compiler-known generic types:
let mut items: List<u32> = List.new()
items.push(1)? // fallible: allocation can fail
items.push(2)?
items.push(3)?
let first = items[0] // infallible: after allocation, indexing does not fail
let len = items.len()
for item in items {
process(item)
}
let mut config: Map<string, string> = Map.new()
config.insert("baud", "115200")?
config.insert("target", "rp2040")?
if let Some(baud) = config.get("baud") {
log.info(baud)
}
for entry in config.entries() {
log.info(entry.key)
log.info(entry.value)
}
Collection operations that allocate (push, insert, extend) return Result. Operations that do not allocate (len, get, indexing after allocation) are infallible.
std/sync: Channels and Mutexes
use std/sync
// Create a channel with capacity 16
let ends = sync.channel<u32>(16)?
let tx = ends.tx
let rx = ends.rx
// Send (blocking)
tx.send(42)?
// Non-blocking send
tx.try_send(42)?
// Receive (blocking)
let value = rx.recv()?
// Non-blocking receive
if let Ok(value) = rx.try_recv() {
process(value)
}
Mutex:
use std/sync
let counter: sync.Mutex<u32> = sync.Mutex.new(0)?
{
let mut guard = counter.lock()?
defer guard.release()
guard.value += 1
}
ISR rules: ISR code must not call blocking recv, send, lock, or wait operations. Use try_send and try_recv in interrupt contexts.
std/embed
Higher-level wrappers over core/embed for structured asset loading. Details depend on the specific asset type.
Text
std/text is Flint’s portable text package.
It complements the built-in string and char types:
stringhandles read-only and view-returning text operationscharexposes Unicode scalar values plus small UTF-8 helpersstd/textprovides split results and fixed-capacity mutable text throughtext.Buffer[N]
See String and Char for the built-in type surface.
Read-Only Text Surface
Most day-to-day string work reads naturally as methods on string:
let text = " key?=value\r\n"
let trimmed = text.trim()
let found = text.contains("?=")
let first = text.find("?=")
let split = text.split_once("?=")
Those methods are backed by the portable std/text layer and the minimal built-in string substrate. The important rule is that they remain allocation-free in the portable baseline.
text.Split
split_once and split_once_last return text.Split, a small allocation-free view result:
let text = "key?=value?=tail"
let split = text.split_once("?=")
if split.found() {
let key = split.before()
let value = split.after()
}
Current methods:
found()before()after()
If no delimiter matches, before() returns the full input and after() returns an empty string.
text.Buffer[N]
text.Buffer[N] is the current mutable text type for portable code.
It uses explicit fixed capacity and preserves UTF-8 validity instead of hiding allocation behind string methods.
use std/text as text
alias NameBuffer = text.Buffer[32]
fn build_name() -> Result<NameBuffer, text.BufferError> {
let mut buffer = text.buffer(32)
buffer.push_str("pico")?
buffer.push('-')?
buffer.push_str("uart")?
return Ok(buffer)
}
The constructor:
let mut buffer = text.buffer(32)
creates an empty text.Buffer[32].
Core Buffer Methods
Current shipped core methods:
capacity()len_bytes()is_empty()clear()as_string()
Example:
let mut buffer = text.buffer(16)
let cap = buffer.capacity()
let empty = buffer.is_empty()
let pushed = buffer.push_str("hi")
let view = buffer.as_string()
buffer.clear()
as_string() returns an immutable string view over the live buffer contents.
Mutation Methods
Current shipped mutation methods:
push(ch: char) -> Result<(), text.BufferError>push_ascii(byte: u8) -> Result<(), text.BufferError>push_str(text: string) -> Result<(), text.BufferError>insert(index: usize, text: string) -> Result<(), text.BufferError>remove(index: usize) -> Result<char, text.BufferError>replace(old: string, new: string) -> Result<u32, text.BufferError>
Example:
fn rewrite_name() -> Result<u32, text.BufferError> {
let mut buffer = text.buffer(32)
buffer.push_str("pico-led")?
buffer.insert(4, "-board")?
let removed = buffer.remove(4)?
let replaced = buffer.replace("led", "uart")?
return Ok(replaced)
}
Notes:
push,push_ascii,push_str,insert, andreplacepreserve UTF-8 validitypush_ascii(byte)is the explicit ASCII bridge for raw byte-stream input such as UART terminal bytesremove(index)removes one UTF-8 scalar value at a validated byte boundary and returns thatcharreplace(old, new)returns the number of non-overlapping replacementsreplace(old, new)rejects an empty pattern
text.BufferError
Current shipped error cases:
FullInvalidIndexEmptyPatternNonAscii
These cover the current fixed-capacity and index-validation failure modes.
Current Backend Status
The current executable backend supports:
- local
text.Buffer[N]construction and mutation - local copies
- direct by-value
text.Buffer[N]parameters and returns
Current limitation:
- by-value buffer arguments still need to come from local
text.Buffer[N]bindings at the call site on the current backend
Design Direction
The portable text baseline is intentionally conservative:
- UTF-8 everywhere
- no hidden allocation for read-only string operations
- fixed-capacity mutation through
text.Buffer[N] - explicit ASCII naming for ASCII-only classification or future transforms
Future richer Unicode behavior belongs in a separate std/unicode layer rather than changing the meaning of the core text APIs across targets.
Time
std/time is the cross-target time API. The same source code works on MCU targets and future host targets.
Sleeping
use std/time
time.sleep_ms(1000) // sleep 1 second
time.sleep_us(500) // sleep 500 microseconds
These are blocking calls. On MCU targets, they lower through the hardware timer. The CPU is not spinning; most MCU implementations use WFE/WFI or timer interrupts internally.
Measuring Time
use std/time
let start = time.now() // Instant
do_work()
let elapsed = start.elapsed() // Duration
Duration
use std/time
let d1 = time.Duration.from_millis(250)
let d2 = time.Duration.from_micros(1500)
let d3 = time.Duration.from_secs(5)
let ms = d1.as_millis() // u64
let us = d1.as_micros() // u64
Timeout Pattern
use std/time
let deadline = time.now().add(time.Duration.from_millis(500))
while not sensor.data_ready() {
if time.now().after(deadline) {
return Err("sensor timeout")
}
time.sleep_ms(1)
}
Notes on MCU vs. Host
std/time provides the same API everywhere:
- On MCU targets,
time.now()uses the hardware timer. On RP2040, this is the 64-bit timer running from a 1 MHz reference. - On host targets (future),
time.now()uses OS monotonic clock facilities. micro/timeris reserved for direct hardware timer/alarm/counter control. Usestd/timefor ordinary sleep and measurement.
Do and Don’t
// Do: use std/time for ordinary sleep and time measurement
time.sleep_ms(100)
let start = time.now()
// Do: use Duration values to express time quantities clearly
let timeout = time.Duration.from_millis(500)
// Avoid: busy-loop delays; use time.sleep_ms/us instead
// while counter < 1_000_000 { counter += 1 } // imprecise and burns CPU
// Avoid: micro/timer for ordinary delays; that is for hardware timer control
Formatting
Flint provides two formatting packages for different scenarios:
| Package | Allocation | Return type | When to use |
|---|---|---|---|
core/fmt | None | Result<(), Error> source API | No-heap checked formatting for sink-style output |
std/fmt | May allocate | Result<string, Error> | Planned owned-string formatting surface |
Current Status
core/fmt.write is available today, but the current executable backend slice is narrower than the long-term library design:
- The compiler recognizes
fmt.write(...)as a checked formatting call. - The format string must be a compile-time string literal.
- The compiler handles the variable-argument source shape specially so ordinary variadics are not required in the language.
- The first argument must be a concrete sink type that implements
core/io.Writer. - The current executable backend slice resolves the concrete
Writerimplementation for that sink and lowers the supported sink path. std/fmt.format(...)is reserved in the language and docs, but it is not implemented end to end yet.- General runtime trait-dispatch through a trait-typed parameter such as
mut out: io.Writeris still separate backend work.
core/fmt.write: No-Heap Checked Formatting
use board/pico as board
use core/fmt
fn main() -> never {
let uart = board.default_uart().init()
let value: u32 = 42
fmt.write(uart, "value={}\r\n", value)
loop {}
}
Today, the first argument should be a concrete sink handle such as a UART port whose type implements core/io.Writer. The format string must be a compile-time string literal, and the compiler validates it at build time.
core/fmt.write is the preferred formatting path for MCU code because it never allocates.
std/fmt.format: Owned String Formatting
use std/fmt
let msg = std_fmt.format("rx={} tx={}", rx_count, tx_count)?
send_log(msg)
This is the intended source API because building a string may allocate. It is not implemented end to end yet on the current toolchain.
Tip: Use
use std/fmt as std_fmtto avoid shadowingcore/fmtif you import both.
Format String Syntax
Format strings use a minimal placeholder language:
| Placeholder | Meaning |
|---|---|
{} | Format next argument in default style |
{{ | Literal { |
}} | Literal } |
Placeholder count must match argument count exactly; a mismatch is a compile error:
fmt.write(out, "x={} y={}", x, y) // ok
fmt.write(out, "x={}", x, y) // error: too many arguments
fmt.write(out, "x={} y={}", x) // error: too few arguments
Supported Argument Types
The current checked-format type checker accepts:
- Unit
() - Integers (
u8,u16,u32,usize,i8,i16,i32,isize) bool: formats astrueorfalsef32: decimal representationchar: the character itselfstring: the string valueError: the error message
On the current executable path, the implemented argument slice is unit, booleans, integers, f32, char, string literals, and string locals. User-defined display/debug traits are not supported yet. For custom types, format the fields explicitly.
Backend Notes
Current backends may still use compiler-aware lowering behind fmt.write(...), but the sink contract is now the concrete core/io.Writer implementation selected for the first argument. Future targets may use a different backend strategy, including compact encoded records similar to defmt, behind the same source API.
Example: Status Line Over UART
use board/pico as board
use core/fmt
use std/time
fn main() -> never {
let port = board.default_uart().init()
let mut tick: u32 = 0
loop {
fmt.write(port, "[{}] alive\r\n", tick)
tick += 1
time.sleep_ms(1000)
}
}
Synchronization
Flint’s synchronization model: concurrency is a library feature, not a language syntax feature. There is no async/await, no channel operators, no goroutines. Blocking and non-blocking operations are ordinary function calls.
core/sync: Low-Level Primitives
Atomics
use core/sync
static COUNTER: sync.AtomicU32 = sync.AtomicU32.new(0)
fn on_event() -> () {
COUNTER.fetch_add(1, sync.Ordering.Relaxed)
}
fn read_count() -> u32 {
return COUNTER.load(sync.Ordering.Acquire)
}
Available: AtomicU8, AtomicU16, AtomicU32, AtomicBool.
Ordering values: Relaxed, Acquire, Release, AcqRel, SeqCst.
OnceCell<T>: Single-Assignment Global
use core/sync
static DEVICE_ID: sync.OnceCell<u32> = sync.OnceCell.new()
fn init() -> () {
DEVICE_ID.set(read_chip_id())
}
fn get_id() -> u32 {
return DEVICE_ID.get().unwrap_or(0)
}
set() is only valid once. Subsequent calls are ignored.
Lazy<T>: On-Demand Initialization
use core/sync
static CALIBRATION: sync.Lazy<Calibration> = sync.Lazy.new(|| -> Calibration {
return Calibration.load_from_flash()
})
fn use_calibration() -> () {
let cal = CALIBRATION.get() // initialized on first call
apply(cal)
}
Critical Sections
use core/sync
let guard = sync.CriticalSection.enter()
defer guard.exit()
// interrupts disabled, shared state is safe to access
update_shared_flag()
std/sync: Higher-Level Synchronization
Channels
use std/sync
let ends = sync.channel<u32>(16)? // capacity 16
let tx = ends.tx
let rx = ends.rx
// Producer
tx.send(42)? // blocking send
tx.try_send(42)? // non-blocking, returns Err if full
// Consumer
let value = rx.recv()? // blocking receive
if let Ok(value) = rx.try_recv() {
process(value)
}
// Shutdown
tx.close()
Mutex
use std/sync
let shared: sync.Mutex<List<u8>> = sync.Mutex.new(List.new())?
fn push_byte(byte: u8) -> Result<(), Error> {
let mut guard = shared.lock()?
defer guard.release()
guard.value.push(byte)?
return Ok()
}
Semaphore
use std/sync
let sem = sync.Semaphore.new(0)? // initial count 0
// Signal from ISR or other core
sem.signal()
// Wait in main loop
sem.wait()?
Multicore (RP2040)
use micro/multicore
fn core1_main() -> never {
loop {
// secondary core work
}
}
fn main() -> never {
multicore.launch(1, core1_main)?
loop {
// primary core work
}
}
ISR Rules
ISR code must not block. In an interrupt handler:
- Use atomics and
OnceCell/Lazyreads: ok. - Use
try_sendandtry_recvon channels: ok. - Use
sync.CriticalSectioncarefully: ok if brief. - Never call
recv,send,lock, orwait; these block and are not safe in ISRs.
Target Support and Tiers
Flint uses a tiered support model for targets and boards. The tier tells you how much you can rely on a given target for production use.
Tier Overview
| Tier | Meaning |
|---|---|
| Tier 1 | Battle-tested. Compiler, HAL, and board packages are complete. The end-to-end path is validated on real hardware. |
| Tier 2 | Code generation is solid, but HAL or board packages may not be fully fleshed out. Usable, but not as “batteries included” as Tier 1. |
| Tier 3 | Experimental or unproven. Codegen issues may be present. Use for exploration only. |
Target Summary
| Target | CPU | Tier | Output | Status |
|---|---|---|---|---|
rp2040 | RP2040 / Cortex-M0+ | Tier 1 | ELF, BIN, UF2 | Active development, primary target |
rp2350 | RP2350 / Cortex-M33 | Tier 1 | ELF, BIN, UF2 | Planned |
Planned targets (Tier 3):
| Target | CPU | Notes |
|---|---|---|
stm32f4 | STM32F4 / Cortex-M4 | Planned |
samd21 | SAMD21 / Cortex-M0+ | Planned |
samd51 | SAMD51 / Cortex-M4 | Planned |
nrf52 | nRF52 / Cortex-M4 | Planned |
rp2350-riscv | RP2350 / RISC-V | Planned |
Linux x86_64 | x86-64 | Planned, once MCU pipeline matures |
Linux aarch64 | ARMv8 | Planned |
Linux riscv64 | RISC-V 64 | Planned |
Board Summary
| Board | Target | Package | Tier |
|---|---|---|---|
| Raspberry Pi Pico | rp2040 | board/pico | Tier 1 |
| Raspberry Pi Pico 2 | rp2350 | board/pico2 | Tier 1 (planned) |
Specifying a Target
In flint.toml:
target = "rp2040"
board = "pico"
Or on the command line:
flint build --target rp2040
The board is optional when the target has a default board profile. When specified, the board package adds pin names, default clocks, and convenience constructors on top of the chip HAL.
Tier System
The tier system communicates how much confidence you should have in a given target for real projects.
Tier 1: Production Ready
A Tier 1 target means:
- Compiler: Code generation is complete and tested for the target architecture.
- HAL: The
hal/*layer brings up the chip correctly: resets, clocks, XIP, watchdog, timers. - Micro packages:
micro/gpio,micro/uart,micro/adc,micro/pwm,micro/i2c,micro/spi,micro/dma, andmicro/piowork correctly. - Board packages: At least one board package is complete with named pins, default clocks, and working convenience APIs.
- End-to-end validation: Programs have been tested on real hardware, not just in simulation.
- Binary output: ELF, BIN, and UF2 output is tested and correct.
If you are building firmware for production or want to learn Flint, pick a Tier 1 target.
Current Tier 1 targets:
rp2040withboard/picorp2350withboard/pico2(planned)
Tier 2: Usable but Incomplete
A Tier 2 target means:
- Code generation is solid and produces correct binaries.
- Basic HAL bring-up works.
- Some
micro/*packages may be missing or incomplete; not all peripherals are implemented. - Board packages may cover only basic pin maps without full convenience APIs.
- Usable for your own projects if you are willing to work around gaps.
Tier 2 targets are on a path to Tier 1. Contributions to complete HAL and board packages are welcome.
Tier 3: Experimental
A Tier 3 target means:
- Code generation may have known issues or gaps.
- HAL bring-up may be partial or untested on real hardware.
- No guarantees of correctness.
- Use for exploration or porting work only.
Do not use Tier 3 targets for production firmware.
How Targets Move Up
A target moves from Tier 3 to Tier 2 when:
- Code generation produces correct binaries.
- Basic HAL bring-up works.
A target moves from Tier 2 to Tier 1 when:
- The full
micro/*surface is implemented. - At least one board package is complete.
- End-to-end tests pass on real hardware.
Declaring Your Target
In flint.toml:
target = "rp2040" # Tier 1
board = "pico" # Tier 1
The tier is a documentation label. The compiler does not refuse to build for lower-tier targets, but it may emit warnings for known limitations.
RP2040 (Tier 1)
The RP2040 is Flint’s primary target. It is the chip on the Raspberry Pi Pico.
Chip Summary
| Property | Value |
|---|---|
| Chip | RP2040 |
| CPU | Dual-core ARM Cortex-M0+ |
| Architecture | ARMv6-M / Thumb baseline |
| Flash | 2 MB (Pico), via external SPI flash |
| RAM | 264 KB SRAM |
| Output formats | ELF, BIN, UF2 |
| Board | board/pico |
Getting Started
# flint.toml
name = "my_project"
version = "0.1.0"
edition = "2026"
target = "rp2040"
board = "pico"
flint build --output-format uf2
Copy build/my_project.uf2 to the Pico in BOOTSEL mode.
Supported Peripherals
All of the following are available on rp2040:
| Package | Peripheral |
|---|---|
micro/gpio | Digital I/O: 30 GPIO pins |
micro/uart | UART0, UART1 |
micro/i2c | I2C0, I2C1 |
micro/spi | SPI0, SPI1 |
micro/adc | 4 external ADC channels (GPIO26-29), 1 internal (temp sensor) |
micro/pwm | 8 PWM slices, 16 channels |
micro/dma | 12 DMA channels |
micro/pio | 2 PIO blocks, 4 state machines each |
micro/multicore | Dual-core launch and inter-core communication |
micro/cpu | WFI, WFE, breakpoint, spin-loop hint |
The board/pico Package
The Pico board package provides:
- Named pins:
pico.pin.led(GPIO 25),pico.pin.gpio0…pico.pin.gpio28 - Default console:
pico.default_console(), configured as UART0 on GPIO 0/1 - Temperature sensor:
pico.temp_sensor(), wrapping ADC channel 4 - Capability descriptor:
pico.capabilities()
use board/pico
use micro/gpio
let mut led = gpio.pin(pico.pin.led).into_output()
let sensor = pico.temp_sensor()
let mut console = pico.default_console().baud(115_200).init()
Memory Layout
Flash: 0x10000000 (2 MB, XIP)
RAM: 0x20000000 (264 KB)
Heap: configured by target profile
Stack: grows down from top of RAM
Image Format
Flint produces a valid UF2 for the RP2040 with the correct family ID. The UF2 includes the boot2 second-stage bootloader, vector table, and program image.
flint build --output-format uf2 # drag-and-drop flashing
flint build --output-format elf # for probe-rs / OpenOCD
flint build --output-format bin # raw binary
Flashing
UF2 drag-and-drop (simplest):
# Hold BOOTSEL while connecting USB, then:
cp build/project.uf2 /Volumes/RPI-RP2/
probe-rs:
probe-rs run --chip RP2040 build/project.elf
picotool:
picotool load build/project.uf2 --force
Dual-Core
RP2040 has two Cortex-M0+ cores. Use micro/multicore to launch code on core 1:
use micro/multicore
fn core1_task() -> never {
loop {
handle_peripheral_work()
}
}
fn main() -> never {
multicore.launch(1, core1_task)?
loop {
handle_main_work()
}
}
Communicate between cores using channels from std/sync.
PIO
RP2040 has 8 PIO state machines (2 blocks of 4). See the PIO chapter for full details.
Recursion on RP2040
Recursive calls are a compile error by default on MCU targets. The Cortex-M0+ has limited stack and no hardware stack overflow detection. Opt in explicitly when you know your recursive depth is bounded:
allow-recursion = true
RP2350 (Tier 1)
The RP2350 is the successor to the RP2040, featured on the Raspberry Pi Pico 2. It is a planned Tier 1 target.
Chip Summary
| Property | Value |
|---|---|
| Chip | RP2350 |
| CPU | Dual Cortex-M33 (or dual RISC-V, user-selectable at boot) |
| Architecture | ARMv8-M Mainline / Thumb-2 |
| Flash | 4 MB (Pico 2), via external SPI flash |
| RAM | 520 KB SRAM |
| Output formats | ELF, BIN, UF2 |
| Board | board/pico2 |
Getting Started
# flint.toml
name = "my_project"
version = "0.1.0"
edition = "2026"
target = "rp2350"
board = "pico2"
Differences from RP2040
The RP2350 Cortex-M33 backend uses ARMv8-M Mainline / Thumb-2 instruction encoding. Key differences in Flint’s code generation:
- Thumb-2 instructions provide 32-bit extensions, improving code density and available operations.
- The M33 has hardware floating-point (FPU), enabling more efficient
f32operations. - More RAM and flash allow larger programs and bigger buffers.
The micro/* API surface is the same as RP2040. Code written for rp2040 should compile for rp2350 without source changes in most cases.
RISC-V Mode
The RP2350 can also run in RISC-V mode (rp2350-riscv). This is a separate planned target. The Cortex-M33 ARM mode is the primary RP2350 target.
board/pico2 Package
The Pico 2 board package mirrors board/pico with Pico 2 pin names, increased flash/RAM, and any Pico 2-specific board conveniences.
Status
RP2350 backend support is reserved and planned. The ARMv6-M RP2040 backend is the current primary implementation. RP2350 will follow once RP2040 is fully hardened.
Check the repository for current RP2350 status.
Planned Targets
These targets are on the roadmap but are not yet supported.
Planned MCU Targets
| Target | Chip | CPU | Notes |
|---|---|---|---|
stm32f4 | STM32F4xx | Cortex-M4F | Widespread in hobbyist and industrial hardware |
samd21 | ATSAMD21 | Cortex-M0+ | Found on Arduino Zero, Adafruit boards |
samd51 | ATSAMD51 | Cortex-M4F | Found on Adafruit Metro M4, Feather M4 |
nrf52 | nRF52840 | Cortex-M4F | BLE-capable; popular for wireless firmware |
rp2350-riscv | RP2350 | RISC-V (Hazard3) | RISC-V mode on the RP2350 chip |
esp32 | ESP32-C3 / C6 | RISC-V | Wi-Fi/BLE capable; popular for IoT firmware |
Planned Host Targets
Host targets are planned once the MCU pipeline matures. The current focus is getting the embedded path right.
| Target | Architecture | Notes |
|---|---|---|
Linux x86_64 | x86-64 | Desktop / server |
Linux aarch64 | ARMv8 | Raspberry Pi, Apple Silicon VMs |
Linux riscv64 | RISC-V 64-bit | Emerging platform |
macOS x86_64 | x86-64 | Intel Mac |
macOS aarch64 | ARMv8 | Apple Silicon |
Host targets use the same Flint source language. micro/* and board/* packages are MCU-only. core/* and std/* packages work on both, with target-specific implementations under the hood.
Backend Design
The compiler’s backend architecture is designed now so these targets fit later without requiring fundamental changes. The FIR (Flint IR) is target-independent. New targets add a machine-lowering stage and a code generation backend, not new language features.
Contributing
If you want to bring up a new target, the right starting point is:
- Add the target identifier to the manifest parser.
- Implement a machine-lowering stage from FIR to target-specific machine IR.
- Implement a code generation backend (instruction selection, register allocation).
- Add a
hal/*package for chip startup, clock, and timer bring-up. - Add
micro/*implementations for the standard peripheral surface. - Add a board package for at least one common board.
- Add end-to-end test cases.
The flint-backend crate and flint-lower crate are the starting points for understanding the current pipeline.
Compiler Internals
This section is for those curious about how the Flint compiler works under the hood. Understanding the internals is not required to use Flint. If you are here to write firmware, skip to Getting Started.
The Big Picture
Flint’s compiler is implemented in Rust and structured as a set of library crates under crates/ in the repository. The main crates are:
| Crate | Role |
|---|---|
flint-cli | The flint binary and argument parsing |
flint-driver | Command orchestration and pipeline execution |
flint-lexer | Tokenization |
flint-parser | Parsing, CST construction, error recovery |
flint-syntax | Shared token kinds, spans, CST/AST node types |
flint-check | Semantic analysis: symbol resolution, type checking, ownership |
flint-lower | HIR and FIR lowering, type inference, ownership analysis, CFG |
flint-backend | RP2040-specific machine lowering and Thumb v6-M code generation |
flint-diagnostics | Diagnostic types, human-readable and JSON rendering |
flint-manifest | flint.toml parsing and validation |
flint-lsp | Language server: completion, diagnostics, navigation, editor features |
flint-elf | ELF image packaging |
flint-uf2 | UF2 image packaging |
Pipeline at a Glance
Source (.fl files)
│
▼
Lexer ← flint-lexer
│
▼
Parser ← flint-parser
│
▼
CST → AST ← flint-syntax, flint-parser
│
▼
Resolver ← flint-check
Type Checker ← flint-check / flint-lower
│
▼
HIR ← flint-lower (high-level IR)
│
▼
FIR ← flint-lower (target-independent IR)
│
▼
Machine Lowering ← flint-backend (target-specific)
│
▼
Code Gen ← flint-backend (Thumb / ARM instruction emission)
│
▼
ELF / BIN / UF2 ← flint-backend, flint-elf, flint-uf2
The key architectural property is that FIR is target-independent. Everything above FIR is shared across all targets. Everything below FIR is per-target.
Design Goals
- No external toolchain dependencies. The compiler emits machine code directly. No GCC, LLVM, or external assembler.
- Fast compilation. MCU programs are small. Compilation should be in tens of milliseconds, not seconds.
- Good diagnostics. Errors point at real source locations with clear messages. JSON output for tooling.
- Recoverable parsing. The parser continues after errors so you see multiple diagnostics in one run.
Self-Hosted Standard Library
Where possible, the standard library (micro/*, std/*, board/*) is written in Flint source. The compiler and runtime provide privileged backing implementations for things Flint source cannot express (allocator hooks, startup glue, target intrinsics), but high-level APIs are Flint.
This keeps the language honest: the standard library is an ordinary user of the language, not a hidden exception to it.
Pipeline
The compiler pipeline transforms Flint source into machine code in a series of well-defined passes. Each pass has a clear input and output.
Source Input
The pipeline starts with a resolved set of .fl source files from the project’s src/ directory. The manifest (flint.toml) provides the target, board, edition, and build settings.
Pass 1: Lexing
flint-lexer tokenizes each source file into a flat token stream:
- Identifiers, keywords, literals (integers, floats, strings, chars)
- Operators and delimiters
- Comments and whitespace (preserved for the formatter; stripped for the parser)
- Spans: every token carries a
(file, start, end)location
The lexer is position-preserving. Every character in the source maps to a token, including whitespace. This matters for flint fmt, which works from the token stream.
Pass 2: Parsing and CST Construction
flint-parser builds a Concrete Syntax Tree (CST):
- The CST is lossless; every token is in the tree, including whitespace and comments.
- The parser performs error recovery: when it encounters a syntax error, it emits a diagnostic and attempts to continue parsing the rest of the file.
- A single parse run can report multiple errors.
The CST is useful for the formatter, which needs to preserve the original structure while normalizing whitespace and ordering.
Pass 3: AST Extraction
An Abstract Syntax Tree (AST) is extracted from the CST:
- Whitespace and comments are stripped.
- The AST represents only the semantic content: function declarations, types, expressions, statements.
- Spans are preserved so every AST node knows where it came from in source.
Pass 4: Symbol Resolution and Checking
flint-check performs:
- Name resolution: resolves all identifiers to their declarations. Reports unknown names, ambiguities, and import errors.
- Visibility checking: ensures
puband private items are accessed correctly. - Entry point validation: checks that
mainhas the correct signature (-> neveron MCU targets). - Recursion analysis: detects recursive named-call cycles. On MCU targets, these are reported as errors unless
allow-recursionis set. - Deprecation lints: emits warnings at use sites of
@deprecateditems.
Pass 5: Type Checking and HIR Lowering
flint-lower type-checks the program and produces a High-Level IR (HIR):
- Type inference resolves unsuffixed literal types from context.
- Parameter modes (
mut,owned) are checked against call sites. - Ownership rules are enforced: use-after-move, partial moves, mutable aliasing.
defersites are elaborated into explicit cleanup schedules.?propagation is expanded into explicit match arms.- Collection method calls are type-checked against official generic definitions.
HIR is a typed, ownership-annotated, desugared form of the source. It is still high-level and does not yet know about registers or machine layout.
Pass 6: FIR Lowering
flint-lower lowers HIR into FIR (Flint IR):
- FIR is a target-independent control-flow graph (CFG) based IR.
- Functions are broken into basic blocks with explicit edges.
- Ownership transfers are explicit in the IR (borrow edges, move edges, drop insertions).
- FIR does not use registers; values are still named IR values with types.
- FIR is the last IR shared across all targets.
Pass 7: Machine Lowering
flint-backend lowers FIR into target-specific machine IR:
- Instruction selection: FIR operations are mapped to machine instruction patterns.
- Register allocation: IR values are assigned to physical registers or stack slots.
- ABI lowering: function calls are expanded to follow the target ABI (calling convention, parameter passing, return values).
- Stack layout: frame setup and teardown are emitted.
For RP2040: this produces Thumb v6-M instruction sequences.
Pass 8: Code Generation
Machine IR is serialized to binary:
- Instruction encoding: each machine instruction is encoded to its binary representation.
- Relocation resolution: cross-function references and data references are resolved.
- Section layout:
.text,.rodata,.data,.bsssections are organized.
Pass 9: Image Packaging
flint-backend, flint-elf, and flint-uf2 produce the final output:
- ELF:
flint-elfconstructs a 32-bit ARM ELF executable with section headers, a load segment mapped to flash, and a symbol table. Used by probe-rs, OpenOCD, and GDB. - BIN: raw binary image stripped of ELF metadata.
- UF2: UF2-packaged image for drag-and-drop flashing, with the correct RP2040 family ID and boot2 second-stage bootloader.
Diagnostics
flint-diagnostics formats errors and warnings from any pass:
- Human-readable output with file, line, column, and source excerpt.
- JSON output (
--json) for editor integrations and CI. - Severity levels: error, warning, hint.
Lexer and Parser
Lexer (flint-lexer)
The lexer converts raw UTF-8 source into a token stream.
Token Design
Every token carries:
- Kind: what kind of token it is (identifier, keyword, integer literal, operator, etc.)
- Span: the byte range
(start, end)in the source file
Token kinds include:
- Keywords:
fn,let,mut,if,else,match,for,while,loop,return,use,pub,struct,enum,impl,trait,const,static,defer,owned,as,in,and,or,not,break,continue,alias,never,false,true - Literals: integer, float, string, character, byte string, byte character, raw string
- Identifiers
- Operators and delimiters
- Whitespace and newlines (preserved for the formatter pass)
- Comments (
//,/* ... */, and///)
Automatic Statement Termination
The lexer implements Flint’s semicolon-insertion rule. A synthetic statement terminator is inserted after a token if:
- The token is an identifier, integer/float/string literal,
true,false,never,break,continue,return,),], or} - The next real token is a newline
- The parser is not inside an open
(,[, or{
The lexer also does not insert terminators after infix operators, incomplete assignments, or ..
This keeps the rule simple, local, and deterministic, with no lookahead into expressions needed.
Position Preservation
The lexer is lossless. Every character in the input maps to exactly one token. Whitespace tokens are included in the token stream, separate from the “clean” stream seen by the parser. This dual-stream design lets the formatter work directly from the token stream without re-parsing.
Parser (flint-parser)
The parser builds a Concrete Syntax Tree (CST) from the clean token stream (whitespace excluded).
CST Design
The CST is:
- Lossless: all tokens from the original source appear in the tree, including whitespace and comments.
- Untyped at the syntax level: the CST represents structure, not semantics.
- Recoverable: parse errors are local; the parser continues after an error.
CST nodes are typed by their production rule: FunctionDecl, IfExpr, MatchArm, StructDecl, UseDecl, etc.
Error Recovery
When the parser encounters an unexpected token, it:
- Emits a diagnostic with the expected and actual tokens.
- Inserts an error node in the CST.
- Attempts to continue by advancing to the next synchronization point (typically a statement boundary, closing brace, or keyword).
This lets a single flint check run report multiple errors rather than stopping at the first one.
AST Extraction
After CST construction, an AST is extracted by:
- Stripping whitespace and comment tokens.
- Extracting the semantic content from CST nodes into a typed AST representation.
- Preserving spans so every AST node knows its source location.
The AST is what subsequent passes operate on.
Formatter Integration
The formatter (flint fmt) operates on the CST, not the AST. It uses the CST’s whitespace tokens to understand the original layout, then replaces whitespace according to Flint’s formatting rules, producing a canonical output. The CST guarantees that the formatter never changes the semantic content; it only normalizes whitespace, ordering of use declarations, and blank lines.
IR and Lowering
Flint uses two intermediate representations (IRs) between the AST and machine code:
- HIR (High-Level IR): typed, desugared, ownership-annotated
- FIR (Flint IR): target-independent control-flow graph
HIR: High-Level IR
HIR is produced from the type-checked AST. It retains the high-level structure of the source but with:
- All types resolved and inferred, with no ambiguity remaining.
?propagation expanded into explicitmatch/returnsequences.deferstatements expanded into explicit cleanup scheduling at scope exits.- Ownership transfers made explicit: every value move, borrow, and drop is annotated.
- Pattern match exhaustiveness verified.
- Collection method calls type-checked against official generic definitions.
- Implicit drop sites computed: the compiler inserts drop operations at scope boundaries for every owned value.
HIR is still structured as functions with statements and expressions. It does not have basic blocks or explicit control flow edges yet.
Key HIR Transformations
? desugaring:
// Source
let data = file.read()?
// HIR (conceptual)
let data = match file.read() {
Ok(v) => v
Err(e) => { drop(everything_live); return Err(e) }
}
defer scheduling:
// Source
let conn = db.connect()?
defer conn.close()
do_work(conn)
// HIR (conceptual)
let conn = db.connect()?
// ... defer registered at this scope level ...
do_work(conn)
// Scope exit: conn.close() runs before conn is dropped
FIR: Flint IR
FIR is a typed, SSA-like control-flow graph IR. It is the last shared representation before target-specific lowering begins.
Basic Blocks
Each function in FIR is a set of basic blocks. A basic block is a linear sequence of instructions with exactly one entry and one exit (a terminator).
Terminators:
Branch(cond, true_block, false_block): conditional jumpJump(target_block): unconditional jumpReturn(value): return from functionTrap: unreachable / fatal
Control flow between blocks is explicit as edges in a directed graph.
FIR Values
FIR uses named values (not physical registers). Each value has a type and is assigned exactly once (SSA property where it applies). Ownership is explicit:
- A move of value
vto a new owner invalidatesv. - A borrow of
vcreates a view valid for a lexically bounded region. - A drop of
vinserts the generated drop glue for its type.
FIR Instructions
Example FIR instruction kinds:
Let(result, type, init): bind a valueCall(result, callee, args, calling_conv): function callFieldGet(result, base, field_index): struct field readFieldSet(base, field_index, value): struct field writeIndex(result, base, index): array/slice indexingMove(result, source): ownership transferBorrow(result, source, mode): create read or write borrowDrop(value): drop an owned valueCast(result, value, target_type): numeric conversion
Why Two IRs?
HIR is better for semantic analysis: it is still structured like source code, making it easier to check ownership, defer, and match exhaustiveness.
FIR is better for code generation: the control-flow graph makes reachability analysis, dead code elimination, and instruction selection straightforward.
Ownership in FIR
The ownership model is encoded directly in FIR:
- Every owned value has a single live definition at any program point.
- Moves are explicit edges that transfer ownership.
- Borrows carry a mode (
readorwrite) and a scope. - Drop insertions are explicit instructions; nothing drops implicitly at the FIR level.
This makes FIR verifiable: a simple analysis can confirm that no value is used after a move or drop.
Dead Code Elimination
FIR supports dead code elimination (DCE) before machine lowering:
- Unreachable basic blocks are removed.
- Unused bindings are eliminated.
- Capability-query branches that are always-false for the selected target are removed (static constant folding).
DCE keeps MCU binaries small by removing code paths for unsupported features on the selected target.
Code Generation
Code generation takes the FIR control-flow graph and produces target machine code, without involving GCC, LLVM, or any external tool.
Machine Lowering
Machine lowering translates FIR to a target-specific machine IR. For RP2040 (flint-backend), the target is Thumb v6-M (ARMv6-M).
Instruction Selection
FIR instructions are mapped to machine instructions:
| FIR operation | Thumb v6-M instruction(s) |
|---|---|
| Integer add | ADDS Rd, Rn, Rm |
| Integer subtract | SUBS Rd, Rn, Rm |
| Load field | LDR Rd, [Rn, #offset] |
| Store field | STR Rd, [Rn, #offset] |
| Conditional branch | BEQ, BNE, BLT, BGE, … |
| Function call | BL target |
| Return | BX LR |
The Thumb v6-M encoding is dense: most instructions are 16-bit, producing compact binaries suitable for the RP2040’s 2 MB flash.
Register Allocation
FIR values are assigned to physical ARM registers or stack slots. The RP2040 has 16 registers (R0-R15):
- R0-R3: argument and return registers (also caller-saved scratch)
- R4-R7: callee-saved (low registers, accessible to all Thumb v6-M instructions)
- R8-R11: callee-saved (high registers, limited access in v6-M)
- R12: scratch (IP)
- R13: stack pointer (SP)
- R14: link register (LR)
- R15: program counter (PC)
The register allocator performs linear scan allocation. Values that cannot be kept in registers are spilled to the stack frame.
ABI
Flint follows the ARM Procedure Call Standard (APCS / AAPCS) for RP2040:
- First four arguments in R0-R3.
- Additional arguments on the stack.
- Return value in R0 (or R0-R1 for 64-bit values).
- Caller saves R0-R3, R12. Callee saves R4-R11, R14.
Parameter modes:
- Copy scalars: passed in registers by value.
- Default (read-only borrow) non-copy: passed as a hidden address register.
mut(mutable borrow) non-copy: passed as an exclusive hidden address register.- Large aggregates: passed as hidden pointer per ABI rules.
Stack Frames
Every function that uses local variables or calls other functions has a stack frame:
High address
┌─────────────────┐
│ Caller's frame │
├─────────────────┤ ← SP on entry
│ Saved LR │ (if function calls others)
│ Saved R4-R7 │ (callee-saved registers used)
│ Local variables│
│ Spill slots │
└─────────────────┘ ← SP during function body
Low address
The compiler emits PUSH on entry and POP on exit for callee-saved registers. Local variable layout is computed from type sizes and alignment requirements.
Instruction Encoding
Thumb v6-M instructions are encoded to 16-bit or 32-bit binary. The encoder converts the machine IR’s instruction objects directly to bytes with no intermediate assembly text format.
All relocations (cross-function calls, PC-relative data references) are resolved after instruction encoding:
- Each function is encoded to a byte buffer.
- Relocation entries record the offset and target for each unresolved reference.
- The linker pass assigns final addresses to all sections and symbols.
- Relocations are patched into the byte buffer.
Section Layout
The final image is organized into ELF sections:
| Section | Contents |
|---|---|
.text | Executable code |
.rodata | Read-only data (string literals, constants, embedded assets) |
.data | Initialized mutable data (copied from flash to RAM at startup) |
.bss | Zero-initialized data |
For RP2040:
.textand.rodatalive in flash at0x10000000..dataand.bsslive in RAM at0x20000000.- The boot2 second-stage bootloader is prepended to the flash image.
Image Packaging
ELF: Standard 32-bit ARM ELF executable (ET_EXEC) with section headers and a symbol table containing all emitted functions. Used by probe-rs, OpenOCD, and GDB. The flint-elf crate constructs the ELF from the same raw binary image, adding ELF headers, a PT_LOAD segment mapped to the flash base address, and function symbols from the artifact layout. DWARF debug information is not yet emitted.
BIN: The raw .text + .data binary, stripped of all ELF metadata. Just the bytes that go on flash.
UF2: The flint-uf2 crate wraps the BIN into UF2 format:
- UF2 family ID for RP2040 (
0xe48bff56) - 256-byte data blocks with 476-byte UF2 headers
- Correct flash start address and block count
- The Pico’s USB bootloader validates the family ID and block structure before accepting the file
No LLVM, No GCC
Everything described above (instruction selection, register allocation, encoding, relocation, image packaging) is implemented in Rust inside the Flint compiler crates. There is no dependency on LLVM IR, GCC’s assembler, GNU ld, or any external compiler infrastructure.
This is a core design requirement: the compiler is self-contained.
Language Server
The flint-lsp crate implements the Flint language server. It reuses the compiler’s frontend crates to provide real-time editor feedback without duplicating language logic.
For user-facing feature documentation and editor setup, see Language Server in the Tools section.
Architecture
The language server is a single-threaded, synchronous event loop that reads JSON-RPC messages from stdin and writes responses to stdout. It does not use an async runtime.
Editor flint lsp
│ │
│ ── initialize ──────────────► │
│ ◄── capabilities ──────────── │
│ ── initialized ─────────────► │
│ │
│ ── didOpen ─────────────────► │ lex + parse + check
│ ◄── publishDiagnostics ───── │
│ │
│ ── completion ──────────────► │ index lookup
│ ◄── completionList ────────── │
│ │
│ ── hover ───────────────────► │ index lookup
│ ◄── hover content ─────────── │
│ │
│ ── shutdown ────────────────► │
│ ── exit ────────────────────► │
Crate Dependencies
flint-lsp depends on the compiler’s frontend crates but not on flint-cli:
flint-lexerfor tokenizationflint-parserfor CST/AST construction and recoveryflint-syntaxfor AST types, token kinds, spans, andLineIndexflint-checkfor semantic analysis and theSymbolTableflint-diagnosticsfor diagnostic types and severity mappingflint-driverfor workspace resolution and the canonical formatterflint-manifestforflint.tomlloading
Module Layout
| Module | Responsibility |
|---|---|
transport | Content-Length framed JSON-RPC over stdio |
protocol | LSP data types (positions, ranges, capabilities, requests) |
session | Event loop, request dispatch, state management |
documents | Open document store with automatic lex/parse on change |
index | Workspace-wide symbol index built from AST walks |
completion | Context-aware completions (keywords, imports, locals, members) |
diagnostics | Syntax and semantic diagnostic mapping |
hover | Function signatures and doc comments |
definition | Cross-file go-to-definition |
references | Cross-file find-references |
rename | Cross-file rename |
signature_help | Parameter info at call sites |
symbols | Document and workspace symbol providers |
formatting | Document formatting via the flint fmt engine |
actions | Code actions (fix hints, add/remove imports) |
semantic_tokens | Token classification for rich highlighting |
inlay_hints | Type annotations and parameter name hints |
highlights | Document-level identifier highlighting |
folding | Folding ranges for blocks and declarations |
location | Byte offset to LSP position conversion utilities |
Document Model
The server keeps an in-memory store of open documents. Each document holds:
- The current source text
- A
LineIndexfor position conversion - The latest
LexedModule(tokens and trivia) - The latest
ParsedModule(CST, AST, recovery status, parse issues)
On every didOpen and didChange, the server re-lexes, re-parses, rebuilds the symbol index, and publishes diagnostics. Incremental document sync is used (the editor sends only the changed range, which the server splices into the existing text before re-parsing).
Diagnostic Pipeline
Diagnostics flow through two lanes:
-
Syntax lane (immediate): parse issues from the current document are mapped to LSP diagnostics and published right away.
-
Analysis lane (on change): the server runs the full compiler pipeline in-process on all open documents:
flint_check::Checker::check()for semantic errors (unresolved symbols, duplicate definitions, invalid signatures, missing entry points)flint_lower::Lowerer::lower()to produce HIR and FIRflint_lower::TypeChecker::check()for deep type-level errors (mutability violations, ownership and move errors, definite initialization, return-path completeness, exhaustive match, closure capture validity, MCU recursion policy)
The lowering and type-checking pass only runs when semantic checking succeeds, since it depends on a well-formed checked package.
Both lanes run synchronously after each document change. There is no background thread or debounce timer in the current implementation. Because the compiler is a set of library crates called in-process, every diagnostic the compiler can produce is available in the editor with no translation layer or subprocess overhead.
Symbol Index
The workspace index maintains a per-document index of:
- Top-level symbols (functions, structs, enums, traits, constants, statics, aliases) with their spans, visibility, rendered signatures, doc comments, parameters, and children (fields, variants, methods)
- Imports with their paths and bound names
- Impl method associations (which type each method belongs to)
The index is rebuilt from the AST on every document change. It powers completion, hover, go-to-definition, find-references, rename, document symbols, workspace symbols, semantic tokens, and inlay hints.
Completion Strategy
Completion is context-sensitive. The server checks the cursor position and dispatches to different strategies:
- Inside
useitems: complete module paths from the known package list - After
::: complete enum variants - Inside
{ }after a type name: complete struct fields - After
.: complete struct fields, impl methods, trait methods, and imported module symbols. The receiver’s type is resolved from explicit annotations, construct expressions (Point { ... }), function call return types, orselfinside impl blocks. - Default: keywords, top-level symbols, imported names, local bindings (parameters, let bindings, for-loop variables), and public symbols from other documents
Language Server
Flint ships a built-in language server that editors can use for real-time feedback while you write code. It runs as a subprocess of your editor, communicating over standard input and output using the Language Server Protocol (LSP).
Starting the Server
flint lsp
This launches the language server with stdio transport. You do not normally run this command yourself; your editor starts it automatically when you open a Flint file. The sections below explain how to configure that for each supported editor.
Supported Features
The language server provides the following capabilities:
- Completion. Context-aware completions including
usepath completion, keywords, top-level items, imported names, local variables (let bindings, parameters, for-loop variables), enum variants after::, struct fields inside constructors, and member/method completion after.with type inference from construct expressions, function return types, andselfinside impl blocks. - Diagnostics. Syntax errors appear instantly as you type. Semantic diagnostics (unresolved symbols, missing entry points) and deep type-level diagnostics (mutability violations, ownership errors, use-after-move, missing returns, non-exhaustive match) update after each change via the full in-process compiler pipeline.
- Hover. Hovering over a function or item shows its signature and doc comments.
- Go-to-definition. Jump to the definition of any symbol, including across open files.
- Document symbols. Browse the structure of the current file (functions, types, constants).
- Workspace symbols. Search for symbols across the entire project.
- Signature help. See parameter names and types while filling in a function call.
- Find references. Locate every use of a symbol across all open files.
- Rename. Rename a symbol and update all references across open files.
- Code actions. Quick fixes from diagnostic suggestions, automatic “add missing import” for unresolved symbols, and “remove unused import” for dead imports.
- Formatting. Format the current file through
textDocument/formatting, using the same rules asflint fmt. - Semantic tokens. Rich, context-aware syntax highlighting that distinguishes functions, types, enums, traits, constants, parameters, and more.
- Inlay hints. Inline type annotations on
letbindings with inferred types, and parameter name hints at call sites. - Document highlights. All occurrences of the symbol under the cursor are highlighted.
- Folding ranges. Collapse function bodies, struct/enum/trait/impl blocks, and nested control flow.
The Self-Contained Part is Real
Most language servers talk to the compiler at arm’s length. They run a separate process, parse its text output, and hope the error messages land on the right line. Some languages have a compiler that was never designed for interactive use, so the LSP has to maintain its own parallel understanding of the code and reconcile it with the real compiler after the fact.
Flint does not work this way. The language server runs the actual compiler frontend in-process: the same lexer, the same parser, the same semantic checker, the same lowerer, and the same type checker that flint check and flint build use. There is no separate analysis engine. There is no second opinion that might disagree with the real compiler. When the language server underlines something in red, it is because the compiler itself rejected it, not because a heuristic guessed something might be wrong.
This means every diagnostic the compiler can produce is available in your editor the moment you stop typing:
- Mutability. Assigning to a non-
mutbinding is an error. The fix suggestion tells you to addmut. - Ownership. Using a value after it has been moved is an error. The diagnostic points at the move and the use.
- Definite initialization. Reading a variable before it has been assigned is caught.
- Return completeness. A function that promises a return value but has a code path that falls through is an error.
- Exhaustive match. A
matchthat does not cover all variants is caught at the point of the match. - Closure captures. Capturing a moved value in a closure is flagged.
- MCU recursion policy. On MCU targets, recursive call cycles are reported unless explicitly opted in.
These are not lint suggestions. They are the same errors that would stop flint build from producing a binary. Seeing them live while writing code, with explanations and fix suggestions, is a fundamentally different experience from discovering them after a failed build.
This is only possible because Flint owns its entire pipeline. There is no GCC or LLVM in the loop. The compiler is a set of Rust library crates, and the language server calls directly into them. Adding a new diagnostic to the compiler means it automatically appears in every editor. There is no protocol translation layer, no output parser to update, no version skew between the compiler and the editor tooling.
The self-contained toolchain is not just about installation convenience. It is about making the editor and the compiler the same thing.
Editor Setup
VS Code and Cursor
A dedicated VS Code extension with TextMate highlighting and automatic LSP launch is planned. Until it ships, you can configure VS Code (or Cursor, which uses the same extension model) manually:
- Install a generic LSP client extension such as vscode-languageclient or configure the built-in language client if your extension supports it.
- Point the server command at
flint lsp. - Associate
.flfiles with the Flint language ID.
A minimal settings.json snippet:
{
"flint.server.path": "flint",
"flint.server.args": ["lsp"]
}
The exact keys depend on which LSP client extension you use. Refer to that extension’s documentation for the precise configuration format.
Zed
A Zed extension with Tree-sitter grammar and LSP launch is planned. In the meantime, you can add a custom language server entry in your Zed settings:
{
"lsp": {
"flint": {
"binary": {
"path": "flint",
"arguments": ["lsp"]
}
}
}
}
Then associate .fl files with the flint language server in your Zed language configuration.
Other Editors
Any editor that supports LSP can use the Flint language server. Configure it to run flint lsp as a stdio-based language server and associate it with .fl files. Neovim (via nvim-lspconfig), Helix, and Sublime Text all support custom LSP server definitions.