Current section

Files

Jump to
surface_formatter lib surface formatter render.ex
Raw

lib/surface/formatter/render.ex

defmodule Surface.Formatter.Render do
@moduledoc "Functions for rendering formatter nodes"
alias Surface.Formatter
# Use 2 spaces for a tab
@tab " "
# Line length of opening tags before splitting attributes onto their own line
@default_line_length 98
@doc """
Given a `t:Surface.Formatter.formatter_node/0` node, render it to a string
for writing back into a file.
"""
@spec node(Formatter.formatter_node(), list(Formatter.option())) :: String.t() | nil
def node(segment, opts)
def node({:interpolation, expression, _meta}, opts) do
formatted =
expression
|> String.trim()
|> Code.format_string!(opts)
String.replace(
"{{ #{formatted} }}",
"\n",
"\n#{String.duplicate(@tab, opts[:indent])}"
)
end
def node(:indent, opts) do
if opts[:indent] >= 0 do
String.duplicate(@tab, opts[:indent])
else
""
end
end
def node(:newline, _opts) do
# There are multiple newlines in a row; don't add spaces
# if there aren't going to be other characters after it
"\n"
end
def node(:space, _opts) do
" "
end
def node(:indent_one_less, opts) do
# Dedent once; this is before a closing tag, so it should be dedented from children
node(:indent, indent: opts[:indent] - 1)
end
def node(html, _opts) when is_binary(html) do
html
end
def node({tag, attributes, children, _meta}, opts) do
self_closing = Enum.empty?(children)
indentation = String.duplicate(@tab, opts[:indent])
rendered_attributes = Enum.map(attributes, &attribute/1)
attributes_on_same_line =
case rendered_attributes do
[] ->
""
rendered_attributes ->
# Prefix attributes string with a space (for after tag name)
joined_attributes =
rendered_attributes
|> Enum.map(fn
{:do_not_indent_newlines, attr} -> attr
attr -> attr
end)
|> Enum.join(" ")
" " <> joined_attributes
end
opening_on_one_line =
"<" <>
tag <>
attributes_on_same_line <>
"#{
if self_closing do
" /"
end
}>"
line_length = opts[:line_length] || @default_line_length
attributes_contain_newline = String.contains?(attributes_on_same_line, "\n")
line_length_exceeded = String.length(opening_on_one_line) > line_length
put_attributes_on_separate_lines =
length(attributes) > 1 and (attributes_contain_newline or line_length_exceeded)
# Maybe split opening tag onto multiple lines depending on line length
opening =
if put_attributes_on_separate_lines do
attr_indentation = String.duplicate(@tab, opts[:indent] + 1)
indented_attributes =
Enum.map(
rendered_attributes,
fn
{:do_not_indent_newlines, attr} ->
"#{attr_indentation}#{attr}"
attr ->
# This is pretty hacky, but it's an attempt to get things like
# class={{
# "foo",
# @bar,
# baz: true
# }}
# to look right
with_newlines_indented = String.replace(attr, "\n", "\n#{attr_indentation}")
"#{attr_indentation}#{with_newlines_indented}"
end
)
[
"<#{tag}",
indented_attributes,
"#{indentation}#{
if self_closing do
"/"
end
}>"
]
|> List.flatten()
|> Enum.join("\n")
else
# We're not splitting attributes onto their own newlines,
# but it's possible that an attribute has a newline in it
# (for interpolated maps/lists) so ensure those lines are indented.
# We're rebuilding the tag from scratch so we can respect
# :do_not_indent_newlines attributes.
attr_indentation = String.duplicate(@tab, opts[:indent])
attributes =
case rendered_attributes do
[] ->
""
_ ->
joined_attributes =
rendered_attributes
|> Enum.map(fn
{:do_not_indent_newlines, attr} -> attr
attr -> String.replace(attr, "\n", "\n#{attr_indentation}")
end)
|> Enum.join(" ")
# Prefix attributes string with a space (for after tag name)
" " <> joined_attributes
end
"<" <>
tag <>
attributes <>
"#{
if self_closing and not is_void_element?(tag) do
" /"
end
}>"
end
rendered_children =
if Formatter.render_contents_verbatim?(tag) do
Enum.map(children, fn
html when is_binary(html) ->
# Render out string portions of <pre>/<code>/<#MacroComponent> children
# verbatim instead of trimming them.
html
child ->
node(child, indent: 0)
end)
else
next_opts = Keyword.update(opts, :indent, 0, &(&1 + 1))
Enum.map(children, &node(&1, next_opts))
end
if self_closing do
"#{opening}"
else
"#{opening}#{rendered_children}</#{tag}>"
end
end
@spec attribute({String.t(), term, map}) ::
String.t() | {:do_not_indent_newlines, String.t()}
defp attribute({name, value, _meta}) when is_binary(value) do
# This is a string, and it might contain newlines. By returning
# `{:do_not_indent_newlines, formatted}` we instruct `node/1`
# to leave newlines alone instead of adding extra tabs at the
# beginning of the line.
#
# Before this behavior, the extra lines in the `bar` attribute below
# would be further indented each time the formatter was run.
#
# <Component foo=false bar="a
# b
# c"
# />
{:do_not_indent_newlines, "#{name}=\"#{String.trim(value)}\""}
end
# For `true` boolean attributes, simply including the name of the attribute
# without `=true` is shorthand for `=true`.
defp attribute({name, true, _meta}),
do: "#{name}"
defp attribute({name, false, _meta}),
do: "#{name}=false"
defp attribute({name, value, _meta}) when is_integer(value),
do: "#{name}=#{Code.format_string!("#{value}")}"
defp attribute({name, {:attribute_expr, expression, _expr_meta}, meta})
when is_binary(expression) do
# Wrap it in square brackets (and then remove after formatting)
# to support Surface sugar like this: `{{ foo: "bar" }}` (which is
# equivalent to `{{ [foo: "bar"] }}`
quoted_wrapped_expression =
try do
Code.string_to_quoted!("[#{expression}]")
rescue
_exception ->
# With some expressions such as function calls without parentheses
# (e.g. `Enum.map @items, & &1.foo`) wrapping in square brackets will
# emit invalid syntax, so we must catch that here
Code.string_to_quoted!(expression)
end
case quoted_wrapped_expression do
[literal] when is_boolean(literal) or is_binary(literal) or is_integer(literal) ->
# The code is a literal value in Surface brackets, e.g. {{ 12345 }} or {{ true }},
# that can exclude the brackets, so render it without the brackets
attribute({name, literal, meta})
_ ->
# This is a somewhat hacky way of checking if the contents are something like:
#
# foo={{ "bar", @baz, :qux }}
# foo={{ "bar", baz: true }}
#
# which is valid Surface syntax; an outer list wrapping the entire expression is implied.
has_invisible_brackets =
Keyword.keyword?(quoted_wrapped_expression) or
(is_list(quoted_wrapped_expression) and length(quoted_wrapped_expression) > 1) or
(is_list(quoted_wrapped_expression) and
Enum.any?(quoted_wrapped_expression, &is_keyword_item_with_interpolated_key?/1))
formatted_expression =
if has_invisible_brackets do
# Handle keyword lists, which will be stripped of the outer brackets
# per surface syntax sugar
"[#{expression}]"
|> Code.format_string!()
|> Enum.slice(1..-2)
|> to_string()
else
expression
|> Code.format_string!()
|> to_string()
end
if String.contains?(formatted_expression, "\n") do
# Don't add extra space characters around the curly braces because
# the formatted elixir code has newlines in it; this helps indentation
# to line up.
"#{name}={{#{formatted_expression}}}"
else
"#{name}={{ #{formatted_expression} }}"
end
end
end
defp attribute({name, strings_and_expressions, _meta})
when is_list(strings_and_expressions) do
formatted_expressions =
strings_and_expressions
|> Enum.map(fn
string when is_binary(string) ->
string
{:attribute_expr, expression, _expr_meta} ->
formatted_expression =
expression
|> Code.format_string!()
|> to_string()
"{{ #{formatted_expression} }}"
end)
|> Enum.join()
"#{name}=\"#{formatted_expressions}\""
end
defp is_keyword_item_with_interpolated_key?(item) do
case item do
{{{:., _, [:erlang, :binary_to_atom]}, _, [_, :utf8]}, _} -> true
_ -> false
end
end
defp is_void_element?(tag) do
tag in ~w(area base br col command embed hr img input keygen link meta param source track wbr)
end
end