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.
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.
# before
%User{name: "Alice", age: 30, occupation: "developer"}
# after (multi-line, as produced by Code.format_string!)
%User{
name: "Alice",
age: 30,
occupation: "developer"
}# before
x = 1
foo = "bar"
something_long = 42
# after
x = 1
foo = "bar"
something_long = 42# before
@name "Alice"
@version "1.0.0"
@default_timeout 5_000
# after
@name "Alice"
@version "1.0.0"
@default_timeout 5_000# before
%{"name" => "Alice", "age" => 30, "occupation" => "developer"}
# after (multi-line)
%{
"name" => "Alice",
"age" => 30,
"occupation" => "developer"
}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/1Macro 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.
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
endArms whose body would exceed line_length, or arms with multi-line bodies, are
left expanded.
# mix.exs
defp deps do
[{:exalign, path: "/path/to/formatter"}]
enddefp deps do
[{:exalign, "~> 0.1", only: :dev}]
endThen fetch dependencies:
mix deps.getRun the installer task to automatically create .formatter.exs in your project:
mix exalign.installThis 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 after Code.format_string!, so the standard Elixir
style is preserved and column alignment is layered on top.
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.
exalign [options] <file|dir> [<file|dir> ...]
Files are formatted in-place. Directories are walked recursively for
*.ex and *.exs files.
| 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 |
# 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.exgit clone https://github.com/saleyn/exalign.git
cd exalign
make escript # produces ./exalignOptions 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.
Both the Mix plugin and the standalone exalign executable read default option
values from ~/.config/exalign/.formatter.exs when that file exists.
Project-local .formatter.exs options (or CLI flags) always take precedence
over the global file, which in turn takes precedence over built-in defaults.
This is useful for enforcing personal preferences (e.g. line_length: 120)
across all projects without touching each project's .formatter.exs.
The file must evaluate to a keyword list containing only ExAlign-recognised
keys (:line_length, :wrap_short_lines, :wrap_with). ExAlign warns on
unknown keys, non-keyword-list content, or evaluation errors, and skips the
file in those cases.
Example ~/.config/exalign/.formatter.exs:
[
line_length: 120,
wrap_short_lines: true,
wrap_with: :backslash
]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}"]
]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}"]
]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.
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}"]
]| 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.
mix testAll 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.
- Elixir
~> 1.13 - No external dependencies
ExAlign rewrites 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.
MIT License. Copyright (c) 2026 Serge Aleynikov. See LICENSE.