Back to flin
flin

The FLIN CLI: Build, Test, Run

How we built the FLIN CLI with build, test, run, format, and lint commands.

Thales & Claude | March 25, 2026 9 min flin
flinclibuildtestrundeveloper-experience

Every programming language lives or dies by its command-line interface. The language itself can be beautifully designed, the runtime blindingly fast, the type system mathematically elegant -- but if developers cannot build, test, and run their code with a single command, none of it matters. The CLI is the first thing a developer touches and the last thing they close at the end of the day.

FLIN's CLI was designed to be a single binary that replaces the entire toolchain. No separate compiler, bundler, test runner, formatter, linter, or package manager. One binary. Eight commands. Everything you need from first prototype to production deployment.

The Command Architecture

The FLIN CLI follows a subcommand pattern inspired by cargo and git. Every operation starts with flin followed by a verb:

flin dev [path]       # Development server with hot reload
flin build [file]     # Compile to .flinc binary
flin check [file]     # Type check without compiling
flin run [file]       # Execute .flin source or .flinc binary
flin test [path]      # Discover and run tests
flin fmt [file]       # Format source code
flin new <name>       # Scaffold a new project
flin docs [topic]     # Built-in documentation

The implementation lives in src/main.rs, built around a simple Rust enum that maps each subcommand to its handler:

enum Command {
    Dev { path: Option<String> },
    Build { file: Option<String> },
    Check { file: Option<String> },
    Run { file: Option<String> },
    Test { path: Option<String> },
    Fmt { file: Option<String>, check: bool },
    New { name: String },
    Docs { topic: Option<String> },
}

This is deliberate minimalism. No plugin system. No configuration files for the CLI itself. No flags that change fundamental behavior. Each command does one thing, does it well, and exits with the correct code for CI/CD pipelines.

Building to .flinc Bytecode

The flin build command compiles FLIN source code into a binary .flinc file that can be distributed and executed without the source:

$ flin build examples/counter.flin
-> Built examples/counter.flin -> dist/counter.flinc

$ ls -lh dist/counter.flinc -rw-r--r-- 1 user staff 487B counter.flinc

$ hexdump -C dist/counter.flinc | head -n 3 00000000 46 4c 49 4e 01 00 03 00 |FLIN....| ```

The build pipeline runs the full compilation: source to tokens, tokens to AST, AST through the type checker, type-checked AST to bytecode, and finally bytecode to the .flinc binary format. The output goes to a dist/ directory, mirroring the convention from frontend build tools but applied to a full-stack language.

The implementation in Rust is straightforward:

fn cmd_build(file: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
    let path = resolve_input_path(file)?;
    let source = fs::read_to_string(&path)?;

// Full compilation pipeline let chunk = flin::compile(&source, &path)?;

// Generate output path: examples/counter.flin -> dist/counter.flinc let output_path = generate_output_path(&path); fs::create_dir_all(output_path.parent().unwrap())?;

// Serialize to binary format let bytecode = serialize_chunk(&chunk)?; fs::write(&output_path, bytecode)?;

println!("-> Built {} -> {}", path.display(), output_path.display()); Ok(()) } ```

What makes this powerful is the flin run command's ability to execute both source files and compiled binaries:

# Development: run from source
flin run app.flin

# Production: run pre-compiled binary flin run dist/app.flinc ```

The run command inspects the file extension and dispatches accordingly. Source files go through the full compilation pipeline. Binary files are deserialized and executed directly, skipping lexing, parsing, and type checking entirely. This means .flinc files load approximately twice as fast as source files -- a meaningful improvement for serverless deployments where cold start time matters.

The Test Runner

FLIN's test runner uses convention over configuration. There are no test configuration files, no test framework imports, no special annotations. If a file ends with _test.flin or -test.flin, it is a test file:

$ flin test tests/
Running 3 test file(s)...

button_test.flin ... PASS input_test.flin ... PASS workflow_test.flin ... PASS

Test Results: Passed: 3 Failed: 0 Total: 3 Time: 0.09s ```

Test discovery is recursive. Point flin test at any directory and it walks the entire tree, collecting every file that matches the naming convention:

fn is_test_file(path: &Path) -> bool {
    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
        name.ends_with("_test.flin") || name.ends_with("-test.flin")
    } else {
        false
    }
}

fn find_test_files(dir: &Path) -> Result, Box> { let mut test_files = Vec::new(); if dir.is_file() { if is_test_file(dir) { test_files.push(dir.to_path_buf()); } return Ok(test_files); } for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { test_files.extend(find_test_files(&path)?); } else if is_test_file(&path) { test_files.push(path); } } Ok(test_files) } ```

Each test file executes in its own fresh VM instance. This provides complete isolation -- no shared global state between tests, no ordering dependencies, no flaky tests caused by leftover data from previous runs. If a test file compiles and runs without error, it passes. If it throws a runtime error or fails to compile, it fails. Simple.

