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

“The shore gives way to the sea. And the sea, my friends, Does not dream of you.”

— Steven Erikson, Reaper’s Gale

Cerulean is an opinionated code formatter for the Teal programming language.

It enforces a consistent style automatically, so you can stop arguing about formatting and focus on the code. Pass a file or directory to ceru and it rewrites your sources in place.

A first example

Before formatting:

local renderer=require('renderer')
local entities=require("entities")
local physics  =  require("physics")

local function update(  world :World,dt:number,debug_flags:DebugFlags,render_ctx:RenderContext ) :boolean
  for _,e in ipairs(world.entities) do
    if e.active==true and e.physics~=nil then physics.step(e,dt) end
  end
  if world.frame_count>MAX_FRAMES then return false
  else return true end
end

After formatting:

local entities = require("entities")
local physics = require("physics")
local renderer = require("renderer")

local function update(
    world: World, dt: number, debug_flags: DebugFlags, render_ctx: RenderContext
): boolean
    for _, e in ipairs(world.entities) do
        if e.active == true and e.physics ~= nil then
            physics.step(e, dt)
        end
    end
    if world.frame_count > MAX_FRAMES then
        return false
    else
        return true
    end
end

In CI, run with --check to reject unformatted code.

Cerulean works with Lua 5.1 and above, including LuaJIT.

The Cerulean Code Style

“Sometimes the prize is not worth the costs. The means by which we achieve victory are as important as the victory itself.”

— Brandon Sanderson, The Way of Kings

Cerulean enforces one style. There are no knobs for bracket placement, operator spacing, or line-breaking heuristics. The rules below describe exactly what that style is.


Line length

The default target line length is 88 characters. When a construct exceeds this width, Cerulean wraps it; when a wrapped construct fits on one line, Cerulean joins it back. Change the target with --line-length or max_line_width in tlconfig.lua.


Supported Teal constructs

Cerulean understands all Teal constructs. Each construct is parsed structurally and laid out by the formatter, not passed through as text.

Leading, trailing, dangling, and inline-block comments are preserved through every wrapping decision. -- fmt: off / -- fmt: on regions are respected, and require sorting honours -- fmt: off.

What Cerulean leaves alone: long-string literals ([[...]]) and any region inside -- fmt: off are passed through verbatim.


Three levels of wrapping

For tables, function calls, and function signatures, Cerulean tries three layouts in order and picks the first that fits within the line limit. If a wrapped construct fits a shorter layout, Cerulean joins it back.

Level 1: single line. Everything on one line:

local items = {Alpha = Alpha, Beta = Beta}

Level 2: compact broken. Opening delimiter stays on the first line, items share one inner line, closing delimiter on its own line:

local items = {
    Alpha = Alpha, Beta = Beta, Gamma = Gamma, Delta = Delta, Epsilon = Epsilon
}

Level 3: one per line. Each item on its own line, with a trailing comma added:

local items = {
    first_parameter_with_a_very_long_name = ExtremelyVerboseValueAlpha,
    second_parameter_with_a_very_long_name = ExtremelyVerboseValueBeta,
    third_parameter_with_a_very_long_name = ExtremelyVerboseValueGamma,
}

Calls and signatures wrap the same way. A long call goes through these three levels:

foo.new_number(
    "long_label_here",
    110,
    nbr_lightning_bombs_selected,
    settings.set_spawn_of_lightning_bombs
)

Long signatures wrap the same way. The return type stays attached to the closing parenthesis:

function f(
    param_one: LongTypeName, param_two: AnotherLongType, param_three: YetAnotherType
): ReturnValue
end

if, while, and until header breaking

When a condition is too long to fit on one line, Cerulean breaks it around the keyword rather than wrapping the condition inline. The condition moves to an indented block between the keyword and then or do:

-- before
if input_device.is_pressed(unit.id, keymap.ACTIONS.R) and unit.handler_state_is_ready_to_apply == "ready" then
end

-- after
if
    input_device.is_pressed(unit.id, keymap.ACTIONS.R)
    and unit.handler_state_is_ready_to_apply == "ready"
then
end

while/do and repeat/until follow the same shape: keyword alone on the opening line, condition indented beneath, closing keyword (do or until) flush left.


Binary operator breaking

Long expressions break at binary operators. The operator moves to the start of the continuation line:

-- before
table.insert(parts, indentation .. self.name .. ": " .. string.format("%.1f", self.elapsed * 1000) .. "ms")

-- after
table.insert(
    parts,
    indentation
        .. self.name
        .. ": "
        .. string.format("%.1f", self.elapsed * 1000)
        .. "ms"
)

String quote normalisation

Single-quoted strings are converted to double quotes. If the string already contains a literal double-quote character, the original quoting is preserved.

-- before
local greeting = 'hello'
local message  = 'say "hello"'

-- after
local greeting = "hello"
local message  = 'say "hello"'   -- unchanged: contains a double quote

