Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. flint parses, 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/* and std/*, 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 rp2040 target 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:

  1. Self-contained over stitched-together. One toolchain, one workflow, one place to understand what is happening.
  2. MCU-first over retrofitted. Start with the hardest environment and let everything else grow from there.
  3. Safe by default, without user-facing ceremony. Keep the ownership model, lose the unnecessary friction.
  4. Readable source and predictable tooling. Code should be easy for humans to follow and easy for tools to reason about.
  5. Official libraries over dependency sprawl. Prefer a coherent shared ecosystem to a registry full of overlapping reinventions.
  6. Developer experience is a feature. Formatting, diagnostics, autocomplete, syntax highlighting, and editor support are part of the language experience, not extras.
  7. 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:

LanguageWhat Flint borrows
RustOwnership, move semantics, Result/Option, no null, pattern matching, deterministic cleanup
GoReadable syntax, error-as-value discipline, simple module system, defer, channels
PythonClean 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 .uf2 file. 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/:

FormatFlagDescription
UF2--output-format uf2For drag-and-drop flashing
ELF--output-format elfFor debugging with probe-rs or OpenOCD
BIN--output-format binRaw binary image

Next Steps

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"
FieldDescription
nameProject name. Used as the artifact filename.
versionSemver project version.
editionSource compatibility year. Use 2026.
targetThe chip to compile for (e.g., rp2040).
boardThe 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 module declarations in source.
  • Import paths use /, not . or ::.
  • The last path segment is the default module name: use drivers/display imports as display.
  • Use as to rename: use drivers/display as screen.
  • No wildcard imports (use foo/* is not supported).
  • No relative imports (./foo or ../foo are 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
  • use declarations 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 .uf2 to the drive.
  • picotool: picotool load build/blink.uf2 --force
  • probe-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.

ItemStyleExample
Functions, methods, variables, fields, modulessnake_caseread_bytes, retry_count
Types, traits, type aliases, enum variantsCamelCaseSerialPort, ParseError, Some
ConstantsUPPER_SNAKE_CASEMAX_RETRIES, LED_PIN
Import pathssnake_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:

OperatorMeaning
andlogical AND (short-circuits)
orlogical OR (short-circuits)
notlogical NOT
inmembership test
if is_ready and not is_busy {
    send(packet)
}

if value in valid_range {
    process(value)
}

There is no &&, ||, !, or ?: ternary.

Arithmetic

OperatorMeaning
+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

OperatorMeaning
&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

OperatorMeaning
=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

TypeDescription
boolBoolean: true or false
u8, u16, u32, u64Unsigned integers
i8, i16, i32, i64Signed integers
usize, isizePointer-sized integers
f3232-bit float
charUnicode scalar value
stringImmutable UTF-8 text
neverThe type of expressions that do not return
()Unit type (nothing / void)

There is no null. Absence is represented with Option<T>.

f64 is intentionally excluded from Flint 1.0. Use f32 for 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_mul exist in core/* 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:

TypeDescription
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:

  • string is immutable. You cannot modify it in place.
  • No integer indexing into string. Use iterators or string APIs.
  • char is a Unicode scalar value (one code point).
  • string.len() returns the UTF-8 byte length.
  • string.bytes() returns an immutable slice<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

  • string values are always valid UTF-8.
  • string is immutable. You do not modify it in place.
  • string.len() returns the UTF-8 byte length, not a character count.
  • string.bytes() returns an immutable slice<u8> view over the underlying bytes.
  • Direct integer indexing into string is 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 uses
  • utf8_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:

  • static is immutable. There is no static 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

  1. Every non-copy value has exactly one owner.
  2. Assigning a non-copy value moves ownership.
  3. Returning a non-copy value moves ownership.
  4. Use-after-move is a compile error.
  5. 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.

  • Copy is a compiler-known structural property, not a user-defined trait.
  • Clone is the standard built-in trait for explicit duplication of non-Copy values.
  • Clone lives in core/clone and is available through the global prelude, so impl Clone for MyType works without use 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 mut parameter
  • 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:

  1. Implicit drop: values are automatically cleaned up when they go out of scope.
  2. 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
  • return
  • break
  • 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 defer to pair cleanup with acquisition: open/close, lock/release, start/stop.
  • Keep deferred calls simple and infallible when possible.
  • Do not use defer as a general exception or error-handling mechanism. That is what Result is 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:

  • None if PORT is missing
  • None if parsing fails
  • None if the parsed value does not fit in u16
  • Some(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-specific Result<T, MyError>.
  • Functions that return optional data return Option<T>.
  • Do not return Result for operations that cannot fail. Unnecessary noise makes code harder to read.
  • Use ? liberally to propagate errors up the call stack without boilerplate.
  • Use fatal and assert for 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:

LevelOperators
Postfix(), [], ., ?
Prefixunary -, not, ~
Multiplicative*, /, %
Additive+, -
Shift<<, >>
Bitwise AND&
Bitwise XOR^
Bitwise OR|
Relational / membership<, <=, >, >=, in
Equality==, !=
Logical ANDand
Logical ORor
Conditionala if cond else b
Block if expressionif 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

  • match is 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 ().
  • return is explicit. Flint does not use implicit last-expression returns.
  • -> never marks 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 |...|, not fn(...).
  • 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) or Ok(T)).
  • Associated functions named new, zero, default, or origin are conventional constructors.
  • There is no struct update syntax or spread syntax (..other is 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 self or mut self.
  • Self in a trait method signature names the eventual implementing type.
  • owned self is 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 impl blocks.
  • No trait bounds on functions (fn foo<T: Trait> is not supported).
  • No owned trait 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 Uart trait).
  • 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 }
    }
}
  • Clone is defined by core/clone.
  • Clone is in the global prelude, so bare Clone resolves without an import.
  • Clone is the standard interface for explicit duplication of non-Copy values.
  • Copy remains a compiler-known structural property, not a normal trait authors implement.

Standard Traits

Official packages provide canonical trait interfaces. Notable ones:

TraitPackagePurpose
Clonecore/cloneExplicit duplication of non-Copy values
io.Writercore/ioWriteable sink
io.Readercore/ioReadable 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:

FileModule path
src/main.flmain
src/drivers/uart.fldrivers/uart
src/http/client.flhttp/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
└─────────────────────────────────────────────┘
PackageWhat 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.
ApplicationYour 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:

PrefixPurpose
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/*

PackageContents
core/cloneStandard Clone trait for explicit duplication of non-Copy values
core/fmtNo-heap, writer-first formatting. fmt.write(out, "val={}", n)
core/ioReader and Writer traits
core/syncOnceCell<T>, Lazy<T>, atomics, CAS helpers, critical sections
core/errorThe Error type (available in prologue without import)
core/cmpOrdering enum and compare() helper for custom sort APIs
core/embedCompile-time asset embedding: embed.bytes(...), embed.string(...)

std/*

PackageContents
std/timeDuration, Instant, sleep_ms, sleep_us (cross-target)
std/fmtOwned-string formatting: fmt.format("val={}", n) -> Result<string, Error>
std/syncChannels, Mutex<T>, Semaphore
std/textPortable text views, text.Split, and text.Buffer[N]
std/ioBuffered IO, file handles (host targets)
std/fsFilesystem APIs (host targets / SD card)
std/embedHigher-level asset loading on top of core/embed

The Prologue

These names are always in scope without any use:

  • Types: bool, u8 through u64, i8 through i64, 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 string for immutable UTF-8 text views
  • built-in char for Unicode scalar values
  • std/text for split results and fixed-capacity mutable text via text.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.

PackagePurpose
core/cloneStandard Clone trait for explicit duplication
core/fmtNo-heap, writer-first formatting
core/ioReader and Writer traits
core/syncAtomics, OnceCell<T>, Lazy<T>, critical sections
core/cmpOrdering enum and compare() for custom sort APIs
core/embedCompile-time asset embedding

std/*

Higher-level. May require heap. Cross-target where the concept applies.

See std/* for full API details.

PackagePurpose
std/timeDuration, Instant, sleep_ms, sleep_us
std/fmtOwned-string formatting
std/syncChannels, Mutex<T>, Semaphore
std/textPortable text views, text.Split, and text.Buffer[N]
std/ioBuffered IO, file handles (host targets)
std/fsFilesystem APIs (host targets / SD card)

micro/*

MCU peripheral APIs. See HAL and Micro Library for full API details.

PackagePurpose
micro/gpioGPIO peripheral API
micro/uartUART peripheral API
micro/adcADC peripheral API
micro/pwmPWM peripheral API
micro/i2cI2C bus API
micro/spiSPI bus API
micro/pioPIO state machine API
micro/dmaDMA controller API

board/*

Board-specific pin maps, defaults, and convenience constructors. Available packages depend on the selected board. See Targets and Boards.

PackagePurpose
board/picoRaspberry Pi Pico (RP2040)
board/pico2Raspberry 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 string method 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:

  • string handles read-only and view-returning text operations
  • char exposes Unicode scalar values plus small UTF-8 helpers
  • std/text provides split results and fixed-capacity mutable text through text.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, and replace preserve UTF-8 validity
  • push_ascii(byte) is the explicit ASCII bridge for raw byte-stream input such as UART terminal bytes
  • remove(index) removes one UTF-8 scalar value at a validated byte boundary and returns that char
  • replace(old, new) returns the number of non-overlapping replacements
  • replace(old, new) rejects an empty pattern

text.BufferError

Current shipped error cases:

  • Full
  • InvalidIndex
  • EmptyPattern
  • NonAscii

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/timer is reserved for direct hardware timer/alarm/counter control. Use std/time for 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:

PackageAllocationReturn typeWhen to use
core/fmtNoneResult<(), Error> source APINo-heap checked formatting for sink-style output
std/fmtMay allocateResult<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 Writer implementation 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.Writer is 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_fmt to avoid shadowing core/fmt if you import both.

Format String Syntax

Format strings use a minimal placeholder language:

PlaceholderMeaning
{}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 as true or false
  • f32: decimal representation
  • char: the character itself
  • string: the string value
  • Error: 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/Lazy reads: ok.
  • Use try_send and try_recv on channels: ok.
  • Use sync.CriticalSection carefully: ok if brief.
  • Never call recv, send, lock, or wait; 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

TierMeaning
Tier 1Battle-tested. Compiler, HAL, and board packages are complete. The end-to-end path is validated on real hardware.
Tier 2Code generation is solid, but HAL or board packages may not be fully fleshed out. Usable, but not as “batteries included” as Tier 1.
Tier 3Experimental or unproven. Codegen issues may be present. Use for exploration only.

Target Summary

TargetCPUTierOutputStatus
rp2040RP2040 / Cortex-M0+Tier 1ELF, BIN, UF2Active development, primary target
rp2350RP2350 / Cortex-M33Tier 1ELF, BIN, UF2Planned

Planned targets (Tier 3):

TargetCPUNotes
stm32f4STM32F4 / Cortex-M4Planned
samd21SAMD21 / Cortex-M0+Planned
samd51SAMD51 / Cortex-M4Planned
nrf52nRF52 / Cortex-M4Planned
rp2350-riscvRP2350 / RISC-VPlanned
Linux x86_64x86-64Planned, once MCU pipeline matures
Linux aarch64ARMv8Planned
Linux riscv64RISC-V 64Planned

Board Summary

BoardTargetPackageTier
Raspberry Pi Picorp2040board/picoTier 1
Raspberry Pi Pico 2rp2350board/pico2Tier 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, and micro/pio work 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:

  • rp2040 with board/pico
  • rp2350 with board/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

PropertyValue
ChipRP2040
CPUDual-core ARM Cortex-M0+
ArchitectureARMv6-M / Thumb baseline
Flash2 MB (Pico), via external SPI flash
RAM264 KB SRAM
Output formatsELF, BIN, UF2
Boardboard/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:

PackagePeripheral
micro/gpioDigital I/O: 30 GPIO pins
micro/uartUART0, UART1
micro/i2cI2C0, I2C1
micro/spiSPI0, SPI1
micro/adc4 external ADC channels (GPIO26-29), 1 internal (temp sensor)
micro/pwm8 PWM slices, 16 channels
micro/dma12 DMA channels
micro/pio2 PIO blocks, 4 state machines each
micro/multicoreDual-core launch and inter-core communication
micro/cpuWFI, WFE, breakpoint, spin-loop hint

The board/pico Package

The Pico board package provides:

  • Named pins: pico.pin.led (GPIO 25), pico.pin.gpio0pico.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

PropertyValue
ChipRP2350
CPUDual Cortex-M33 (or dual RISC-V, user-selectable at boot)
ArchitectureARMv8-M Mainline / Thumb-2
Flash4 MB (Pico 2), via external SPI flash
RAM520 KB SRAM
Output formatsELF, BIN, UF2
Boardboard/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 f32 operations.
  • 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

TargetChipCPUNotes
stm32f4STM32F4xxCortex-M4FWidespread in hobbyist and industrial hardware
samd21ATSAMD21Cortex-M0+Found on Arduino Zero, Adafruit boards
samd51ATSAMD51Cortex-M4FFound on Adafruit Metro M4, Feather M4
nrf52nRF52840Cortex-M4FBLE-capable; popular for wireless firmware
rp2350-riscvRP2350RISC-V (Hazard3)RISC-V mode on the RP2350 chip
esp32ESP32-C3 / C6RISC-VWi-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.

TargetArchitectureNotes
Linux x86_64x86-64Desktop / server
Linux aarch64ARMv8Raspberry Pi, Apple Silicon VMs
Linux riscv64RISC-V 64-bitEmerging platform
macOS x86_64x86-64Intel Mac
macOS aarch64ARMv8Apple 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:

  1. Add the target identifier to the manifest parser.
  2. Implement a machine-lowering stage from FIR to target-specific machine IR.
  3. Implement a code generation backend (instruction selection, register allocation).
  4. Add a hal/* package for chip startup, clock, and timer bring-up.
  5. Add micro/* implementations for the standard peripheral surface.
  6. Add a board package for at least one common board.
  7. 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:

CrateRole
flint-cliThe flint binary and argument parsing
flint-driverCommand orchestration and pipeline execution
flint-lexerTokenization
flint-parserParsing, CST construction, error recovery
flint-syntaxShared token kinds, spans, CST/AST node types
flint-checkSemantic analysis: symbol resolution, type checking, ownership
flint-lowerHIR and FIR lowering, type inference, ownership analysis, CFG
flint-backendRP2040-specific machine lowering and Thumb v6-M code generation
flint-diagnosticsDiagnostic types, human-readable and JSON rendering
flint-manifestflint.toml parsing and validation
flint-lspLanguage server: completion, diagnostics, navigation, editor features
flint-elfELF image packaging
flint-uf2UF2 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 pub and private items are accessed correctly.
  • Entry point validation: checks that main has the correct signature (-> never on MCU targets).
  • Recursion analysis: detects recursive named-call cycles. On MCU targets, these are reported as errors unless allow-recursion is set.
  • Deprecation lints: emits warnings at use sites of @deprecated items.

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.
  • defer sites 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, .bss sections are organized.

Pass 9: Image Packaging

flint-backend, flint-elf, and flint-uf2 produce the final output:

  • ELF: flint-elf constructs 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:

  1. The token is an identifier, integer/float/string literal, true, false, never, break, continue, return, ), ], or }
  2. The next real token is a newline
  3. 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:

  1. Emits a diagnostic with the expected and actual tokens.
  2. Inserts an error node in the CST.
  3. 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 explicit match/return sequences.
  • defer statements 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 jump
  • Jump(target_block): unconditional jump
  • Return(value): return from function
  • Trap: 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 v to a new owner invalidates v.
  • A borrow of v creates a view valid for a lexically bounded region.
  • A drop of v inserts the generated drop glue for its type.

FIR Instructions

Example FIR instruction kinds:

  • Let(result, type, init): bind a value
  • Call(result, callee, args, calling_conv): function call
  • FieldGet(result, base, field_index): struct field read
  • FieldSet(base, field_index, value): struct field write
  • Index(result, base, index): array/slice indexing
  • Move(result, source): ownership transfer
  • Borrow(result, source, mode): create read or write borrow
  • Drop(value): drop an owned value
  • Cast(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 (read or write) 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 operationThumb v6-M instruction(s)
Integer addADDS Rd, Rn, Rm
Integer subtractSUBS Rd, Rn, Rm
Load fieldLDR Rd, [Rn, #offset]
Store fieldSTR Rd, [Rn, #offset]
Conditional branchBEQ, BNE, BLT, BGE, …
Function callBL target
ReturnBX 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:

  1. Each function is encoded to a byte buffer.
  2. Relocation entries record the offset and target for each unresolved reference.
  3. The linker pass assigns final addresses to all sections and symbols.
  4. Relocations are patched into the byte buffer.

Section Layout

The final image is organized into ELF sections:

SectionContents
.textExecutable code
.rodataRead-only data (string literals, constants, embedded assets)
.dataInitialized mutable data (copied from flash to RAM at startup)
.bssZero-initialized data

For RP2040:

  • .text and .rodata live in flash at 0x10000000.
  • .data and .bss live in RAM at 0x20000000.
  • 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-lexer for tokenization
  • flint-parser for CST/AST construction and recovery
  • flint-syntax for AST types, token kinds, spans, and LineIndex
  • flint-check for semantic analysis and the SymbolTable
  • flint-diagnostics for diagnostic types and severity mapping
  • flint-driver for workspace resolution and the canonical formatter
  • flint-manifest for flint.toml loading

Module Layout

ModuleResponsibility
transportContent-Length framed JSON-RPC over stdio
protocolLSP data types (positions, ranges, capabilities, requests)
sessionEvent loop, request dispatch, state management
documentsOpen document store with automatic lex/parse on change
indexWorkspace-wide symbol index built from AST walks
completionContext-aware completions (keywords, imports, locals, members)
diagnosticsSyntax and semantic diagnostic mapping
hoverFunction signatures and doc comments
definitionCross-file go-to-definition
referencesCross-file find-references
renameCross-file rename
signature_helpParameter info at call sites
symbolsDocument and workspace symbol providers
formattingDocument formatting via the flint fmt engine
actionsCode actions (fix hints, add/remove imports)
semantic_tokensToken classification for rich highlighting
inlay_hintsType annotations and parameter name hints
highlightsDocument-level identifier highlighting
foldingFolding ranges for blocks and declarations
locationByte 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 LineIndex for 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:

  1. Syntax lane (immediate): parse issues from the current document are mapped to LSP diagnostics and published right away.

  2. 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 FIR
    • flint_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:

  1. Inside use items: complete module paths from the known package list
  2. After ::: complete enum variants
  3. Inside { } after a type name: complete struct fields
  4. 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, or self inside impl blocks.
  5. 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 use path 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, and self inside 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 as flint fmt.
  • Semantic tokens. Rich, context-aware syntax highlighting that distinguishes functions, types, enums, traits, constants, parameters, and more.
  • Inlay hints. Inline type annotations on let bindings 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-mut binding is an error. The fix suggestion tells you to add mut.
  • 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 match that 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:

  1. Install a generic LSP client extension such as vscode-languageclient or configure the built-in language client if your extension supports it.
  2. Point the server command at flint lsp.
  3. Associate .fl files 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.