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
| Flag | Default | Description |
|---|---|---|
--check | off | Report files that would be reformatted; exit 1 if any |
--indent <n> | 4 | Indentation width in spaces |
--line-length <n> | 88 | Target 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,
},
}
| Key | Default | Description |
|---|---|---|
indent_width | 4 | Indentation width in spaces |
max_line_width | 88 | Target line length |
sort_requires | true | Sort 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.