Long-string literals ([[...]]) are never modified.


Require sorting

Top-level require statements at the start of a file are sorted alphabetically by module path and consolidated into a single block with no blank lines between them. Requires that appear after any non-require statement are left alone.

-- before
local b = require("b")
local a = require("a")

local d = require("d")
local c = require("c")

-- after
local a = require("a")
local b = require("b")
local c = require("c")
local d = require("d")

Pass --no-sort-requires or set sort_requires = false in tlconfig.lua to opt out.


Indentation and spacing

  • Indentation is 4 spaces by default (configurable via --indent / indent_width). Tabs are converted to the configured space width.
  • Cerulean inserts exactly one space after commas, around binary operators, and around = in assignments and table constructors.
  • Consecutive blank lines are collapsed to at most one.
  • Trailing whitespace is removed from every line.

Inline if/else expansion

Single-line if/else bodies are expanded to the standard multi-line block form:

-- before
if n < 10 then return prefix .. n else return tostring(n) end

-- after
if n < 10 then
    return prefix .. n
else
    return tostring(n)
end

Teal type system

Cerulean normalises spacing throughout type annotations:

-- before
local f: function < T > ( value : T ) : T |   string

-- after
local f: function<T>(value: T): T | string

Trailing commas

A trailing comma on the last item of a multi-line collection is a signal to Cerulean: never collapse this to one line, even if it would fit.

Without a trailing comma, a short table gets joined:

-- before
local t = {
    a = 1,
    b = 2
}

-- after
local t = {a = 1, b = 2}

Add a trailing comma and the table stays expanded no matter how short it is:

-- before (trailing comma present)
local t = {
    a = 1,
    b = 2,
}

-- after (unchanged)
local t = {
    a = 1,
    b = 2,
}

This lets you communicate “keep this expanded” without any comment directive.

Installation

“It’s a dangerous business, Frodo, going out your door. You step onto the road, and if you don’t keep your feet, there’s no knowing where you might be swept off to.”

— J.R.R. Tolkien, The Fellowship of the Ring

luarocks install cerulean

This installs the cerulean binary and the cerulean.* Lua modules.

Dependencies: Lua 5.1+, luafilesystem, tl

Usage

“Once you’ve got a task to do, it’s better to do it than live with the fear of it.”

— Joe Abercrombie, The Blade Itself

Format files in place:

ceru src/
ceru src/myfile.tl

Check whether files would be reformatted (exits 1 if any would change):

ceru --check src/

Flags

FlagDefaultDescription
--checkoffReport files that would be reformatted; exit 1 if any
--indent <n>4Indentation width in spaces
--line-length <n>88Target line length
--no-sort-requires-Disable sorting of require statements
--help, -h-Show help message and exit
--version, -v-Print version and exit

Configuration

“Nobody’s mind ever remains a blank page, however carefully they are locked away from the world.”

— Frances Hardinge, A Face Like Glass

Options can be set project-wide in tlconfig.lua under a cerulean key. CLI flags override config file values.

return {
   cerulean = {
      indent_width = 2,
      max_line_width = 100,
      sort_requires = false,
   },
}
KeyDefaultDescription
indent_width4Indentation width in spaces
max_line_width88Target line length
sort_requirestrueSort require statements alphabetically

Formatting Directives

“Only in silence the word, only in dark the light, only in dying life: bright the hawk’s flight on the empty sky.”

— Ursula K. Le Guin, A Wizard of Earthsea

Surround regions you want left untouched with formatter directives:

-- fmt: off
local hand_formatted = {1,    2,   3,
                        100,  200, 300}
-- fmt: on

Everything between -- fmt: off and -- fmt: on is passed through verbatim. The directives themselves are preserved in the output.

Scoped usage

The directives are scope-aware. Place them inside any block to leave just that block unformatted while the rest of the file is formatted normally:

local function formatted()
    return 1 + 2
end

local function hand_crafted()
    -- fmt: off
    local matrix = {
        1, 0, 0,
        0, 1, 0,
        0, 0, 1,
    }
    return matrix
end

local function also_formatted()
    return 3 + 4
end

Limitations

“You can never know everything, and part of what you know is always wrong. Perhaps even the most important part. A portion of wisdom lies in knowing that. A portion of courage lies in going on anyway.”

― Robert Jordan, Winter’s Heart

Cerulean is a hobby project with a small user base. The author started it without a full appreciation of how hard writing a correct formatter is.

The author used agentic coding extensively throughout development as a deliberate exercise in learning and improving this workflow on a real codebase.

Known issues

The fuzzer (make fuzz) still finds idempotency failures: formatting the output of the formatter a second time sometimes produces a different result. On the other hand, the fuzzer rarely finds crashes, broken output, or cases where the formatter silently produces unparseable code. Those failure modes appear to now be uncommon.

Test coverage

Cerulean has been run against large, non-trivial Teal codebases without issues. The test suite has over 500 cases and covers a wide range of inputs.