Back to flin
flin

The FLIN Formatter and Linting

Built-in code formatting and linting for FLIN -- no external tools needed.

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

Style arguments are the most expensive waste of engineering time in existence. Tabs versus spaces. Semicolons or not. Where to break long lines. Whether to use trailing commas. Every team has these debates, every code review surfaces them, and every minute spent arguing about formatting is a minute not spent building features.

FLIN solves this the same way Go and Rust solved it: there is one style. The formatter enforces it. The debate is over.

The Formatter Architecture

FLIN's formatter lives in src/fmt/formatter.rs and operates on the AST, not on raw text. This is a critical design choice. A text-based formatter (like early versions of Prettier) has to guess at the structure of the code. An AST-based formatter knows exactly what every token means and can make intelligent decisions about layout.

The formatting pipeline has three stages:

1. Parse the source into an AST (reusing the standard parser). 2. Format the AST into canonical text using the formatter. 3. Reinsert comments that were captured during parsing.

The public API is minimal:

pub fn format_source(source: &str) -> Result<String, String> {
    let tokens = Scanner::new(source).scan_tokens()?;
    let comments = extract_comments(&tokens);
    let program = Parser::new(tokens).parse()?;
    let formatted = format_program(&program);
    Ok(format_source_with_comments(formatted, comments))
}

