Packages
surface
0.7.5
0.12.3
0.12.2
0.12.1
0.12.0
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.2
0.3.1
0.3.0
0.2.1
0.2.0
0.1.1
0.1.0
0.1.0-alpha.2
0.1.0-alpha.1
0.1.0-alpha.0
A component based library for Phoenix LiveView
Current section
Files
Jump to
Current section
Files
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 """
**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/master/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/master/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