ExAlign
A Mix formatter plugin that column-aligns Elixir code, inspired by how Go's
gofmt aligns struct fields and variable declarations, which are more readable
than the output of the default Elixir code formatter.
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.
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.
Arrow-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.
Installation
As a path dependency (local development)
# mix.exs
defp deps do
[{:exalign, path: "/path/to/formatter"}]
endFrom Hex (once published)
defp deps do
[{:exalign, "~> 0.1"}]
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.
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.
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 |
--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 ./exalignOptions
Options are passed through .formatter.exs alongside the standard formatter
options. Here is a full example with all options set explicitly:
# .formatter.exs
[
plugins: [ExAlign],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
line_length: 98,
wrap_short_lines: false,
wrap_with: :backslash,
locals_without_parens: [field: :*, validate: 2]
]Only include options you need to override — unset options use their defaults.
line_length (integer, default 98)
Maximum line length forwarded to Code.format_string! and used as the threshold
for arrow-clause collapsing. When aligned macro-call lines are longer than this
value, the limit is automatically raised to the longest such line so the
formatter does not break them.
Arms whose collapsed form would exceed line_length are left expanded:
# line_length: 60
case result do
{:ok, value} -> transform_and_process(value)
{:error, reason} -> {:error, reason}
end
# line_length: 40 — first arm no longer fits inline
case result do
{:ok, value} ->
transform_and_process(value)
{:error, reason} -> {:error, reason}
end# .formatter.exs
[
plugins: [ExAlign],
line_length: 120,
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]wrap_short_lines (boolean, default false)
When true, disables the arrow-clause collapsing pass. The standard
formatter's expanded form for -> arms is preserved as-is.
# wrap_short_lines: false (default) — arms collapsed and aligned
case result do
{:ok, value} -> value
{:error, reason} -> {:error, reason}
_ -> nil
end
# wrap_short_lines: true — arms stay expanded
case result do
{:ok, value} ->
value
{:error, reason} ->
{:error, reason}
_ ->
nil
end# .formatter.exs
[
plugins: [ExAlign],
wrap_short_lines: true,
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]locals_without_parens (keyword list)
Merged with the macro names that ExAlign auto-detects. Use this to
explicitly list macros that should remain paren-free, exactly as you would for
the standard formatter.
# without locals_without_parens — formatter adds parens
preprocess(:name, &String.trim/1)
preprocess(:email, &String.downcase/1)
# with locals_without_parens: [preprocess: 2]
preprocess :name, &String.trim/1
preprocess :email, &String.downcase/1# .formatter.exs
[
plugins: [ExAlign],
locals_without_parens: [field: :*, preprocess: 2],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]Auto-detected names and explicitly listed names are merged; duplicates are removed automatically.
wrap_with (boolean or atom, default :backslash)
Controls how with blocks whose clauses span multiple lines are formatted:
| Value | Behaviour |
|---|---|
false |
Leave do at the end of the last clause (standard formatter output). |
true |
Extract do onto its own line at the with keyword's indentation level. |
:backslash |
Like true, and replace with with with \ and re-indent all clauses two spaces in. |
# wrap_with: false (standard output)
with {:ok, a} <- foo(),
{:ok, b} <- bar(a) do
{:ok, {a, b}}
end
# wrap_with: true
with {:ok, a} <- foo(),
{:ok, b} <- bar(a)
do
{:ok, {a, b}}
end
# wrap_with: :backslash (default)
with \
{:ok, a} <- foo(),
{:ok, b} <- bar(a)
do
{:ok, {a, b}}
end# .formatter.exs
[
plugins: [ExAlign],
wrap_with: true,
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]Alignment rules
| Pattern | Aligned element | Example trigger |
|---|---|---|
:keyword | space after atom key | name: value |
:assignment | = sign | var = value |
:attribute |
value after @attr | @attr value |
:arrow | => operator | "key" => value |
{:macro_arg, name} |
second argument after , | field :name, opts |
Grouping: only consecutive lines with the same indentation and same
pattern (including the same macro name for :macro_arg) are aligned together.
A blank line, a # comment, or a change in pattern or indent level always
breaks the group. A group of one line is never modified.
Running tests
mix testContributing
All change requests must be accompanied by:
- An input fixture — a minimal
.exfile placed intest/fixtures/input/that reproduces the formatting behaviour being added or changed. - An expected output fixture — the corresponding file in
test/fixtures/expected/showing exactly whatExAlignshould produce.
Once both files are in place, regenerate the expected file and confirm the test suite passes:
mix fmt.regenerate_tests
mix testPull requests that change formatting behaviour without a corresponding fixture pair will not be accepted.
Make sure that test coverage is above 90%. Check with make cover.
Requirements
-
Elixir
~> 1.13 - No external dependencies
Disclaimer
ExAlignrewrites your source files in place. While it is designed
to be idempotent and purely cosmetic, any tool that modifies code carries a risk
of introducing unexpected changes.
Use version control. Always run the formatter on a clean working tree so that you can review the diff and revert if needed.
The authors provide this software as-is, without warranty of any kind. They shall not be liable for any loss or corruption of source code, data, or other assets arising from the use of this tool. See the full disclaimer in the MIT License.
License
MIT License. Copyright (c) 2026 Serge Aleynikov. See LICENSE.