pub fn format_source_with_config( source: &str, config: &FormatConfig, ) -> Result { // Same pipeline with configurable options } ```

The formatter accepts the source code as a string, parses it, formats the AST, reinserts comments, and returns the formatted string. If the source has syntax errors, formatting fails gracefully with an error message rather than producing malformed output.

Comment Preservation

Comments are the hardest part of any formatter. The AST discards them during parsing because they carry no semantic meaning -- but they carry enormous meaning to the developer. Losing comments during formatting would make the tool unusable.

FLIN handles this by capturing comments as tokens during lexing, before the parser strips them away. The scanner emits both regular comments and documentation comments as distinct token types:

fn scan_comment(&mut self) {
    let is_doc_comment = self.peek() == Some('/');
    if is_doc_comment {
        self.advance(); // consume third /
    }
    let text = self.consume_until_newline();
    if is_doc_comment {
        self.add_token(TokenKind::DocComment(text));
    } else {
        self.add_token(TokenKind::Comment(text));
    }
}

The extract_comments function collects these tokens with their line numbers before the parser processes the token stream:

struct SourceComment {
    line: usize,
    text: String,
}

fn extract_comments(tokens: &[Token]) -> Vec { tokens .iter() .filter(|t| t.kind.is_comment()) .map(|t| SourceComment { line: t.span.line, text: match &t.kind { TokenKind::Comment(s) => format!("// {}", s), TokenKind::DocComment(s) => format!("/// {}", s), _ => unreachable!(), }, }) .collect() } ```

After formatting, the format_source_with_comments function reinserts comments at the appropriate positions. Leading comments (before the first line of code) are placed at the top. Inline comments are matched to their original line positions in the formatted output.

This approach handles the common cases well: file-level comments, function-level comments, and end-of-line comments all survive formatting. The only case it cannot handle perfectly is comments that appear in the middle of an expression, which is rare enough that we consider the trade-off acceptable.

What the Formatter Normalizes

The formatter enforces a consistent style across every FLIN file:

Indentation. Four spaces per level. No tabs. This matches the convention used in the FLIN documentation and standard library.

Spacing. One space around binary operators, one space after commas, no space before semicolons. Consistent spacing inside braces and parentheses.

Line breaks. One blank line between top-level declarations (functions, entities, enums). No trailing blank lines at the end of files.

Import ordering. Imports are grouped by type (standard library, relative, package) and sorted alphabetically within each group.

Doc comments. The /// prefix is preserved and multi-line doc comments are properly indented:

/// Calculates the total price including tax.
/// Returns the price multiplied by (1 + taxRate).
fn calculateTotal(price: float, taxRate: float) -> float {
    return price * (1 + taxRate)
}

The Check Mode

For CI pipelines, the formatter supports a --check flag that verifies formatting without modifying files:

$ flin fmt --check app.flin
app.flin is not formatted

$ echo $? 1 ```

This exits with code 0 if the file is already correctly formatted and code 1 if it would change. Combined with a pre-commit hook or CI check, this ensures that no unformatted code reaches the main branch:

# Pre-commit hook
flin fmt --check $(git diff --cached --name-only --diff-filter=ACM | grep '\.flin$')

Formatting Each AST Node

The formatter handles every AST node type that FLIN supports. Each node type has a dedicated formatting function that produces canonical output. Here are the key patterns:

Entity declarations receive consistent field alignment:

entity User {
    name: text @required
    email: text @email
    age: int @min(0) @max(150)
    role: text = "user"
    created_at: time = now()
}

Function declarations normalize parameter lists and body indentation:

fn calculateDiscount(price: float, percentage: float) -> float {
    discount = price * (percentage / 100)
    return price - discount
}

Import statements are formatted with consistent spacing:

import { format, validate } from "./utils"
import { User, Product } from "models"
import * as helpers from "../lib/helpers"

Match expressions align arms and normalize arrow placement:

result = match status {
    "active" => processActive(user)
    "pending" => sendReminder(user)
    "banned" => rejectAccess(user)
    _ => handleUnknown(user)
}

View templates preserve the HTML-like structure while normalizing attribute formatting:

<div class="card">
    <h2>{title}</h2>
    <p>{description}</p>
    {if showButton}
        <button click={handleClick}>Submit</button>
    {/if}
</div>

Template Literal Formatting

Template literals required special handling in the formatter because they contain both static text and interpolated expressions. The formatter preserves the template structure while normalizing the expressions inside interpolation blocks:

// Before formatting
message=`Hello,    ${   firstName  +  " "  +  lastName   }!`

// After formatting message = Hello, ${firstName + " " + lastName}! ```

The key insight is that the static text portions of a template literal should not be modified (they represent intentional content), while the interpolated expressions should be formatted like any other expression.

Integration With the Parser

The formatter's behavior is tightly coupled to the parser's output. When we add new syntax to FLIN -- arrow functions, destructuring, pattern matching -- the formatter must be updated in the same session. This is enforced by our development process: every parser change includes a corresponding formatter change and test.

The parser changes in Session 020 illustrate this. When we added the Comment token kind to the lexer, we simultaneously updated the parser to skip comments without losing them, and updated the formatter to reinsert them. The three components moved in lockstep:

Lexer: Comment(String) token  ->  Parser: skip_comments()  ->  Formatter: reinsert comments

This co-evolution is why FLIN's formatter handles 100% of the language syntax from day one. There are no "unsupported syntax" warnings, no cases where formatting silently drops code, and no version mismatches between the parser and formatter.

CLI Integration Tests

The formatter is tested at two levels: unit tests for individual formatting functions, and CLI integration tests for the end-to-end workflow.

The CLI tests use temporary files to verify the complete pipeline:

#[test]
fn test_fmt_formats_file() {
    let temp = TempDir::new().unwrap();
    let source = temp.path().join("messy.flin");
    fs::write(&source, "x=1+2\ny  =   3").unwrap();

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

assert!(status.success());

let formatted = fs::read_to_string(&source).unwrap(); assert!(formatted.contains("x = 1 + 2")); assert!(formatted.contains("y = 3")); } ```

Six CLI tests cover the formatter: specific file formatting, check mode, file-not-found handling, wrong file extension, no-files case, and format-all-files mode. Combined with the unit tests in formatter.rs, we have confidence that formatting is correct and stable.

Why Not a Linter?

FLIN does not have a separate linter because the language design eliminates most of the issues linters catch in other languages.

No unused imports. The module system only binds names that are actually referenced. Unreferenced imports produce a type error.

No undefined variables. The type checker catches these at compile time, before the code ever runs.

No type coercion bugs. FLIN requires explicit conversions between types. There is no implicit string + number concatenation.

No semicolon confusion. FLIN does not use semicolons as statement terminators. There is nothing to lint.

No style inconsistency. The formatter handles it. Period.

The remaining category of issues that linters traditionally catch -- code complexity, naming conventions, security patterns -- are better addressed through documentation and code review than through automated tools. We may add a lint pass in the future for FLIN-specific patterns, but the language design means that most of the traditional lint rules simply do not apply.

Performance

The formatter processes a typical FLIN file in under 1 millisecond. For a project with 100 files, flin fmt completes in under 100 milliseconds. This speed comes from the AST-based approach: parsing is the expensive part, and formatting is a linear walk of the tree.

Format-on-save in editors works seamlessly at this speed. There is no perceptible delay between saving a file and seeing the formatted result, which makes the formatter feel like a natural part of the editing experience rather than an external tool that interrupts the workflow.

The formatter is one of those features that disappears when it works well. Developers stop thinking about code style entirely. They write code however they want, hit save, and the formatter produces clean, consistent output. That invisible quality-of-life improvement compounds across every file, every commit, and every code review for the lifetime of a project.

---

This is Part 172 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: - [171] The FLIN CLI: Build, Test, Run - [172] The FLIN Formatter and Linting (you are here) - [173] The .flinc Binary Format

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles