From 2bbc791dacebfaea7ae0a610ff6ff790e8c7e341 Mon Sep 17 00:00:00 2001 From: Aaron Tinio Date: Sat, 25 Jul 2026 07:43:42 +0800 Subject: [PATCH 1/5] Pass :sigils to mix format plugins invoked for a sigil mix format passes the :sigils option to a plugin invoked for a file extension, but not to a plugin invoked for a sigil, because the sigil formatter closure captures the options before :sigils is put into them. A plugin that formats embedded Elixir code therefore cannot dispatch a sigil nested inside the sigil it is formatting by passing on the options it received, so the same snippet formats differently in a .heex file and in an ~H sigil. The sigil formatters are part of the options they receive, so a private function rebuilds the list whenever a sigil is formatted. --- lib/mix/lib/mix/tasks/format.ex | 32 ++++++++------ lib/mix/test/mix/tasks/format_test.exs | 60 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/lib/mix/lib/mix/tasks/format.ex b/lib/mix/lib/mix/tasks/format.ex index b19e82ed316..85dfc95bea9 100644 --- a/lib/mix/lib/mix/tasks/format.ex +++ b/lib/mix/lib/mix/tasks/format.ex @@ -354,19 +354,7 @@ defmodule Mix.Tasks.Format do sigil <- find_sigils_from_plugins(plugin, formatter_opts), do: {sigil, plugin} - sigils = - sigils - |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) - |> Enum.map(fn {sigil, plugins} -> - {sigil, - fn input, opts -> - Enum.reduce(plugins, input, fn plugin, input -> - plugin.format(input, opts ++ formatter_opts) - end) - end} - end) - - {Keyword.put(formatter_opts, :sigils, sigils), + {Keyword.put(formatter_opts, :sigils, sigil_formatters(sigils, formatter_opts)), Enum.map(subs, fn {path, formatter_opts_and_subs} -> {path, load_plugins(formatter_opts_and_subs, opts)} end)} @@ -384,6 +372,24 @@ defmodule Mix.Tasks.Format do plugins end + # A sigil formatter must receive :sigils itself, so a plugin can format sigils + # nested inside the sigil it was given. The list is rebuilt on each invocation. + defp sigil_formatters(sigil_plugins, formatter_opts) do + sigil_plugins + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Enum.map(fn {sigil, plugins} -> + {sigil, + fn input, opts -> + formatter_opts = + Keyword.put(formatter_opts, :sigils, sigil_formatters(sigil_plugins, formatter_opts)) + + Enum.reduce(plugins, input, fn plugin, input -> + plugin.format(input, opts ++ formatter_opts) + end) + end} + end) + end + @typedoc """ Options for `formatter_for_file/2`. """ diff --git a/lib/mix/test/mix/tasks/format_test.exs b/lib/mix/test/mix/tasks/format_test.exs index 1498d2a162f..f828e5c4790 100644 --- a/lib/mix/test/mix/tasks/format_test.exs +++ b/lib/mix/test/mix/tasks/format_test.exs @@ -572,6 +572,66 @@ defmodule Mix.Tasks.FormatTest do end) end + defmodule Elixir.SigilOuterPlugin do + @behaviour Mix.Tasks.Format + + def features(opts) do + assert opts[:from_formatter_exs] == :yes + [sigils: [:O]] + end + + def format(contents, opts) do + assert opts[:from_formatter_exs] == :yes + assert opts[:sigil] == :O + [Code.format_string!(contents, opts), ?\n] + end + end + + defmodule Elixir.SigilInnerPlugin do + @behaviour Mix.Tasks.Format + + def features(opts) do + assert opts[:from_formatter_exs] == :yes + [sigils: [:I]] + end + + def format(contents, opts) do + assert opts[:from_formatter_exs] == :yes + assert opts[:sigil] == :I + String.upcase(contents) + end + end + + test "uses sigil plugins for sigils nested inside sigils", context do + in_tmp(context.test, fn -> + File.write!(".formatter.exs", """ + [ + inputs: ["a.ex"], + plugins: [SigilOuterPlugin, SigilInnerPlugin], + from_formatter_exs: :yes + ] + """) + + File.write!("a.ex", """ + def nested_sigil_test do + ~O''' + inner(~I"foo bar") + ''' + end + """) + + Mix.Tasks.Format.run([]) + + assert File.read!("a.ex") == """ + def nested_sigil_test do + ~O''' + inner(~I"FOO BAR") + ''' + end + """ + end) + end + test "customizes plugin loading", context do in_tmp(context.test, fn -> File.write!(".formatter.exs", """ From ca1da9f442fbe619ca1f2f01bbe2ba85a3ed5f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 25 Jul 2026 09:36:44 +0200 Subject: [PATCH 2/5] Reduce recursive sigil logic --- lib/mix/lib/mix/tasks/format.ex | 37 +++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/lib/mix/lib/mix/tasks/format.ex b/lib/mix/lib/mix/tasks/format.ex index 85dfc95bea9..8bbdcb1ffe1 100644 --- a/lib/mix/lib/mix/tasks/format.ex +++ b/lib/mix/lib/mix/tasks/format.ex @@ -349,12 +349,17 @@ defmodule Mix.Tasks.Format do end end - sigils = + flatten_sigils = for plugin <- plugins, sigil <- find_sigils_from_plugins(plugin, formatter_opts), do: {sigil, plugin} - {Keyword.put(formatter_opts, :sigils, sigil_formatters(sigils, formatter_opts)), + formatter_opts = + flatten_sigils + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> prepend_sigils(Keyword.delete(formatter_opts, :sigils)) + + {formatter_opts, Enum.map(subs, fn {path, formatter_opts_and_subs} -> {path, load_plugins(formatter_opts_and_subs, opts)} end)} @@ -374,20 +379,20 @@ defmodule Mix.Tasks.Format do # A sigil formatter must receive :sigils itself, so a plugin can format sigils # nested inside the sigil it was given. The list is rebuilt on each invocation. - defp sigil_formatters(sigil_plugins, formatter_opts) do - sigil_plugins - |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) - |> Enum.map(fn {sigil, plugins} -> - {sigil, - fn input, opts -> - formatter_opts = - Keyword.put(formatter_opts, :sigils, sigil_formatters(sigil_plugins, formatter_opts)) - - Enum.reduce(plugins, input, fn plugin, input -> - plugin.format(input, opts ++ formatter_opts) - end) - end} - end) + defp prepend_sigils(grouped_sigils, formatter_opts_without_sigils) do + sigils = + Enum.map(sigil_groups, fn {sigil, plugins} -> + {sigil, + fn input, opts -> + formatter_opts = prepend_sigils(grouped_sigils, formatter_opts_without_sigils) + + Enum.reduce(plugins, input, fn plugin, input -> + plugin.format(input, opts ++ formatter_opts) + end) + end} + end) + + [sigils: sigils] ++ formatter_opts_without_sigils end @typedoc """ From f336eb7ee5b096dc8e82f81b6fffe9c1d90792da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 25 Jul 2026 09:39:28 +0200 Subject: [PATCH 3/5] Apply suggestion from @josevalim --- lib/mix/lib/mix/tasks/format.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/lib/mix/tasks/format.ex b/lib/mix/lib/mix/tasks/format.ex index 8bbdcb1ffe1..7b0f72068b7 100644 --- a/lib/mix/lib/mix/tasks/format.ex +++ b/lib/mix/lib/mix/tasks/format.ex @@ -381,7 +381,7 @@ defmodule Mix.Tasks.Format do # nested inside the sigil it was given. The list is rebuilt on each invocation. defp prepend_sigils(grouped_sigils, formatter_opts_without_sigils) do sigils = - Enum.map(sigil_groups, fn {sigil, plugins} -> + Enum.map(grouped_sigils, fn {sigil, plugins} -> {sigil, fn input, opts -> formatter_opts = prepend_sigils(grouped_sigils, formatter_opts_without_sigils) From 3eac352c82662961d4fd8c794ee60ce6070ded8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 25 Jul 2026 09:45:11 +0200 Subject: [PATCH 4/5] Apply suggestion from @josevalim --- lib/mix/lib/mix/tasks/format.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/mix/lib/mix/tasks/format.ex b/lib/mix/lib/mix/tasks/format.ex index 7b0f72068b7..fceb0004b65 100644 --- a/lib/mix/lib/mix/tasks/format.ex +++ b/lib/mix/lib/mix/tasks/format.ex @@ -385,7 +385,6 @@ defmodule Mix.Tasks.Format do {sigil, fn input, opts -> formatter_opts = prepend_sigils(grouped_sigils, formatter_opts_without_sigils) - Enum.reduce(plugins, input, fn plugin, input -> plugin.format(input, opts ++ formatter_opts) end) From 8195bcc20f16fb163e3db7121a5f964e9743c4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 25 Jul 2026 09:50:01 +0200 Subject: [PATCH 5/5] Apply suggestion from @josevalim --- lib/mix/lib/mix/tasks/format.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mix/lib/mix/tasks/format.ex b/lib/mix/lib/mix/tasks/format.ex index fceb0004b65..2951ceac1ac 100644 --- a/lib/mix/lib/mix/tasks/format.ex +++ b/lib/mix/lib/mix/tasks/format.ex @@ -385,6 +385,7 @@ defmodule Mix.Tasks.Format do {sigil, fn input, opts -> formatter_opts = prepend_sigils(grouped_sigils, formatter_opts_without_sigils) + Enum.reduce(plugins, input, fn plugin, input -> plugin.format(input, opts ++ formatter_opts) end)