defmodule ExAlign do @moduledoc """ A Mix formatter plugin that column-aligns Elixir code, similar to how Go's `gofmt` aligns struct fields and variable groups. ## Patterns aligned - **Keyword list entries** - `key: value` aligns after the colon - **Variable assignments** - `var = value` aligns the `=` - **Map arrow entries** - `key => value` aligns the `=>` - **Module attributes** - `@attr value` aligns the value ## Usage Add to your project's `.formatter.exs`: [ plugins: [ExAlign], inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] ] And add `exalign` to your `mix.exs` dependencies. ## Global configuration ExAlign reads default option values from `~/.config/exalign/.formatter.exs` when that file exists. The file must evaluate to a keyword list containing any ExAlign-recognised keys (`:line_length`, `:wrap_short_lines`, `:wrap_with`). Options found in the project-local `.formatter.exs` always take precedence over the global file. Example `~/.config/exalign/.formatter.exs`: [ line_length: 120, wrap_short_lines: true, wrap_with: :backslash ] """ @behaviour Mix.Tasks.Format # Elixir keywords / macros that begin expressions - never treat as assignments @elixir_keywords ~w[ def defp defmacro defmacrop defguard defguardp defmodule defprotocol defimpl defstruct defdelegate defexception defoverridable defcallback defmacrocallback if unless case cond with for receive try after rescue catch raise reraise import require use alias quote unquote unquote_splicing do end fn ] @impl Mix.Tasks.Format def features(_opts) do [extensions: [".ex", ".exs"]] end @doc """ Returns the ExAlign options loaded from the global config file `~/.config/exalign/.formatter.exs`, or an empty list if the file does not exist or cannot be evaluated. """ @global_config_path Path.expand("~/.config/exalign/.formatter.exs") @supported_global_options ~w[line_length wrap_short_lines wrap_with]a @standard_formatter_options ~w[locals_without_parens inputs plugins subdirectories import_deps]a @ignored_options ~w[extension file sigils]a def load_global_config do path = @global_config_path if File.regular?(path) do {result, _bindings} = Code.eval_file(path) if Keyword.keyword?(result) do validate_options(result, "#{path}", @supported_global_options) else IO.warn("exalign: #{path} must evaluate to a keyword list — ignoring") [] end else [] end rescue e -> IO.warn("exalign: could not load #{@global_config_path}: #{Exception.message(e)} — ignoring") [] end @impl Mix.Tasks.Format @doc """ Formats the given Elixir source `contents` string, applying column alignment on top of the standard `Code.format_string!` pass. `opts` may include any standard formatter options plus the ExAlign-specific keys `:wrap_short_lines`, `:wrap_with`, and `:line_length`. """ def format(contents, opts) do opts = Keyword.merge(load_global_config(), validate_options(opts, ".formatter.exs")) # Before running the standard formatter, detect any macro names that appear in # aligned groups (e.g. `field :name, opts`). We add them to # `locals_without_parens` so Code.format_string! leaves them paren-free, and # we raise `line_length` to the longest such line so the formatter does not # break aligned one-liners into multi-line form. formatting_opts = build_formatting_opts(opts, contents) formatted = contents |> Code.format_string!(formatting_opts) |> IO.iodata_to_binary() line_length = Keyword.get(opts, :line_length, 98) if Keyword.get(opts, :wrap_short_lines, false) do formatted |> extract_do_to_own_line(opts) |> realign_pipe_chains() |> align_case_blocks(line_length) |> align_columns() else formatted |> collapse_one_liners(opts) |> extract_do_to_own_line(opts) |> realign_pipe_chains() |> collapse_one_liners(opts) |> align_case_blocks(line_length) |> align_columns() end end # Warn about unrecognised ExAlign options in `opts` (from `source`). # When `filter_keys` is a list, only those keys are considered known and the # returned keyword list is filtered to that set. When `filter_keys` is `nil` # (default), all standard formatter keys are also considered known and the # full opts keyword list is returned unchanged. defp validate_options(opts, source, filter_keys \\ nil) do known = List.wrap(filter_keys) ++ @supported_global_options ++ @standard_formatter_options unknown = opts |> Keyword.keys() |> Enum.reject(&(&1 in @ignored_options)) |> Enum.reject(&(&1 in known)) unknown == [] || IO.warn("exalign: #{source} contains unsupported option(s): #{Enum.map_join(unknown, ", ", &inspect/1)}") filter_keys && Keyword.take(opts, filter_keys ++ @supported_global_options) || opts end # Build Code.format_string! opts that prevent it from adding parens to or # breaking up lines that are part of aligned macro-call groups. defp build_formatting_opts(opts, contents) do aligned_macros = detect_aligned_macros(contents) existing_lwp = Keyword.get(opts, :locals_without_parens, []) new_lwp = aligned_macros |> Enum.map(&{&1, :*}) |> Kernel.++(existing_lwp) |> Enum.uniq_by(&elem(&1, 0)) # Find the longest line that looks like a macro-arg-aligned call so we can # ensure Code.format_string! does not break it. max_aligned_len = contents |> String.split("\n") |> Enum.filter(fn line -> stripped = String.trim_leading(line) Regex.match?(~r/^[a-z_]\w*\s+:\w+,\s+\S/, stripped) end) |> Enum.map(&String.length/1) |> case do [] -> 0 lengths -> Enum.max(lengths) end effective_line_length = max(Keyword.get(opts, :line_length, 98), max_aligned_len) opts |> Keyword.put(:locals_without_parens, new_lwp) |> Keyword.put(:line_length, effective_line_length) end # Return the atom names of macros that appear 2+ times in the source with # the shape `macro :atom, rest` (potential alignment group members). defp detect_aligned_macros(code) do code |> String.split("\n") |> Enum.flat_map(fn line -> stripped = String.trim_leading(line) case Regex.run(~r/^([a-z_]\w*)\s+:\w+,\s+\S/, stripped) do [_, macro] when macro not in @elixir_keywords -> [macro] _ -> [] end end) |> Enum.frequencies() |> Enum.filter(fn {_name, count} -> count >= 2 end) |> Enum.map(fn {name, _} -> String.to_atom(name) end) end # --------------------------------------------------------------------------- # Collapse short one-liner arrow clauses (case/cond/fn/with-else arms) # --------------------------------------------------------------------------- defp collapse_one_liners(code, opts) do line_length = Keyword.get(opts, :line_length, 98) code |> String.split("\n") |> do_collapse(line_length, []) |> Enum.join("\n") end defp do_collapse([], _ll, acc), do: Enum.reverse(acc) defp do_collapse([line], _ll, acc), do: Enum.reverse([line | acc]) defp do_collapse([line_a, line_b | rest], ll, acc) do indent_a = get_indent(line_a) indent_b = get_indent(line_b) next_indent = case rest do [next | _] -> get_indent(next) [] -> 0 end arrow_head? = String.ends_with?(String.trim_trailing(line_a), " ->") and indent_b == indent_a + 2 and (rest == [] or next_indent <= indent_a) # An inline lambda body followed by an orphaned `end` / `end)` / `end,` at the # same indent level. This arises after collapsing `fn x ->\n body\nend)` to # `fn x -> body\nend)` - we need one more step to pull `end)` onto the same line. orphan_end? = not arrow_head? and indent_b == indent_a and String.contains?(line_a, " -> ") and String.match?(String.trim_leading(line_b), ~r/^end[\s),|>]*$/) and not String.ends_with?(String.trim_trailing(line_a), " ->") # When the line after the body is an orphan `end)/end,` at `indent_a`, we must # either collapse the full form `fn -> body end)` in one go, or not collapse at # all — a partial collapse (body inline, `end)` on next line) is malformed. next_is_orphan_end? = case rest do [l | _] -> String.match?(String.trim_leading(l), ~r/^end[\s),]*$/) and get_indent(l) == indent_a _ -> false end cond do arrow_head? and next_is_orphan_end? -> # Attempt full collapse: `fn -> body end)` body_part = String.trim_trailing(line_a) <> " " <> String.trim_leading(line_b) end_part = String.trim_leading(hd(rest)) collapsed = body_part <> " " <> end_part if String.length(collapsed) <= ll do # Skip the orphan `end)` line too do_collapse(tl(rest), ll, acc, collapsed) else # Full form doesn't fit — leave expanded (don't partial-collapse) do_collapse([line_b | rest], ll, [line_a | acc]) end arrow_head? -> collapsed = String.trim_trailing(line_a) <> " " <> String.trim_leading(line_b) if String.length(collapsed) <= ll do do_collapse([collapsed | rest], ll, acc) else do_collapse([line_b | rest], ll, [line_a | acc]) end orphan_end? -> collapsed = String.trim_trailing(line_a) <> " " <> String.trim_leading(line_b) if String.length(collapsed) <= ll do do_collapse([collapsed | rest], ll, acc) else do_collapse([line_b | rest], ll, [line_a | acc]) end true -> do_collapse([line_b | rest], ll, [line_a | acc]) end end # Variant that injects an already-collapsed line as next to process. defp do_collapse(rest, ll, acc, collapsed_line) do do_collapse([collapsed_line | rest], ll, acc) end # --------------------------------------------------------------------------- # Re-indent pipe chains that follow block keywords # --------------------------------------------------------------------------- # Code.format_string! aligns `|>` continuations directly under the expression # that follows the keyword, e.g. `case ` (5 chars) -> pipes at kw_indent+5. # We normalise them to kw_indent+2. All sub-lines (fn bodies, end)) inside # the chain are shifted by the same delta. defp realign_pipe_chains(code) do lines = String.split(code, "\n") do_realign_pipes(lines, []) |> Enum.join("\n") end defp do_realign_pipes([], acc), do: Enum.reverse(acc) defp do_realign_pipes([line | rest], acc) do stripped = String.trim_leading(line) kw_indent = get_indent(line) is_block_kw? = case Regex.run(~r/^(case|if|cond|with|for|receive|try|unless)\b/, stripped) do [_, _] -> true _ -> false end if is_block_kw? do case rest do [next | _] -> next_stripped = String.trim_leading(next) next_indent = get_indent(next) desired = kw_indent + 2 if String.starts_with?(next_stripped, "|>") and next_indent > desired do delta = desired - next_indent {block, after_block} = collect_pipe_block(rest, next_indent, kw_indent) realigned = Enum.map(block, &reindent_by(&1, delta)) do_realign_pipes(after_block, Enum.reverse(realigned) ++ [line | acc]) else do_realign_pipes(rest, [line | acc]) end [] -> Enum.reverse([line | acc]) end else do_realign_pipes(rest, [line | acc]) end end # Collect lines belonging to the pipe-chain expression: all lines at indent # >= pipe_indent (including fn bodies, end) etc.), stopping before the bare # `do` line at kw_indent or any line shallower than pipe_indent. defp collect_pipe_block([], _pipe_indent, _kw_indent), do: {[], []} defp collect_pipe_block([line | rest] = all, pipe_indent, kw_indent) do stripped = String.trim_leading(line) line_indent = get_indent(line) cond do stripped == "" -> {[], all} line_indent == kw_indent and String.trim(line) == "do" -> {[], all} line_indent >= pipe_indent -> {block, after_block} = collect_pipe_block(rest, pipe_indent, kw_indent) {[line | block], after_block} true -> {[], all} end end defp reindent_by(line, delta) do if String.trim(line) == "" do line else new_indent = max(0, get_indent(line) + delta) String.duplicate(" ", new_indent) <> String.trim_leading(line) end end # --------------------------------------------------------------------------- # Move "do" to its own line when the block header spans multiple lines # --------------------------------------------------------------------------- # Code.format_string! writes e.g.: # # case expr # |> foo() # |> bar() do # # We transform that to: # # case expr # |> foo() # |> bar() # do # # The `do` is placed at the indentation of the enclosing block keyword. # # A line is a "continuation do-line" when: # - it ends with " do" (after trimming trailing whitespace), AND # - it does NOT start (after trimming leading whitespace) with one of the # block keywords (case/if/cond/with/for/receive/try/unless), meaning the # `do` was tacked onto a continuation expression rather than the # keyword-head line itself. @block_keywords ~w[case if cond with for receive try unless] defp extract_do_to_own_line(code, opts) do wrap_with = Keyword.get(opts, :wrap_with, :backslash) lines = String.split(code, "\n") lines |> do_extract_do(wrap_with, [], []) |> Enum.join("\n") end # We accumulate lines into `buf` (newest-first) looking for a multi-line # block header. When we see a ` do` terminus, we scan `buf` backwards to find # the keyword line that opened the expression. defp do_extract_do([], _swd, _buf, acc), do: Enum.reverse(acc) defp do_extract_do([line | rest], wrap_with, _buf, acc) do trimmed = String.trim_trailing(line) if String.ends_with?(trimmed, " do") and is_continuation_do_line?(trimmed, acc, wrap_with) do # Strip " do" from this line body = String.slice(trimmed, 0..(String.length(trimmed) - 4)) body_line = if String.trim(body) == "", do: nil, else: body # Find the keyword indentation by scanning backward through acc for the # block-keyword line that this expression belongs to. kw_indent = find_keyword_indent(acc, get_indent(line)) do_line = String.duplicate(" ", kw_indent) <> "do" {new_lines, new_acc} = if wrap_with == :backslash and is_with_clause_line?(trimmed, acc) do # Rewrite: replace `with FIRST_CLAUSE,` line with `with \` + re-indented # first clause; re-indent all subsequent clause lines to kw_indent+2. clause_indent = String.duplicate(" ", kw_indent + 2) {rewritten_acc, _found} = Enum.map_reduce(acc, false, fn l, done -> if not done and String.match?(String.trim_leading(l), ~r/^with\b/) do # Strip the first clause off the `with` line and emit just `with \` with_prefix = String.duplicate(" ", get_indent(l)) <> "with \\" first_clause_stripped = Regex.replace(~r/^(\s*)with\s+/, l, "") first_clause_line = clause_indent <> String.trim_leading(first_clause_stripped) # Return a two-element list — map_reduce expects one element per input, # so we collect them as a tagged tuple and flatten after. {{:split, with_prefix, first_clause_line}, true} else old_ind = get_indent(l) if not done and old_ind > kw_indent do {clause_indent <> String.trim_leading(l), false} else {l, done} end end end) # Flatten {:split, ...} tuples back to individual lines. # acc is newest-first, so first_clause_line must precede with_prefix # in the list so that with_prefix appears first after Enum.reverse. flat_acc = Enum.flat_map(rewritten_acc, fn {:split, a, b} -> [b, a] l -> [l] end) last_clause = if body_line, do: clause_indent <> String.trim_leading(body), else: nil new_ls = if last_clause, do: [do_line, last_clause], else: [do_line] {new_ls, flat_acc} else new_ls = if body_line do [do_line, body_line] else [do_line] end {new_ls, acc} end do_extract_do(rest, wrap_with, [], new_lines ++ new_acc) else do_extract_do(rest, wrap_with, [], [line | acc]) end end # Returns true if the line whose trailing " do" we're inspecting is a # continuation line (not the keyword-head itself). defp is_continuation_do_line?(trimmed, acc, wrap_with) do stripped = String.trim_leading(trimmed) # Check for block keyword with do at the end case Regex.run(~r/(case|if|cond|with|for|receive|try|unless)\s+(.+?)\s+do$/, stripped) do [_full, _keyword, content] -> # If content has pipes or other complex expressions, allow wrapping String.contains?(content, "|>") nil -> # Not a block keyword with do, check other patterns cond do # Pipe continuation — check if it's part of a complex block String.match?(stripped, ~r/^\|>/) -> # Is this a with clause? (always extract) if is_with_clause_line?(trimmed, acc) do true else # Is this a pipe continuation of case/if/cond/etc with multiple lines? # If the parent block keyword is on a separate line, it's multi-line, so extract has_multiline_block_parent?(acc) end # Last clause of a `with` block: contains ` <- ` and a `with` ancestor exists wrap_with in [true, :backslash] and String.contains?(trimmed, " <- ") -> is_with_clause_line?(trimmed, acc) true -> false end end end # Check if this line is part of a multi-line case/if/cond/etc block # by looking for a parent block keyword (case/if/cond/etc) at lesser indentation # that doesn't have 'do' on the same line defp has_multiline_block_parent?(acc) do # Determine current indentation from the most recent accumulated line current_line_indent = case acc do [tail | _] -> get_indent(tail) [] -> 0 end # Look through accumulated lines for a parent block keyword Enum.any?(acc, fn line -> stripped = String.trim_leading(line) line_indent = get_indent(line) # A parent must be at lesser indentation AND be a block keyword without do on same line line_indent < current_line_indent and Regex.match?(~r/^(case|if|cond|for|receive|try|unless)\b/, stripped) and not String.ends_with?(String.trim_trailing(line), " do") end) end defp is_with_clause_line?(trimmed, acc) do ind = get_indent(trimmed) Enum.any?(acc, fn l -> get_indent(l) < ind and String.match?(String.trim_leading(l), ~r/^with\b/) end) end # Scan backward through already-emitted lines (acc is newest-first) looking # for an enclosing block keyword at an indent level <= the continuation indent. defp find_keyword_indent(acc, cont_indent) do acc |> Enum.find(fn line -> ind = get_indent(line) stripped = String.trim_leading(line) first_word = case Regex.run(~r/^([a-z_]\w*)/, stripped) do [_, w] -> w _ -> "" end ind < cont_indent and first_word in @block_keywords end) |> case do nil -> cont_indent line -> get_indent(line) end end # --------------------------------------------------------------------------- # Case-block arm alignment # --------------------------------------------------------------------------- # Recognises a sequence of case/cond arms at the same indentation level and # column-aligns: # 1. Elements inside tuple patterns e.g. {nil, nil}, {comps, nil} # 2. Guards after the pattern e.g. `when is_list(comps)` # 3. The `->` operator # 4. Bodies: inline when they fit within line_length (majority rule), # otherwise kept on the next indented line. defp align_case_blocks(code, line_length) do lines = String.split(code, "\n") lines |> do_align_case_blocks(line_length, []) |> Enum.join("\n") end # Walk through lines collecting arms into blocks then emitting them. defp do_align_case_blocks([], _ll, acc), do: Enum.reverse(acc) defp do_align_case_blocks([line | rest], ll, acc) do # An arm head is: pattern [guard] -> # where indent is at least 2 (inside a case/cond block). case parse_arm_head(line) do {:ok, indent} -> # Collect the full block of consecutive arms at this indent level {block_lines, after_block} = collect_arm_block([line | rest], indent) if length(block_lines) >= 2 do aligned = align_arm_block(block_lines, indent, ll) do_align_case_blocks(after_block, ll, Enum.reverse(aligned) ++ acc) else do_align_case_blocks(rest, ll, [line | acc]) end :error -> do_align_case_blocks(rest, ll, [line | acc]) end end # Returns {:ok, indent_size} if the line looks like a case arm head. # An arm head matches: pattern [when guard] -> # and the pattern must not be a lone bare keyword like `do`, `end`, etc. defp parse_arm_head(line) do stripped = String.trim_leading(line) indent = String.length(line) - String.length(stripped) cond do # Must be indented (inside a block) and end with -> indent < 2 -> :error # Matches: anything followed by optional guard then " ->" Regex.match?(~r/^.+\s+->\s*$/, stripped) -> {:ok, indent} # Collapsed arm: pattern [guard] -> body Regex.match?(~r/^.+\s+->\s+\S/, stripped) -> {:ok, indent} true -> :error end end # Collect an arm block: consecutive arm heads + their bodies at the given # indent level. Returns {arm_lines_flat, remaining_lines}. # arm_lines_flat is a list of {head_line, [body_lines]} tuples flattened back # to line lists; we keep it as a flat list here and re-parse below. defp collect_arm_block([], _indent), do: {[], []} defp collect_arm_block([line | rest] = all, indent) do stripped = String.trim_leading(line) cond do # Skip blank separator lines between arms stripped == "" -> {more_block, final_rest} = collect_arm_block(rest, indent) # Only include blank if we found more arms if more_block != [] do {more_block, final_rest} else {[], all} end true -> case parse_arm_head(line) do {:ok, ^indent} -> {body, after_body} = consume_body(rest, indent) {more_block, final_rest} = collect_arm_block(after_body, indent) {[{line, body} | more_block], final_rest} _ -> {[], all} end end end # Consume lines that are body lines: indented more than arm indent. # Blank lines between arms are NOT consumed - they're arm separators. defp consume_body([], _indent), do: {[], []} defp consume_body([line | rest] = all, indent) do stripped = String.trim_leading(line) line_indent = String.length(line) - String.length(stripped) cond do # Blank line - stop; blank lines separate arms, not part of the body stripped == "" -> {[], all} # Body line (deeper indent) line_indent > indent -> {body, tail} = consume_body(rest, indent) {[line | body], tail} true -> {[], all} end end # Take a list of {head_line, [body_lines]} arm tuples and produce aligned output lines. defp align_arm_block(arms, indent, ll) do prefix = String.duplicate(" ", indent) # Parse each head: split on first " -> " or trailing " ->" parsed = Enum.map(arms, fn {head, body} -> stripped = String.trim_leading(head) case Regex.run(~r/^(.*?)\s+->\s*(.*)$/, stripped) do [_, lhs, rhs] -> lhs_trimmed = String.trim_trailing(lhs) rhs_trimmed = String.trim(rhs) # Split lhs into pattern + optional guard {pattern, guard} = case Regex.run(~r/^(.*?)\s+(when\s+.+)$/, lhs_trimmed) do [_, pat, g] -> {String.trim_trailing(pat), g} _ -> {lhs_trimmed, nil} end {pattern, guard, rhs_trimmed, body} _ -> nil end end) if Enum.any?(parsed, &is_nil/1) do Enum.flat_map(arms, fn {head, body} -> [head | body] end) else # If any arm has a multi-line body, keep all arms expanded but still # align tuple patterns within the heads. any_multiline_body = Enum.any?(arms, fn {_head, body} -> length(body) > 1 end) if any_multiline_body do # Align tuple patterns and guards but do NOT pad -> to a common column # and do NOT collapse bodies inline. patterns = Enum.map(parsed, &elem(&1, 0)) aligned_patterns = align_tuple_patterns(patterns) max_pat_len = aligned_patterns |> Enum.map(&String.length/1) |> Enum.max() Enum.zip(aligned_patterns, parsed) |> Enum.flat_map(fn {aligned_pat, {_pat, guard, rhs, body}} -> head_lhs = case guard do nil -> aligned_pat g -> pad = String.duplicate(" ", max_pat_len - String.length(aligned_pat) + 1) "#{aligned_pat}#{pad}#{g}" end if rhs == "" do # Multi-line body arm: head ends with -> ["#{prefix}#{head_lhs} ->"] ++ body else # Already-collapsed single-line arm: keep as expanded (body on next line) body_prefix = String.duplicate(" ", indent + 2) ["#{prefix}#{head_lhs} ->", "#{body_prefix}#{rhs}"] ++ body end end) else patterns = Enum.map(parsed, &elem(&1, 0)) aligned_patterns = align_tuple_patterns(patterns) # max aligned-pattern length max_pat_len = aligned_patterns |> Enum.map(&String.length/1) |> Enum.max() # Build "pattern [pad] guard" lhs strings lhs_strings = Enum.zip(aligned_patterns, parsed) |> Enum.map(fn {aligned_pat, {_pat, guard, _rhs, _body}} -> case guard do nil -> aligned_pat g -> pad = String.duplicate(" ", max_pat_len - String.length(aligned_pat) + 1) "#{aligned_pat}#{pad}#{g}" end end) max_lhs_len = lhs_strings |> Enum.map(&String.length/1) |> Enum.max() # Determine which arms can be collapsed inline can_collapse = Enum.zip(lhs_strings, parsed) |> Enum.map(fn {lhs, {_pat, _guard, rhs, body}} -> rhs != "" and body == [] and String.length("#{prefix}#{lhs}#{String.duplicate(" ", max_lhs_len - String.length(lhs) + 1)}-> #{rhs}") <= ll end) # If ANY collapsible arm doesn't fit, expand all any_cant_inline = Enum.any?(Enum.zip(parsed, can_collapse), fn {{_pat, _guard, rhs, body}, can} -> rhs != "" and body == [] and not can end) Enum.zip([lhs_strings, parsed, can_collapse]) |> Enum.flat_map(fn {lhs, {_pat, _guard, rhs, body}, can} -> pad = String.duplicate(" ", max_lhs_len - String.length(lhs) + 1) if rhs == "" do # Head was already multi-line (blank rhs) - keep body indented below ["#{prefix}#{lhs}#{pad}->"] ++ body else collapsed = "#{prefix}#{lhs}#{pad}-> #{rhs}" if can and not any_cant_inline do [collapsed] else # Keep body on next line (re-indent to indent+2) body_prefix = String.duplicate(" ", indent + 2) ["#{prefix}#{lhs}#{pad}->", "#{body_prefix}#{rhs}"] ++ body end end end) end end end # Align all-tuple patterns of the same arity by padding each field. defp align_tuple_patterns(patterns) do # Check whether all patterns are simple tuples (no nested structures to parse) parsed = Enum.map(patterns, &parse_simple_tuple/1) case parsed do [first | _] when not is_nil(first) -> arity = length(first) if Enum.all?(parsed, fn p -> not is_nil(p) and length(p) == arity end) do # Compute max width per field position max_widths = Enum.reduce(parsed, List.duplicate(0, arity), fn fields, acc -> Enum.zip(fields, acc) |> Enum.map(fn {f, m} -> max(String.length(String.trim(f)), m) end) end) Enum.map(parsed, fn fields -> last_idx = length(fields) - 1 # Build segments: each non-last field contributes "field, " padded so # the next field starts at a fixed column. The last field has no trailing pad. segments = fields |> Enum.with_index() |> Enum.map(fn {f, idx} -> trimmed = String.trim(f) w = Enum.at(max_widths, idx) if idx == last_idx do trimmed else # Comma sits right after the value; pad after the comma+space so # the following field starts at column (w + 2): "field, next" pad = String.duplicate(" ", w - String.length(trimmed)) "#{trimmed},#{pad} " end end) "{#{Enum.join(segments, "")}}" end) else patterns end _ -> patterns end end # Parse "{a, b, c}" into ["a", "b", "c"], handling nested parens/brackets. # Returns nil if the pattern is not a simple tuple literal. defp parse_simple_tuple(str) do s = String.trim(str) if String.starts_with?(s, "{") and String.ends_with?(s, "}") do inner = s |> String.slice(1..-2//1) |> String.trim() split_tuple_fields(inner) else nil end end defp split_tuple_fields(inner) do # Split on "," but only at nesting depth 0 {fields, current, depth} = inner |> String.graphemes() |> Enum.reduce({[], "", 0}, fn ch, {fields, cur, depth} -> case ch do c when c in ["(", "[", "{"] -> {fields, cur <> c, depth + 1} c when c in [")", "]", "}"] -> {fields, cur <> c, depth - 1} "," when depth == 0 -> {fields ++ [cur], "", depth} _ -> {fields, cur <> ch, depth} end end) if depth == 0 do all_fields = fields ++ [current] if Enum.any?(all_fields, &(&1 == "")), do: nil, else: all_fields else nil end end # --------------------------------------------------------------------------- # Top-level pipeline # --------------------------------------------------------------------------- defp align_columns(code) do code |> String.split("\n") |> lines_to_groups() |> Enum.flat_map(&align_group/1) |> Enum.join("\n") end # Group consecutive lines that share the same alignment type *and* indentation. # Blank lines and comment lines at the same indentation are treated as # transparent: they are absorbed into the current group rather than breaking # it, provided the next non-blank/comment line continues the same pattern. # Groups are accumulated in newest-first order, then reversed at the end. defp lines_to_groups(lines) do lines |> Enum.reduce([], fn line, groups -> case groups do [] -> [[line]] [current | rest] -> group_type = effective_group_type(current) group_indent = effective_group_indent(current) cond do # Pure blank line inside an active keyword/arrow/attribute group: absorb. String.trim(line) == "" and group_type != :other -> [current ++ [line] | rest] # Comment at same indent as an active keyword group: absorb. other_line?(line) and group_type != :other and get_indent(line) == group_indent -> [current ++ [line] | rest] # Same type and indent as current group: absorb. same_group?(List.last(current), line) -> [current ++ [line] | rest] # Current group ends in blanks/comments; the new line may still # belong to the same group if it matches the group's effective type. group_type != :other and line_type(line) == group_type and get_indent(line) == group_indent -> [current ++ [line] | rest] true -> [[line] | groups] end end end) |> Enum.reverse() end # Type of the last non-:other line in the group (or :other if none). defp effective_group_type(lines) do lines |> Enum.reverse() |> Enum.find_value(:other, fn l -> t = line_type(l) if t != :other, do: t end) end # Indent of the first non-:other line in the group (or 0 if none). defp effective_group_indent(lines) do case Enum.find(lines, fn l -> line_type(l) != :other end) do nil -> 0 line -> get_indent(line) end end defp other_line?(line) do line_type(line) == :other end defp same_group?(line_a, line_b) do type_a = line_type(line_a) type_b = line_type(line_b) type_a != :other and type_a == type_b and get_indent(line_a) == get_indent(line_b) end # --------------------------------------------------------------------------- # Line classification # --------------------------------------------------------------------------- # Returns the alignment "type" for a line: # :attribute -> @attr value # :keyword -> key: value (keyword list / struct field) # :arrow -> key => value # :case_arm -> pattern -> body (case/cond/fn arm, one-liner) # :assignment -> var = value # :tuple_entry -> {:atom, value, ...},? # :other -> everything else (blank, comment, unrecognised) defp line_type(line) do stripped = String.trim_leading(line) cond do stripped == "" or String.starts_with?(stripped, "#") -> :other # Module attribute with a value (not a call like @spec, which uses parens) Regex.match?(~r/^@\w+\s+(?!\()/, stripped) -> :attribute # Keyword list / struct literal entry: some_key: value Regex.match?(~r/^[a-z_]\w*[?!]?:\s+\S/, stripped) -> :keyword # Map arrow entry: key => value (key may be any expression) Regex.match?(~r/^\S.*?\s+=>\s+\S/, stripped) -> :arrow # Case/cond/fn arm with body on same line: pattern -> body not elixir_keyword?(stripped) and Regex.match?(~r/^.+\s+->\s+\S/, stripped) -> :case_arm # Macro call with atom first arg: macro :atom, rest (e.g. field :name, opts) not elixir_keyword?(stripped) and Regex.match?(~r/^[a-z_]\w*\s+:\w+,\s+\S/, stripped) -> case Regex.run(~r/^([a-z_]\w*)/, stripped) do [_, macro_name] -> {:macro_arg, macro_name} _ -> :other end # Tuple entry starting with an atom: {:atom, ...},? Regex.match?(~r/^\{:\w+[?!]?,\s+.+\}\s*,?\s*$/, stripped) -> :tuple_entry # Simple variable assignment: var = value (not ==, !=, <=, >=, =>) Regex.match?(~r/^[a-z_]\w*\s+=(?![>=])\s+\S/, stripped) and not elixir_keyword?(stripped) -> :assignment true -> :other end end defp elixir_keyword?(stripped) do case Regex.run(~r/^([a-z_]\w*)/, stripped) do [_, word] -> word in @elixir_keywords _ -> false end end defp get_indent(line) do String.length(line) - String.length(String.trim_leading(line)) end # --------------------------------------------------------------------------- # Group alignment # --------------------------------------------------------------------------- defp align_group([]), do: [] defp align_group([line]), do: [line] defp align_group(lines) do type = effective_group_type(lines) # Separate :other lines (blanks/comments), align only the typed lines, # then re-insert the :other lines at their original positions. {typed_lines, other_positions} = lines |> Enum.with_index() |> Enum.split_with(fn {line, _idx} -> line_type(line) != :other end) aligned_typed = do_align(Enum.map(typed_lines, &elem(&1, 0)), type) # Reconstruct full list in original order typed_with_idx = Enum.zip(aligned_typed, Enum.map(typed_lines, &elem(&1, 1))) other_with_idx = Enum.map(other_positions, fn {line, idx} -> {line, idx} end) (typed_with_idx ++ other_with_idx) |> Enum.sort_by(&elem(&1, 1)) |> Enum.map(&elem(&1, 0)) end # Keyword list: align the space after the colon # before: name: "Alice", # age: 30, # occupation: "dev" # # after: name: "Alice", # age: 30, # occupation: "dev" defp do_align(lines, :keyword) do parsed = Enum.map(lines, fn line -> case Regex.run(~r/^(\s*)([a-z_]\w*[?!]?:)\s+(.*)$/, line) do [_, indent, key, value] -> {indent, key, value} _ -> nil end end) if Enum.all?(parsed, & &1) do max_len = parsed |> Enum.map(fn {_, key, _} -> String.length(key) end) |> Enum.max() Enum.map(parsed, fn {indent, key, value} -> pad = String.duplicate(" ", max_len - String.length(key) + 1) "#{indent}#{key}#{pad}#{value}" end) else lines end end # Variable assignment: align the = sign defp do_align(lines, :tuple_entry) do parsed = Enum.map(lines, fn line -> case Regex.run(~r/^(\s*)\{(.+)\}(,?)\s*$/, line) do [_, indent, contents, trailing] -> {:ok, indent, split_tuple_elements(contents), trailing} _ -> :error end end) if Enum.all?(parsed, &match?({:ok, _, _, _}, &1)) do all_elements = Enum.map(parsed, fn {:ok, _, elems, _} -> elems end) max_cols = all_elements |> Enum.map(&length/1) |> Enum.max() # Max width per column position across all rows col_widths = Enum.map(0..(max_cols - 2), fn col -> all_elements |> Enum.flat_map(fn elems -> case Enum.at(elems, col) do nil -> [] elem -> [String.length(elem)] end end) |> Enum.max() end) Enum.map(parsed, fn {:ok, indent, elems, trailing} -> parts = elems |> Enum.with_index() |> Enum.map(fn {elem, idx} -> if idx < length(elems) - 1 do max_w = Enum.at(col_widths, idx, String.length(elem)) pad = String.duplicate(" ", max_w - String.length(elem)) "#{elem},#{pad}" else elem end end) "#{indent}{#{Enum.join(parts, " ")}}#{trailing}" end) else lines end end defp do_align(lines, :assignment) do parsed = Enum.map(lines, fn line -> case Regex.run(~r/^(\s*)([a-z_]\w*)\s*=(?![>=])\s*(.*)$/, line) do [_, indent, var, value] -> {indent, var, value} _ -> nil end end) if Enum.all?(parsed, & &1) do max_len = parsed |> Enum.map(fn {_, var, _} -> String.length(var) end) |> Enum.max() aligned = Enum.map(parsed, fn {indent, var, value} -> pad = String.duplicate(" ", max_len - String.length(var) + 1) {indent, "#{indent}#{var}#{pad}= #{value}", value} end) # Second-level: if all RHS are `SamePrefix:atom, value)`, align atom and value columns rhs_parsed = Enum.map(aligned, fn {_indent, _line, value} -> case Regex.run(~r/^(.*,\s*)(:\w+),\s*(.+)\)$/, value) do [_, prefix, atom, val] -> {prefix, atom, val} _ -> nil end end) final_lines = if Enum.all?(rhs_parsed, & &1) do prefixes = rhs_parsed |> Enum.map(&elem(&1, 0)) if length(Enum.uniq(prefixes)) == 1 do max_atom_len = rhs_parsed |> Enum.map(fn {_, a, _} -> String.length(a) end) |> Enum.max() Enum.zip(aligned, rhs_parsed) |> Enum.map(fn {{_indent, line, value}, {prefix, atom, val}} -> base = String.slice(line, 0, String.length(line) - String.length(value)) atom_pad = String.duplicate(" ", max_atom_len - String.length(atom) + 1) "#{base}#{prefix}#{atom},#{atom_pad}#{val})" end) else Enum.map(aligned, &elem(&1, 1)) end else Enum.map(aligned, &elem(&1, 1)) end final_lines else lines end end # Module attribute: align the value # before: @moduledoc "…" # @name "Alice" # @default_timeout 5000 # # after: @moduledoc "…" # @name "Alice" # @default_timeout 5000 defp do_align(lines, :attribute) do parsed = Enum.map(lines, fn line -> case Regex.run(~r/^(\s*)(@\w+)\s+(.*)$/, line) do [_, indent, attr, value] -> {indent, attr, value} _ -> nil end end) if Enum.all?(parsed, & &1) do max_len = parsed |> Enum.map(fn {_, attr, _} -> String.length(attr) end) |> Enum.max() Enum.map(parsed, fn {indent, attr, value} -> pad = String.duplicate(" ", max_len - String.length(attr) + 1) "#{indent}#{attr}#{pad}#{value}" end) else lines end end # Map arrow: align the => operator # before: "name" => "Alice", # "age" => 30, # "occupation" => "dev" # # after: "name" => "Alice", # "age" => 30, # "occupation" => "dev" defp do_align(lines, :arrow) do parsed = Enum.map(lines, fn line -> case Regex.run(~r/^(\s*)(.*?)\s*=>\s*(.*)$/, line) do [_, indent, key, value] -> {indent, String.trim_trailing(key), value} _ -> nil end end) if Enum.all?(parsed, & &1) do max_len = parsed |> Enum.map(fn {_, key, _} -> String.length(key) end) |> Enum.max() Enum.map(parsed, fn {indent, key, value} -> pad = String.duplicate(" ", max_len - String.length(key) + 1) "#{indent}#{key}#{pad}=> #{value}" end) else lines end end # Macro call with atom first arg: align the rest after the comma # before: field :guest_name, function: &foo/1 # field :reservation_code, function: &bar/1 # # after: field :guest_name, function: &foo/1 # field :reservation_code, function: &bar/1 defp do_align(lines, {:macro_arg, _}) do parsed = Enum.map(lines, fn line -> case Regex.run(~r/^(\s*)([a-z_]\w*)\s+(:\w+),\s+(.*)$/, line) do [_, indent, macro, atom, rest] -> {indent, macro, atom, rest} _ -> nil end end) if Enum.all?(parsed, & &1) do max_prefix_len = parsed |> Enum.map(fn {_, macro, atom, _} -> String.length(macro) + 1 + String.length(atom) end) |> Enum.max() # Level-1 aligned lines (macro + first atom) level1 = Enum.map(parsed, fn {indent, macro, atom, rest} -> prefix_len = String.length(macro) + 1 + String.length(atom) pad = String.duplicate(" ", max_prefix_len - prefix_len + 1) {indent, macro, atom, pad, rest} end) # Try level-2 alignment: rest matches `(:type_atom), (kw_key:) (kw_value)` rest_parsed = Enum.map(level1, fn {_, _, _, _, rest} -> case Regex.run(~r/^(:\w+),\s+(\w+:)\s+(.+)$/, rest) do [_, type_atom, kw_key, kw_val] -> {type_atom, kw_key, kw_val} _ -> nil end end) level1_lines = if Enum.all?(rest_parsed, & &1) do max_type_len = rest_parsed |> Enum.map(fn {type_atom, _, _} -> String.length(type_atom) end) |> Enum.max() max_kw_key_len = rest_parsed |> Enum.map(fn {_, kw_key, _} -> String.length(kw_key) end) |> Enum.max() Enum.zip(level1, rest_parsed) |> Enum.map(fn {{indent, macro, atom, pad, _}, {type_atom, kw_key, kw_val}} -> type_pad = String.duplicate(" ", max_type_len - String.length(type_atom) + 1) kw_pad = String.duplicate(" ", max_kw_key_len - String.length(kw_key) + 1) "#{indent}#{macro} #{atom},#{pad}#{type_atom},#{type_pad}#{kw_key}#{kw_pad}#{kw_val}" end) else Enum.map(level1, fn {indent, macro, atom, pad, rest} -> "#{indent}#{macro} #{atom},#{pad}#{rest}" end) end level1_lines else lines end end # Case/cond/fn arm: align the -> operator # before: [value] -> t.(value) # _ -> nil # # after: [value] -> t.(value) # _ -> nil defp do_align(lines, :case_arm) do parsed = Enum.map(lines, fn line -> case Regex.run(~r/^(\s*)(.*?)\s+->\s+(.+)$/, line) do [_, indent, pattern, body] -> {indent, String.trim_trailing(pattern), body} _ -> nil end end) if Enum.all?(parsed, & &1) do max_len = parsed |> Enum.map(fn {_, pattern, _} -> String.length(pattern) end) |> Enum.max() Enum.map(parsed, fn {indent, pattern, body} -> pad = String.duplicate(" ", max_len - String.length(pattern) + 1) "#{indent}#{pattern}#{pad}-> #{body}" end) else lines end end defp do_align(lines, _), do: lines # Split a tuple's content string by ", " at bracket depth 0. defp split_tuple_elements(str) do {elems, current, _depth} = str |> String.graphemes() |> Enum.reduce({[], "", 0}, fn ch, {elems, curr, depth} when ch in ["[", "{", "("] -> {elems, curr <> ch, depth + 1} ch, {elems, curr, depth} when ch in ["]", "}", ")"] -> {elems, curr <> ch, depth - 1} ",", {elems, curr, 0} -> {elems ++ [curr], "", 0} " ", {elems, "", 0} -> # skip leading space after a split {elems, "", 0} ch, {elems, curr, depth} -> {elems, curr <> ch, depth} end) elems ++ [current] end end