The exit code behavior is designed for CI/CD: exit 0 if all tests pass, exit 1 if any test fails. This means you can use flin test directly in shell scripts and pipeline definitions without any wrapper:

# CI pipeline
flin check app.flin && flin test tests/ && flin build app.flin

Development Server

The flin dev command starts a development server with hot module reload. When you change a .flin file, the server recompiles and pushes updates to the browser in under 50 milliseconds. This is the command developers use 90% of the time:

$ flin dev
Server running at http://localhost:3000
Watching for file changes...

The development server handles multi-page routing, static file serving, and server-sent events for live reload. It is a complete development environment in a single command, replacing the combination of webpack-dev-server, nodemon, and a separate build process that JavaScript developers know all too well.

Type Checking Without Building

The flin check command runs the type checker without generating bytecode or starting a server. It is the fastest way to verify your code is correct:

$ flin check app.flin
No errors found.

$ flin check broken.flin error[E0301]: Type mismatch --> broken.flin:12:5 | 12 | count = "not a number" | ^^^^^^^^^^^^^^ expected int, found text ```

This command is particularly useful in editor integrations and pre-commit hooks, where you want fast feedback without the overhead of a full build.

The Formatter

flin fmt formats FLIN source code to a canonical style, similar to gofmt or rustfmt. It preserves comments, normalizes indentation, and ensures consistent code style across a project:

# Format a file in place
flin fmt app.flin

# Check formatting without modifying (for CI) flin fmt --check app.flin ```

The --check flag exits with code 1 if the file would change, making it suitable for CI pipelines that enforce formatting standards.

Project Scaffolding

The flin new command creates a new project with the correct directory structure:

$ flin new myapp
Created project 'myapp'

$ tree myapp/ myapp/ app/ index.flin flin.config README.md ```

Three templates are available, covering the spectrum from beginner to production:

flin new myapp                        # Minimal project
flin new myapp --template starter     # Counter demo (beginner)
flin new myapp --template todo        # TodoMVC with entities (intermediate)
flin new myapp --template fullstack   # Full-stack with routing, API, auth

The templates are embedded in the binary at compile time using Rust's include_str! macro, meaning flin new works offline with no network access required.

Testing the CLI Itself

We wrote 48 integration tests for the CLI using Rust's assert_cmd and predicates crates. These tests spawn actual flin processes, feed them inputs, and verify outputs:

#[test]
fn test_build_creates_flinc_file() {
    let temp = TempDir::new().unwrap();
    let source = temp.path().join("counter.flin");
    fs::write(&source, "count = 0\n<button click={count++}>{count}</button>").unwrap();

let status = Command::new("./target/release/flin") .arg("build") .arg(&source) .status() .unwrap();

assert!(status.success());

let output = PathBuf::from("dist/counter.flinc"); assert!(output.exists());

let bytecode = fs::read(&output).unwrap(); assert_eq!(&bytecode[0..4], b"FLIN"); // Magic number } ```

Every command has tests for success paths, error paths, and edge cases. The test runner tests verify discovery, naming conventions, pass/fail reporting, exit codes, recursive scanning, and the empty-directory case. The build tests verify file creation, output paths, checksum validation, debug info, and multi-file builds.

Comparison With Other Toolchains

The FLIN CLI achieves feature parity with the combined toolchains of major ecosystems in a single binary:

Featurenpm ecosystemcargoflin
Build to binaryNoYesYes (.flinc)
Test runnerJest/Vitest (separate)Built-inBuilt-in
Auto test discoveryManual configBuilt-inBuilt-in
Dev serverwebpack-dev-serverNoBuilt-in
Type checkingtsc (separate)Built-inBuilt-in
FormatterPrettier (separate)rustfmtBuilt-in
Hot reloadHMR (complex setup)NoBuilt-in
CI exit codesYesYesYes

The JavaScript ecosystem requires installing and configuring five to ten separate tools to achieve what FLIN provides out of the box. The Rust ecosystem is closer but lacks a built-in development server and hot reload. FLIN combines the best of both worlds into a single 1.8 MB binary.

Design Philosophy

Three principles guided the CLI design:

Zero configuration. The CLI works with sensible defaults and requires no configuration files for standard workflows. You should be able to clone a FLIN project and run flin dev without reading any documentation.

Correct exit codes. Every command returns 0 on success and non-zero on failure. This sounds trivial, but many tools get it wrong, breaking CI pipelines. We test exit codes explicitly in our integration tests.

Single binary. No runtime dependencies, no global installs, no PATH manipulation. Download one file, make it executable, and you have the complete FLIN toolchain. This matters especially for deployment to containers and serverless environments where every megabyte of image size counts.

The CLI is the developer's daily companion. It should be invisible when everything works and helpful when something goes wrong. With 48 tests covering every command and every edge case, FLIN's CLI delivers on that promise.

---

This is Part 171 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [170] Previous article - [171] The FLIN CLI: Build, Test, Run (you are here) - [172] The FLIN Formatter and Linting

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles