Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 68 additions & 21 deletions internal/lsp/beam_server.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
163 changes: 163 additions & 0 deletions internal/lsp/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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*<span>text<\/span>$/m, input, " <span>text</span>")
input = Regex.replace(~r/^\s*<span>more text\s*\n\s*<\/span>$/m, input, " <span>more text</span>")
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()
Expand Down Expand Up @@ -162,6 +230,101 @@ func TestFormatterServer_WithStylerPlugin(t *testing.T) {
}
}

func TestFormatterServer_HEEXPluginFormatsSigilsAndFiles(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
}

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"""
<div>
<span>text</span>



<span>more text
</span>
</div>
"""
end
end
`,
want: []string{
" <span>text</span>",
" <span>more text</span>",
},
unwantedOutput: "<span>more text\n",
},
{
name: "HEEX file",
relativePath: filepath.Join("lib", "component.heex"),
input: `<div>
<span>text</span>



<span>more text
</span>
</div>
`,
want: []string{
" <span>text</span>",
" <span>more text</span>",
},
},
}

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)
}
})
}
}

func TestFormatterServer_BasicProject(t *testing.T) {
if _, err := exec.LookPath("mix"); err != nil {
t.Skip("mix not available in PATH")
Expand Down
Loading