ExAlign
A column-aligning code formatter for Elixir—inspired by how Go's gofmt aligns struct fields and variable declarations, which are more readable than standard formatter output.
Looking for Erlang formatting? Check out ErlAlign, a separate rebar3-based project for Erlang code.
What it does
ExAlign runs as a pass on top of the standard Elixir formatter. It
scans consecutive lines that share the same indentation and pattern type, then
pads them so their operators and values line up vertically. It also:
-
Collapses short
->arms back to one line when they fit within the line-length limit -
Aligns
->operators vertically in case and cond blocks - Aligns complex case patterns, guards, and arrows across arms
-
Extracts
dokeywords to their own line for complex block headers - Auto-detects and registers paren-free macros from aligned groups
Keyword list / struct fields
# before
%User{name: "Alice", age: 30, occupation: "developer"}
# after (multi-line, as produced by Code.format_string!)
%User{
name: "Alice",
age: 30,
occupation: "developer"
}Variable assignments
# before
x = 1
foo = "bar"
something_long = 42
# after
x = 1
foo = "bar"
something_long = 42Module attributes
# before
@name "Alice"
@version "1.0.0"
@default_timeout 5_000
# after
@name "Alice"
@version "1.0.0"
@default_timeout 5_000Map fat-arrow entries
# before
%{"name" => "Alice", "age" => 30, "occupation" => "developer"}
# after (multi-line)
%{
"name" => "Alice",
"age" => 30,
"occupation" => "developer"
}Macro calls with an atom first argument
Consecutive calls of the same macro that follow the pattern macro :atom, rest
are kept paren-free and aligned at the second argument:
# before
field :reservation_code, function: &extract_reservation_code/1
field :guest_name, function: &extract_guest_name/1
field :check_in_date, function: &extract_check_in_date/1
field :nights, pattern: ~r/(\d+)\s+nights/, capture: :first, transform: &String.to_integer/1
# after
field :reservation_code, function: &extract_reservation_code/1
field :guest_name, function: &extract_guest_name/1
field :check_in_date, function: &extract_check_in_date/1
field :nights, pattern: ~r/(\d+)\s+nights/, capture: :first, transform: &String.to_integer/1
Macro names are auto-detected from the source: any bare macro name that
appears two or more times with this shape is automatically added to
locals_without_parens so the standard formatter does not add parentheses.
Only lines with the same macro name and same indentation form a group.
Case arm alignment
Aligns the -> operator vertically across consecutive case arms:
# before
case Regex.run(pattern, text) do
[value] -> transform.(value)
_ -> nil
end
# after
case Regex.run(pattern, text) do
[value] -> transform.(value)
_ -> nil
endCond arm alignment
Aligns the -> operator vertically across consecutive cond arms:
# before
cond do
x > 100 -> :large
x > 10 -> :medium
true -> :small
end
# after
cond do
x > 100 -> :large
x > 10 -> :medium
true -> :small
endCase block alignment (complex patterns with guards)
Aligns tuple patterns, guards, and the -> operator across case arms only
when all clauses are single-line (pattern plus body fit on one line). If any
clause has a multi-line body, the entire block is left unaligned:
# all one-liners — aligned
case {a, b} do
{nil, nil} -> :both_nil
{x, nil} when is_integer(x) -> {:left, x}
{nil, y} -> {:right, y}
{x, y} -> {x, y}
end
# mixed: last clause has multi-line body — NOT aligned
case {Keyword.get(opts, :components), Keyword.get(opts, :structs)} do
{nil, nil} ->
raise ArgumentError, "must pass either :components or :structs"
{comps, nil} when is_list(comps) ->
{comps, false}
{_, structs} when is_list(structs) ->
{structs, true}
endArrow-clause collapsing
Short -> arms (pattern + single-line body) that the standard formatter expands
are collapsed back to one line when the result fits within line_length:
# standard formatter output
case result do
{:ok, value} ->
value
{:error, _} = err ->
err
end
# ExAlign output
case result do
{:ok, value} -> value
{:error, _} = err -> err
end
Arms whose body would exceed line_length, or arms with multi-line bodies, are
left expanded.
Do extraction for complex headers
For case, cond, with, and other block expressions with complex (multi-line)
headers, the do keyword is automatically moved to its own line for readability:
# before (multi-line header)
case list
|> Enum.filter(&is_integer/1)
|> Enum.sort() do
[] -> :empty
_ -> :ok
end
# after
case list
|> Enum.filter(&is_integer/1)
|> Enum.sort()
do
[] -> :empty
_ -> :ok
endSingle-line headers are left unchanged.
With block formatting
ExAlign handles multi-line with blocks with flexible formatting options controlled
by the wrap_with configuration. When a with block spans multiple lines, you can
choose how to format it:
Standard formatter output (wrap_with: false)
with {:ok, a} <- foo(),
{:ok, b} <- bar(a) do
{:ok, {a, b}}
endDo on separate line (wrap_with: true)
with {:ok, a} <- foo(),
{:ok, b} <- bar(a)
do
{:ok, {a, b}}
endBackslash wrapping (wrap_with: :backslash, default)
Combines do extraction with a backslash after with and re-indents the clauses:
with \
{:ok, a} <- foo(),
{:ok, b} <- bar(a)
do
{:ok, {a, b}}
end
The backslash style (wrap_with: :backslash) is the default as it provides visual
clarity that the with clauses are a continuation rather than the clause structure
of normal pattern matching.
Installation
As a library dependency
Add to your mix.exs:
defp deps do
[{:exalign, "~> 0.1", only: :dev}]
endThen fetch dependencies:
mix deps.getUsage
Run the installer task to automatically create .formatter.exs in your project:
mix exalign.install
This creates .formatter.exs if it does not exist yet, or tells you how to
update it manually if a custom one is already present.
Alternatively, register the plugin in your project's .formatter.exs manually:
[
plugins: [ExAlign],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]Run the formatter as usual:
mix formatExAlign runs afterCode.format_string!, so the standard Elixir
style is preserved and column alignment is layered on top.
Programmatic usage
You can also use ExAlign directly as a library:
# Format a single code string
code = """
x = 1
foo = "bar"
something_long = 42
"""
formatted = ExAlign.format(code, line_length: 120)
# => x = 1\nfoo = "bar"\nsomething_long = 42Or in batch operations:
"lib/**/*.ex"
|> Path.wildcard()
|> Enum.each(fn file ->
code = File.read!(file)
formatted = ExAlign.format(code, line_length: 120)
File.write!(file, formatted)
end)Configuration
When using ExAlign as a formatter plugin, you can pass options in .formatter.exs:
[
plugins: [ExAlign],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
# ExAlign-specific options
line_length: 120,
wrap_with: :backslash,
eol_at_eof: :add,
trim_eol_ws: true
]
Global configuration is also supported via ~/.config/exalign/.formatter.exs:
[
line_length: 120,
wrap_with: :backslash,
eol_at_eof: :add,
trim_eol_ws: true
]Project-local options always take precedence over global configuration.
Supported options
:line_length(integer, default98)
Maximum line length. Used for both ExAlign alignment decisions and collapsing short->arms.:wrap_with(:do,:backslash, orfalse, default:backslash)
How to format multi-linewithblocks. With:backslash, a backslash continuation is inserted afterwith, and clauses are re-indented. With:do, thedokeyword is extracted to its own line. Withfalse, the standard Elixir formatter output is used unchanged.:collapse_arrow_arms(boolean, defaulttrue)
Whentrue, short->arms are collapsed to one line if they fit withinline_length.:extract_do(boolean, defaulttrue)
Whentrue, thedokeyword is extracted to its own line for complex block headers.:eol_at_eof(:add,:remove, ornil, defaultnil)
Controls the end-of-file newline handling. With:add, a trailing newline is added if not present. With:remove, any trailing newline is removed. Withnil(default), the end-of-file newline is left unchanged.:trim_eol_ws(boolean, defaulttrue)
Whentrue, trailing whitespace is trimmed from each line. Whenfalse, trailing whitespace is left untouched.
Standalone exalign executable
exalign is a self-contained escript that formats Elixir files without
requiring a Mix project. Download the latest binary from the
GitHub releases page and place it somewhere on your $PATH.
Usage
exalign [options] <file|dir> [<file|dir> ...]
Files are formatted in-place. Directories are walked recursively for *.ex and *.exs files.
Program Options
| Flag | Default | Description |
|---|---|---|
--line-length N | 98 | Maximum line length |
--wrap-short-lines | off |
Keep -> arms expanded instead of collapsing them |
--wrap-with backslash|do | backslash |
How to format multi-line with blocks |
--eol-at-eof add|remove | unset | End-of-file newline handling (unset means leave unchanged) |
--trim-eol-ws / --no-trim-eol-ws | on | Trim or don't trim trailing whitespace from each line |
--check | off | Exit 1 if any file would be changed; write nothing |
--dry-run | off | Print reformatted content to stdout; write nothing |
-s, --silent | off | Suppress stdout output (stderr warnings still shown) |
-h, --help | Print usage |
Examples
# Format all Elixir files under lib/ and test/
exalign lib/ test/
# Use a longer line limit
exalign --line-length 120 lib/
# CI check — fail if anything is out of alignment
exalign --check lib/ test/
# Preview changes without writing
exalign --dry-run lib/my_module.exBuilding from source
git clone https://github.com/saleyn/exalign.git
cd exalign
make escript # produces ./exalignLicense
MIT License - see LICENSE file