Current section

Files

Jump to
surface lib mix tasks surface surface.format.ex
Raw

lib/mix/tasks/surface/surface.format.ex

defmodule Mix.Tasks.Surface.Format do
@shortdoc "Formats Surface ~F sigils and .sface files in the given files/patterns"
@moduledoc """
> #### Formatter Plugin Preferred {: .tip}
>
> To format Surface code using Elixir 1.13 or later, use
> `Surface.Formatter.Plugin`.
Formats Surface `~F` sigils and `.sface` files in the given files and patterns.
```bash
$ mix surface.format "lib/**/*.{ex,exs}" "test/**/*.{ex,exs}"
$ cat path/to/file.ex | mix surface.format -
```
Takes the same options as `mix format` except for `--check-equivalent`.
## Formatting options
Like `mix format`, the Surface formatter reads a `.formatter.exs` file in the
current directory for formatter configuration. The Surface formatter accepts
the same options as `mix format`. Read more about the expected format of
`.formatter.exs` and the shared configuration options
[documented here](https://hexdocs.pm/mix/main/Mix.Tasks.Format.html#module-formatting-options).
The Surface formatter also takes the following two additional options
specified in `.formatter.exs`:
- `:surface_line_length` overrides `:line_length` only for `mix surface.format`
(`:line_length` is used otherwise, or defaults to 98)
- `:surface_inputs` overrides `:inputs` only for `mix surface.format`
(`:inputs` is used otherwise)
## Task-specific options
The Surface formatter accepts the same task-specific options as `mix format`.
[Read documentation for the options documented here.](https://hexdocs.pm/mix/main/Mix.Tasks.Format.html#module-task-specific-options).
For quick reference, here are some examples of using these options:
```bash
$ mix surface.format --check-formatted
** (Mix) mix surface.format failed due to --check-formatted.
The following files are not formatted:
* path/to/component.ex
* path/to/file.sface
```
```bash
$ mix surface.format --dry-run
```
```bash
$ mix surface.format --dot-formatter path/to/.formatter.exs
```
You can also use the same syntax as `mix format` for specifying which files to
format:
```bash
$ mix surface.format path/to/file.ex "lib/**/*.{ex,exs}" "test/**/*.{ex,exs}"
```
"""
use Mix.Task
alias Surface.Formatter
#
# Functions unique to surface.format (Everything else is taken from Mix.Tasks.Format)
#
defp format_file_contents!(:stdin, input, opts) do
# determine whether the input is Elixir or Surface code by checking if `Code.string_to_quoted` can parse it
case Code.string_to_quoted(input) do
{:ok, _} ->
format_ex_string!(input, opts)
{:error, _} ->
Formatter.format_string!(input, opts)
end
end
defp format_file_contents!(file, input, opts) do
case Path.extname(file) do
".sface" ->
Formatter.format_string!(input, opts)
_ ->
format_ex_string!(input, opts)
end
end
defp format_ex_string!(input, opts) do
string =
~r/\n( *)~F"""(.*?)"""/s
|> Regex.replace(input, fn _match, indentation, surface_code ->
# Indent the entire ~F sigil contents based on the indentation of `~F"""`
tabs =
indentation
|> String.length()
|> Kernel.div(2)
opts = Keyword.put(opts, :indent, tabs)
"\n#{indentation}~F\"\"\"\n#{Formatter.format_string!(surface_code, opts)}#{indentation}\"\"\""
end)
# We do not match on ~F"..." sigils where the first character is `\`
# in order to allow for a use case in surface_site, where Surface
# code examples are being rendered inside a Markdown component and
# contains the string: `~F"\""`
string =
Regex.replace(~r/~F\"([^\"\\].*?)\"/s, string, fn _match, code ->
"~F\"#{Formatter.format_string!(code, opts)}\""
end)
string =
Regex.replace(~r/~F\[(.*?)\]/s, string, fn _match, code ->
"~F[#{Formatter.format_string!(code, opts)}]"
end)
string =
Regex.replace(~r/~F\((.*?)\)/s, string, fn _match, code ->
"~F(#{Formatter.format_string!(code, opts)})"
end)
Regex.replace(~r/~F\{(.*?)\}/s, string, fn _match, code ->
"~F{#{Formatter.format_string!(code, opts)}}"
end)
end
#
# The below functions are taken directly from Mix.Tasks.Format with insignificant modification
#
@switches [
check_formatted: :boolean,
dot_formatter: :string,
dry_run: :boolean
]
@manifest "cached_dot_formatter"
@manifest_vsn 1
@impl true
def run(args) do
{opts, args} = OptionParser.parse!(args, strict: @switches)
{dot_formatter, formatter_opts} = eval_dot_formatter(opts)
{{formatter_opts, subdirectories}, _sources} =
eval_deps_and_subdirectories(dot_formatter, [], formatter_opts, [dot_formatter])
# surface_line_length can be used to override the line_length option
formatter_opts =
if line_length = formatter_opts[:surface_line_length] do
Keyword.put(formatter_opts, :line_length, line_length)
else
formatter_opts
end
args
|> expand_args(dot_formatter, {formatter_opts, subdirectories})
|> Task.async_stream(&format_file(&1, opts), ordered: false, timeout: 30000)
|> Enum.reduce({[], [], []}, &collect_status/2)
|> check!()
end
defp format_file({file, formatter_opts}, task_opts) do
{input, extra_opts} = read_file(file)
formatted = format_file_contents!(file, input, extra_opts ++ formatter_opts)
output = IO.iodata_to_binary([formatted])
check_equivalent? = Keyword.get(task_opts, :check_equivalent, false)
check_formatted? = Keyword.get(task_opts, :check_formatted, false)
dry_run? = Keyword.get(task_opts, :dry_run, false)
cond do
check_equivalent? and not equivalent?(input, output) ->
{:not_equivalent, file}
check_formatted? ->
if input == output, do: :ok, else: {:not_formatted, file}
dry_run? ->
:ok
true ->
write_or_print(file, input, output)
end
rescue
exception ->
{:exit, file, exception, __STACKTRACE__}
end
# This function reads exported configuration from the imported
# dependencies and subdirectories and deals with caching the result
# of reading such configuration in a manifest file.
defp eval_deps_and_subdirectories(dot_formatter, prefix, formatter_opts, sources) do
deps = Keyword.get(formatter_opts, :import_deps, [])
subs = Keyword.get(formatter_opts, :subdirectories, [])
if not is_list(deps) do
Mix.raise("Expected :import_deps to return a list of dependencies, got: #{inspect(deps)}")
end
if not is_list(subs) do
Mix.raise("Expected :subdirectories to return a list of directories, got: #{inspect(subs)}")
end
if deps == [] and subs == [] do
{{formatter_opts, []}, sources}
else
manifest = Path.join(Mix.Project.manifest_path(), @manifest)
maybe_cache_in_manifest(dot_formatter, manifest, fn ->
{subdirectories, sources} = eval_subs_opts(subs, prefix, sources)
{{eval_deps_opts(formatter_opts, deps), subdirectories}, sources}
end)
end
end
defp eval_deps_opts(formatter_opts, deps) do
deps_paths = Mix.Project.deps_paths()
parenless_calls =
for dep <- deps,
dep_path = assert_valid_dep_and_fetch_path(dep, deps_paths),
dep_dot_formatter = Path.join(dep_path, ".formatter.exs"),
File.regular?(dep_dot_formatter),
dep_opts = eval_file_with_keyword_list(dep_dot_formatter),
parenless_call <- dep_opts[:export][:locals_without_parens] || [],
uniq: true,
do: parenless_call
Keyword.update(
formatter_opts,
:locals_without_parens,
parenless_calls,
&(&1 ++ parenless_calls)
)
end
defp eval_subs_opts(subs, prefix, sources) do
{subs, sources} =
Enum.flat_map_reduce(subs, sources, fn sub, sources ->
prefix = Path.join(prefix ++ [sub])
{Path.wildcard(prefix), [Path.join(prefix, ".formatter.exs") | sources]}
end)
Enum.flat_map_reduce(subs, sources, fn sub, sources ->
sub_formatter = Path.join(sub, ".formatter.exs")
if File.exists?(sub_formatter) do
formatter_opts = eval_file_with_keyword_list(sub_formatter)
{formatter_opts_and_subs, sources} =
eval_deps_and_subdirectories(:in_memory, [sub], formatter_opts, sources)
{[{sub, formatter_opts_and_subs}], sources}
else
{[], sources}
end
end)
end
defp assert_valid_dep_and_fetch_path(dep, deps_paths) when is_atom(dep) do
case Map.fetch(deps_paths, dep) do
{:ok, path} ->
if File.dir?(path) do
path
else
Mix.raise(
"Unavailable dependency #{inspect(dep)} given to :import_deps in the formatter configuration. " <>
"The dependency cannot be found in the file system, please run \"mix deps.get\" and try again"
)
end
:error ->
Mix.raise(
"Unknown dependency #{inspect(dep)} given to :import_deps in the formatter configuration. " <>
"The dependency is not listed in your mix.exs for environment #{inspect(Mix.env())}"
)
end
end
defp maybe_cache_in_manifest(dot_formatter, manifest, fun) do
cond do
is_nil(Mix.Project.get()) or dot_formatter != ".formatter.exs" -> fun.()
entry = read_manifest(manifest) -> entry
true -> write_manifest!(manifest, fun.())
end
end
defp read_manifest(manifest) do
with {:ok, binary} <- File.read(manifest),
{:ok, {@manifest_vsn, entry, sources}} <- safe_binary_to_term(binary),
expanded_sources = Enum.flat_map(sources, &Path.wildcard(&1, match_dot: true)),
false <- Mix.Utils.stale?([Mix.Project.config_mtime() | expanded_sources], [manifest]) do
{entry, sources}
else
_ -> nil
end
end
defp safe_binary_to_term(binary) do
{:ok, :erlang.binary_to_term(binary)}
rescue
_ -> :error
end
defp write_manifest!(manifest, {entry, sources}) do
File.mkdir_p!(Path.dirname(manifest))
File.write!(manifest, :erlang.term_to_binary({@manifest_vsn, entry, sources}))
{entry, sources}
end
defp eval_dot_formatter(opts) do
cond do
dot_formatter = opts[:dot_formatter] ->
{dot_formatter, eval_file_with_keyword_list(dot_formatter)}
File.regular?(".formatter.exs") ->
{".formatter.exs", eval_file_with_keyword_list(".formatter.exs")}
true ->
{".formatter.exs", []}
end
end
defp eval_file_with_keyword_list(path) do
{opts, _} = Code.eval_file(path)
unless Keyword.keyword?(opts) do
Mix.raise("Expected #{inspect(path)} to return a keyword list, got: #{inspect(opts)}")
end
opts
end
defp read_file(:stdin) do
{IO.stream(:stdio, :line) |> Enum.to_list() |> IO.iodata_to_binary(), file: "stdin"}
end
defp read_file(file) do
{File.read!(file), file: file}
end
defp expand_args([], dot_formatter, formatter_opts_and_subs) do
if no_entries_in_formatter_opts?(formatter_opts_and_subs) do
Mix.raise(
"Expected one or more files/patterns to be given to mix format " <>
"or for a .formatter.exs file to exist with an :inputs, :surface_inputs or :subdirectories key"
)
end
dot_formatter
|> expand_dot_inputs([], formatter_opts_and_subs, %{})
|> Enum.map(fn {file, {_dot_formatter, formatter_opts}} -> {file, formatter_opts} end)
end
defp expand_args(files_and_patterns, _dot_formatter, {formatter_opts, subs}) do
files =
for file_or_pattern <- files_and_patterns,
file <- stdin_or_wildcard(file_or_pattern),
uniq: true,
do: file
if files == [] do
Mix.raise(
"Could not find a file to format. The files/patterns given to command line " <>
"did not point to any existing file. Got: #{inspect(files_and_patterns)}"
)
end
for file <- files do
if file == :stdin do
{file, formatter_opts}
else
split = file |> Path.relative_to_cwd() |> Path.split()
{file, find_formatter_opts_for_file(split, {formatter_opts, subs})}
end
end
end
defp expand_dot_inputs(dot_formatter, prefix, {formatter_opts, subs}, acc) do
if no_entries_in_formatter_opts?({formatter_opts, subs}) do
Mix.raise("Expected :inputs, :surface_inputs or :subdirectories key in #{dot_formatter}")
end
map =
for input <- List.wrap(formatter_opts[:surface_inputs] || formatter_opts[:inputs]),
file <- Path.wildcard(Path.join(prefix ++ [input]), match_dot: true),
do: {expand_relative_to_cwd(file), {dot_formatter, formatter_opts}},
into: %{}
acc =
Map.merge(acc, map, fn file, {dot_formatter1, _}, {dot_formatter2, formatter_opts} ->
Mix.shell().error(
"Both #{dot_formatter1} and #{dot_formatter2} specify the file " <>
"#{Path.relative_to_cwd(file)} in their :inputs or :surface_inputs option. To resolve the " <>
"conflict, the configuration in #{dot_formatter1} will be ignored. " <>
"Please change the list of :inputs (or :surface_inputs) in one of the formatter files so only " <>
"one of them matches #{Path.relative_to_cwd(file)}"
)
{dot_formatter2, formatter_opts}
end)
Enum.reduce(subs, acc, fn {sub, formatter_opts_and_subs}, acc ->
sub_formatter = Path.join(sub, ".formatter.exs")
expand_dot_inputs(sub_formatter, [sub], formatter_opts_and_subs, acc)
end)
end
defp expand_relative_to_cwd(path) do
case File.cwd() do
{:ok, cwd} -> Path.expand(path, cwd)
_ -> path
end
end
defp find_formatter_opts_for_file(split, {formatter_opts, subs}) do
Enum.find_value(subs, formatter_opts, fn {sub, formatter_opts_and_subs} ->
if List.starts_with?(split, Path.split(sub)) do
find_formatter_opts_for_file(split, formatter_opts_and_subs)
end
end)
end
defp stdin_or_wildcard("-"), do: [:stdin]
defp stdin_or_wildcard(path), do: path |> Path.expand() |> Path.wildcard(match_dot: true)
defp no_entries_in_formatter_opts?({formatter_opts, subs}) do
is_nil(formatter_opts[:inputs]) and is_nil(formatter_opts[:surface_inputs]) and subs == []
end
defp write_or_print(file, input, output) do
cond do
file == :stdin -> IO.write(output)
input == output -> :ok
true -> File.write!(file, output)
end
:ok
end
defp collect_status({:ok, :ok}, acc), do: acc
defp collect_status({:ok, {:exit, _, _, _} = exit}, {exits, not_equivalent, not_formatted}) do
{[exit | exits], not_equivalent, not_formatted}
end
defp collect_status({:ok, {:not_equivalent, file}}, {exits, not_equivalent, not_formatted}) do
{exits, [file | not_equivalent], not_formatted}
end
defp collect_status({:ok, {:not_formatted, file}}, {exits, not_equivalent, not_formatted}) do
{exits, not_equivalent, [file | not_formatted]}
end
defp check!({[], [], []}) do
:ok
end
defp check!({[{:exit, :stdin, exception, stacktrace} | _], _not_equivalent, _not_formatted}) do
Mix.shell().error("mix surface.format failed for stdin")
reraise exception, stacktrace
end
defp check!({[{:exit, file, exception, stacktrace} | _], _not_equivalent, _not_formatted}) do
Mix.shell().error("mix surface.format failed for file: #{Path.relative_to_cwd(file)}")
reraise exception, stacktrace
end
defp check!({_exits, [_ | _] = not_equivalent, _not_formatted}) do
Mix.raise("""
mix surface.format failed due to --check-equivalent.
The following files are not equivalent:
#{to_bullet_list(not_equivalent)}
Please report this bug with the input files at https://github.com/surface-ui/surface_formatter/issues
""")
end
defp check!({_exits, _not_equivalent, [_ | _] = not_formatted}) do
Mix.raise("""
mix surface.format failed due to --check-formatted.
The following files are not formatted:
#{to_bullet_list(not_formatted)}
""")
end
defp to_bullet_list(files) do
Enum.map_join(files, "\n", &" * #{&1 |> to_string() |> Path.relative_to_cwd()}")
end
defp equivalent?(input, output) do
input == output
end
end