From d5fa298f8d3b583297c550431fb66c01cd8dfd39 Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Fri, 1 May 2026 14:13:57 -0600 Subject: [PATCH 1/2] Fixes formatter with sigils like ~H --- internal/lsp/beam_server.exs | 89 +++++++++++++---- internal/lsp/formatter_test.go | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 21 deletions(-) diff --git a/internal/lsp/beam_server.exs b/internal/lsp/beam_server.exs index e5d8b7e..6fbc489 100644 --- a/internal/lsp/beam_server.exs +++ b/internal/lsp/beam_server.exs @@ -192,38 +192,51 @@ defmodule Dexter.Formatter do IO.puts(:stderr, "Formatter: plugins loaded for #{formatter_exs_path}: #{Enum.map_join(active_plugins, ", ", &inspect/1)}") end + format_opts = Keyword.put(format_opts, :sigils, sigils_for_plugins(active_plugins, format_opts)) + {format_opts, active_plugins} end + defp sigils_for_plugins(plugins, format_opts) do + plugins + |> Enum.flat_map(fn plugin -> + plugin.features(format_opts) + |> Keyword.get(:sigils) + |> List.wrap() + |> Enum.map(fn sigil -> {sigil, plugin} end) + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Enum.map(fn {sigil, plugins} -> + {sigil, + fn input, opts -> + Enum.reduce(plugins, input, fn plugin, acc -> + plugin.format(acc, opts ++ format_opts) + end) + end} + end) + end + defp do_format(content, filename, format_opts, plugins) when is_binary(content) do try do opts = if filename != "", do: [file: filename] ++ format_opts, else: format_opts ext = Path.extname(filename) - # Filter plugins to those that handle this file extension - applicable_plugins = - Enum.filter(plugins, fn plugin -> - features = plugin.features(format_opts) - extensions = Keyword.get(features, :extensions, []) - extensions == [] or ext in extensions - end) + extension_plugins = plugins_for_extension(plugins, ext, format_opts) formatted = - if applicable_plugins != [] do - # Redirect group leader to stderr during plugin calls so any - # IO.puts from plugins doesn't corrupt the binary protocol on stdout. - old_gl = Process.group_leader() - Process.group_leader(self(), Process.whereis(:standard_error)) - - try do - Enum.reduce(applicable_plugins, content, fn plugin, acc -> - plugin.format(acc, opts) + cond do + extension_plugins != [] -> + with_plugin_output_redirect(fn -> + Enum.reduce(extension_plugins, content, fn plugin, acc -> + plugin.format(acc, [extension: ext] ++ opts) + end) end) - after - Process.group_leader(self(), old_gl) - end - else - content |> Code.format_string!(opts) |> IO.iodata_to_binary() + + elixir_source?(ext) -> + format_elixir(content, opts) + + true -> + content end # Ensure trailing newline to match mix format output @@ -241,6 +254,40 @@ defmodule Dexter.Formatter do end defp do_format(_, _, _, _), do: {1, "invalid input"} + + defp plugins_for_extension(plugins, ext, format_opts) do + Enum.filter(plugins, fn plugin -> + ext in List.wrap(plugin.features(format_opts)[:extensions]) + end) + end + + defp elixir_source?(""), do: true + defp elixir_source?(ext), do: ext in [".ex", ".exs"] + + defp format_elixir(content, opts) do + formatter = fn -> + content |> Code.format_string!(opts) |> IO.iodata_to_binary() + end + + if Keyword.get(opts, :sigils, []) != [] do + with_plugin_output_redirect(formatter) + else + formatter.() + end + end + + # Plugin callbacks can write to the group leader. Redirect those writes to + # stderr so they cannot corrupt the stdout binary protocol. + defp with_plugin_output_redirect(fun) do + old_gl = Process.group_leader() + Process.group_leader(self(), Process.whereis(:standard_error)) + + try do + fun.() + after + Process.group_leader(self(), old_gl) + end + end end # Protocol Writer diff --git a/internal/lsp/formatter_test.go b/internal/lsp/formatter_test.go index fb8d873..447dd0e 100644 --- a/internal/lsp/formatter_test.go +++ b/internal/lsp/formatter_test.go @@ -48,6 +48,74 @@ func ensureFixtureDeps(t *testing.T, mixRoot string) { } } +func createHEEXFormatterFixture(t *testing.T) string { + t.Helper() + mixRoot := t.TempDir() + if err := os.MkdirAll(filepath.Join(mixRoot, "lib", "phoenix", "live_view"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(mixRoot, "mix.exs"), + []byte(`defmodule AppWithHEEXFormatter.MixProject do + use Mix.Project + + def project do + [ + app: :app_with_heex_formatter, + version: "0.1.0", + elixir: "~> 1.18" + ] + end +end +`), + 0644, + ); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(mixRoot, ".formatter.exs"), + []byte("[plugins: [Phoenix.LiveView.HTMLFormatter], inputs: [\"{lib,test}/**/*.{ex,exs,heex}\"]]\n"), + 0644, + ); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(mixRoot, "lib", "phoenix", "live_view", "html_formatter.ex"), + []byte(`defmodule Phoenix.LiveView.HTMLFormatter do + def features(_opts), do: [extensions: [".heex"], sigils: [:H]] + + def format(input, opts) do + cond do + Keyword.get(opts, :sigil) == :H and Keyword.get(opts, :file) -> + format_heex(input) + + Keyword.get(opts, :extension) == ".heex" and Keyword.get(opts, :file) -> + format_heex(input) + + true -> + raise "expected HEEX formatter metadata" + end + end + + defp format_heex(input) do + input = Regex.replace(~r/^\s*text<\/span>$/m, input, " text") + input = Regex.replace(~r/^\s*more text\s*\n\s*<\/span>$/m, input, " more text") + Regex.replace(~r/\n\s*\n\s*\n+/, input, "\n\n") + end +end +`), + 0644, + ); err != nil { + t.Fatal(err) + } + cmd := exec.Command("mix", "compile") + cmd.Dir = mixRoot + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("could not compile HEEX formatter fixture: %v\n%s", err, out) + } + return mixRoot +} + func setupTestServerForFixture(t *testing.T, mixRoot string) (*Server, func()) { t.Helper() dir := t.TempDir() @@ -162,6 +230,115 @@ func TestFormatterServer_WithStylerPlugin(t *testing.T) { } } +func TestFormatterServer_HEEXPluginFormatsSigilInElixirFile(t *testing.T) { + if _, err := exec.LookPath("mix"); err != nil { + t.Skip("mix not available in PATH") + } + + mixRoot := createHEEXFormatterFixture(t) + storeDir := t.TempDir() + s, err := store.Open(storeDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + + server := NewServer(s, mixRoot) + if p, err := exec.LookPath("mix"); err == nil { + server.mixBin = p + } + + input := `defmodule MyApp.Component do + def render(assigns) do + ~H""" +
+ text + + + + more text + +
+ """ + end +end +` + filePath := filepath.Join(mixRoot, "lib", "component.ex") + docURI := string(uri.File(filePath)) + server.docs.Set(docURI, input) + + edits, err := server.Formatting(context.Background(), &protocol.DocumentFormattingParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(docURI)}, + }) + if err != nil { + t.Fatal(err) + } + if edits == nil { + t.Fatal("expected formatting edits from HEEX formatter plugin, got nil") + } + for _, want := range []string{ + " text", + " more text", + } { + if !strings.Contains(edits[0].NewText, want) { + t.Errorf("expected HEEX formatter output %q, got:\n%s", want, edits[0].NewText) + } + } + if strings.Contains(edits[0].NewText, "more text\n") { + t.Errorf("expected HEEX formatter to collapse split span, got:\n%s", edits[0].NewText) + } +} + +func TestFormatterServer_HEEXPluginFormatsHEEXFile(t *testing.T) { + if _, err := exec.LookPath("mix"); err != nil { + t.Skip("mix not available in PATH") + } + + mixRoot := createHEEXFormatterFixture(t) + storeDir := t.TempDir() + s, err := store.Open(storeDir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + + server := NewServer(s, mixRoot) + if p, err := exec.LookPath("mix"); err == nil { + server.mixBin = p + } + + input := `
+text + + + + more text + +
+` + filePath := filepath.Join(mixRoot, "lib", "component.heex") + docURI := string(uri.File(filePath)) + server.docs.Set(docURI, input) + + edits, err := server.Formatting(context.Background(), &protocol.DocumentFormattingParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(docURI)}, + }) + if err != nil { + t.Fatal(err) + } + if edits == nil { + t.Fatal("expected formatting edits for .heex file, got nil") + } + for _, want := range []string{ + " text", + " more text", + } { + if !strings.Contains(edits[0].NewText, want) { + t.Errorf("expected HEEX formatter output %q, got:\n%s", want, edits[0].NewText) + } + } +} + func TestFormatterServer_BasicProject(t *testing.T) { if _, err := exec.LookPath("mix"); err != nil { t.Skip("mix not available in PATH") From 12c80c9f176adb1bd701c2e8f1a95dedd1413197 Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Fri, 1 May 2026 14:20:03 -0600 Subject: [PATCH 2/2] DRY up tests --- internal/lsp/formatter_test.go | 122 +++++++++++++++------------------ 1 file changed, 54 insertions(+), 68 deletions(-) diff --git a/internal/lsp/formatter_test.go b/internal/lsp/formatter_test.go index 447dd0e..6d00ad2 100644 --- a/internal/lsp/formatter_test.go +++ b/internal/lsp/formatter_test.go @@ -230,7 +230,7 @@ func TestFormatterServer_WithStylerPlugin(t *testing.T) { } } -func TestFormatterServer_HEEXPluginFormatsSigilInElixirFile(t *testing.T) { +func TestFormatterServer_HEEXPluginFormatsSigilsAndFiles(t *testing.T) { if _, err := exec.LookPath("mix"); err != nil { t.Skip("mix not available in PATH") } @@ -248,7 +248,17 @@ func TestFormatterServer_HEEXPluginFormatsSigilInElixirFile(t *testing.T) { server.mixBin = p } - input := `defmodule MyApp.Component do + tests := []struct { + name string + relativePath string + input string + want []string + unwantedOutput string + }{ + { + name: "sigil in Elixir file", + relativePath: filepath.Join("lib", "component.ex"), + input: `defmodule MyApp.Component do def render(assigns) do ~H"""
@@ -262,52 +272,17 @@ func TestFormatterServer_HEEXPluginFormatsSigilInElixirFile(t *testing.T) { """ end end -` - filePath := filepath.Join(mixRoot, "lib", "component.ex") - docURI := string(uri.File(filePath)) - server.docs.Set(docURI, input) - - edits, err := server.Formatting(context.Background(), &protocol.DocumentFormattingParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(docURI)}, - }) - if err != nil { - t.Fatal(err) - } - if edits == nil { - t.Fatal("expected formatting edits from HEEX formatter plugin, got nil") - } - for _, want := range []string{ - " text", - " more text", - } { - if !strings.Contains(edits[0].NewText, want) { - t.Errorf("expected HEEX formatter output %q, got:\n%s", want, edits[0].NewText) - } - } - if strings.Contains(edits[0].NewText, "more text\n") { - t.Errorf("expected HEEX formatter to collapse split span, got:\n%s", edits[0].NewText) - } -} - -func TestFormatterServer_HEEXPluginFormatsHEEXFile(t *testing.T) { - if _, err := exec.LookPath("mix"); err != nil { - t.Skip("mix not available in PATH") - } - - mixRoot := createHEEXFormatterFixture(t) - storeDir := t.TempDir() - s, err := store.Open(storeDir) - if err != nil { - t.Fatal(err) - } - defer func() { _ = s.Close() }() - - server := NewServer(s, mixRoot) - if p, err := exec.LookPath("mix"); err == nil { - server.mixBin = p - } - - input := `
+`, + want: []string{ + " text", + " more text", + }, + unwantedOutput: "more text\n", + }, + { + name: "HEEX file", + relativePath: filepath.Join("lib", "component.heex"), + input: `
text @@ -315,27 +290,38 @@ func TestFormatterServer_HEEXPluginFormatsHEEXFile(t *testing.T) { more text
-` - filePath := filepath.Join(mixRoot, "lib", "component.heex") - docURI := string(uri.File(filePath)) - server.docs.Set(docURI, input) - - edits, err := server.Formatting(context.Background(), &protocol.DocumentFormattingParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(docURI)}, - }) - if err != nil { - t.Fatal(err) - } - if edits == nil { - t.Fatal("expected formatting edits for .heex file, got nil") +`, + want: []string{ + " text", + " more text", + }, + }, } - for _, want := range []string{ - " text", - " more text", - } { - if !strings.Contains(edits[0].NewText, want) { - t.Errorf("expected HEEX formatter output %q, got:\n%s", want, edits[0].NewText) - } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := filepath.Join(mixRoot, tt.relativePath) + docURI := string(uri.File(filePath)) + server.docs.Set(docURI, tt.input) + + edits, err := server.Formatting(context.Background(), &protocol.DocumentFormattingParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(docURI)}, + }) + if err != nil { + t.Fatal(err) + } + if edits == nil { + t.Fatal("expected formatting edits from HEEX formatter plugin, got nil") + } + for _, want := range tt.want { + if !strings.Contains(edits[0].NewText, want) { + t.Errorf("expected HEEX formatter output %q, got:\n%s", want, edits[0].NewText) + } + } + if tt.unwantedOutput != "" && strings.Contains(edits[0].NewText, tt.unwantedOutput) { + t.Errorf("expected HEEX formatter to remove %q, got:\n%s", tt.unwantedOutput, edits[0].NewText) + } + }) } }