defmodule Combo.Template.CEExEngine.Tokenizer do @moduledoc false alias Combo.Template.CEExEngine.SyntaxError @space_chars ~c"\s\t\f" @quote_chars ~c"\"'" @stop_chars ~c">/=\r\n" ++ @space_chars ++ @quote_chars @type line :: pos_integer() @type column :: pos_integer() @type expr :: String.t() @type text :: {:text, String.t(), %{line_end: line(), column_end: column()}} @type tag_name :: String.t() @type raw_tag_name :: String.t() @type tag_attr_name :: String.t() | :root @type tag_attr_value :: nil | {:string, String.t(), %{delimiter: char()}} | {:expr, expr(), %{line: line(), column: column()}} @type tag_attr_meta :: %{line: line(), column: column()} @type tag_attr :: {tag_attr_name(), tag_attr_value(), tag_attr_meta()} @type tag_open_meta :: %{ tag_name: raw_tag_name(), void?: boolean(), self_closing?: boolean(), line: line(), column: column(), inner_location: {line(), column} } @type tag_close_meta :: %{ tag_name: raw_tag_name(), void?: boolean(), line: line(), column: column(), inner_location: {line(), column} } @type tag :: {:htag, tag_name(), [tag_attr()], tag_open_meta()} | {:close, :htag, tag_name(), tag_close_meta()} | {:remote_component, tag_name(), [tag_attr()], tag_open_meta()} | {:close, :remote_component, tag_name(), tag_close_meta()} | {:local_component, tag_name(), [tag_attr()], tag_open_meta()} | {:close, :local_component, tag_name(), tag_close_meta()} | {:slot, tag_name(), [tag_attr()], tag_open_meta()} | {:close, :slot, tag_name(), tag_close_meta()} @type body_expr :: {:body_expr, expr(), %{line: line(), column: column()}} @type contents :: String.t() @type tokens :: [text() | tag() | body_expr()] @type cont :: {:text, :enabled} | :style | :script | {:comment, line(), column()} @type source :: String.t() @type tokenize_opt :: {:file, String.t()} | {:indentation, non_neg_integer()} | {:line, line()} | {:column, column()} | {:cont, cont()} @type tokenize_opts :: [tokenize_opt()] @type finalize_opt :: {:file, String.t()} @type finalize_opts :: [finalize_opt()] @doc """ Tokenize the given contents according to the given options. ### Arguments * `contents` - the contents to be tokenized. * `tokens` - a list of existing tokens. * `source` - the source which is used to show code snippet. * `opts`: * `:file` - a string as the path of file. Default to `"nofile"`. * `:indentation` - an integer that indicates the indentation. Default to `0`. * `:line` - an integer to start as line. Default to `1`. * `:column` - an integer to start as column. Default to `1`. Note it's the value relative to the current indentation level. * `:cont` - the continuation which indicates current processing context. ### Examples iex> contents = "
" iex> tokens = [] iex> source = contents iex> opts = [] iex> Tokenizer.tokenize(contents, tokens, source, opts) {[ {:close, :htag, "section", %{ tag_name: "section", void?: false, line: 1, column: 16, inner_location: {1, 16} }}, {:htag, "div", [], %{ tag_name: "div", void?: false, self_closing?: true, line: 1, column: 10, inner_location: {1, 16} }}, {:htag, "section", [], %{ tag_name: "section", void?: false, self_closing?: false, line: 1, column: 1, inner_location: {1, 10} }} ], {:text, :enabled}} """ @spec tokenize(source()) :: {tokens(), cont()} def tokenize(source) do tokens = [] contents = source opts = [] tokenize(contents, tokens, source, opts) end @spec tokenize(source(), tokenize_opts()) :: {tokens(), cont()} def tokenize(source, opts) do tokens = [] contents = source tokenize(contents, tokens, source, opts) end @spec tokenize(source(), tokens(), tokenize_opts()) :: {tokens(), cont()} def tokenize(source, tokens, opts) do contents = source tokenize(contents, tokens, source, opts) end @spec tokenize(contents(), tokens(), source(), tokenize_opts()) :: {tokens(), cont()} def tokenize(contents, tokens, source, opts) do file = opts[:file] || "nofile" indentation = opts[:indentation] || 0 line = opts[:line] || 1 column = indentation + (opts[:column] || 1) cont = opts[:cont] || {:text, :enabled} buffer = [] state = %{ source: source, file: file, indentation: indentation, braces: :enabled, context: [] } case cont do {:text, braces} -> handle_text(contents, line, column, buffer, tokens, %{state | braces: braces}) :style -> handle_style(contents, line, column, buffer, tokens, state) :script -> handle_script(contents, line, column, buffer, tokens, state) {:comment, _line, _column} -> handle_comment(contents, line, column, buffer, tokens, state) end end @doc """ Gets the final tokens. """ @spec finalize(tokens(), cont(), source()) :: tokens() def finalize(tokens, cont, source) do finalize(tokens, cont, source, []) end @spec finalize(tokens(), cont(), source(), finalize_opts()) :: tokens() def finalize(_tokens, {:comment, line, column}, source, opts) do file = opts[:file] || "nofile" message = "unexpected end of string inside tag" raise_syntax_error!(message, {line, column}, %{source: source, file: file, indentation: 0}) end def finalize(tokens, _cont, _source, _opts) do tokens |> trim_leading_whitespace_tokens() |> Enum.reverse() |> trim_leading_whitespace_tokens() end ## handle_text defp handle_text("\r\n" <> rest, line, _column, buffer, tokens, state) do handle_text(rest, line + 1, state.indentation + 1, ["\r\n" | buffer], tokens, state) end defp handle_text("\n" <> rest, line, _column, buffer, tokens, state) do handle_text(rest, line + 1, state.indentation + 1, ["\n" | buffer], tokens, state) end defp handle_text(" rest, line, column, buffer, tokens, state) do handle_doctype(rest, line, column + 9, [" rest, line, column, buffer, tokens, state) do handle_doctype(rest, line, column + 9, [" rest, line, column, buffer, tokens, state) do state = update_in(state.context, &[:comment_start | &1]) handle_comment(rest, line, column + 4, ["" <> rest, line, column, buffer, _state) do {:text, rest, line, column + 3, ["-->" | buffer]} end defp handle_comment(<>, line, column, buffer, state) do handle_comment(rest, line, column + 1, [char_or_bin(c) | buffer], state) end defp handle_comment(<<>>, line, column, buffer, _state) do {:eoc, line, column, buffer} end ## handle_style defp handle_style("" <> rest, line, column, buffer, tokens, state) do tokens = [ {:close, :htag, "style", %{line: line, column: column, inner_location: {line, column}}} | tokenize_buffer(buffer, tokens, line, column, []) ] handle_text(rest, line, column + 8, [], tokens, state) end defp handle_style("\r\n" <> rest, line, _column, buffer, tokens, state) do handle_style(rest, line + 1, state.indentation + 1, ["\r\n" | buffer], tokens, state) end defp handle_style("\n" <> rest, line, _column, buffer, tokens, state) do handle_style(rest, line + 1, state.indentation + 1, ["\n" | buffer], tokens, state) end defp handle_style(<>, line, column, buffer, tokens, state) do handle_style(rest, line, column + 1, [char_or_bin(c) | buffer], tokens, state) end defp handle_style(<<>>, line, column, buffer, tokens, _state) do ok(tokenize_buffer(buffer, tokens, line, column, []), :style) end ## handle_script defp handle_script("" <> rest, line, column, buffer, tokens, state) do tokens = [ {:close, :htag, "script", %{line: line, column: column, inner_location: {line, column}}} | tokenize_buffer(buffer, tokens, line, column, []) ] handle_text(rest, line, column + 9, [], tokens, state) end defp handle_script("\r\n" <> rest, line, _column, buffer, tokens, state) do handle_script(rest, line + 1, state.indentation + 1, ["\r\n" | buffer], tokens, state) end defp handle_script("\n" <> rest, line, _column, buffer, tokens, state) do handle_script(rest, line + 1, state.indentation + 1, ["\n" | buffer], tokens, state) end defp handle_script(<>, line, column, buffer, tokens, state) do handle_script(rest, line, column + 1, [char_or_bin(c) | buffer], tokens, state) end defp handle_script(<<>>, line, column, buffer, tokens, _state) do ok(tokenize_buffer(buffer, tokens, line, column, []), :script) end ## handle_tag_open defp handle_tag_open(text, line, column, tokens, state) do case handle_tag_name(text, column, []) do {:ok, name, new_column, rest} -> meta = %{ tag_name: name, void?: :pending, self_closing?: :pending, line: line, column: column - 1, inner_location: :pending } case classify_tag_name(name) do {:ok, {type, name}} -> void? = type == :htag and void_tag?(name) meta = %{meta | void?: void?} tokens = [{type, name, [], meta} | tokens] handle_maybe_tag_open_end(rest, line, new_column, tokens, state) {:error, message} -> raise_syntax_error!(message, {line, column}, state) end :error -> message = "missing tag name after <" raise_syntax_error!(message, {line, column}, state) end end ## handle_tag_close defp handle_tag_close(text, line, column, tokens, state) do case handle_tag_name(text, column, []) do {:ok, name, new_column, rest} -> meta = %{ tag_name: name, void?: :pending, line: line, column: column - 2, inner_location: {line, column - 2} } case classify_tag_name(name) do {:ok, {type, name}} -> void? = type == :htag and void_tag?(name) meta = %{meta | void?: void?} tokens = [{:close, type, name, meta} | tokens] handle_maybe_tag_close_end(rest, line, new_column, tokens, state) {:error, message} -> raise_syntax_error!(message, {line, column - 2}, state) end :error -> message = "missing tag name after " <> rest, line, column, tokens, state) do handle_text(rest, line, column + 1, [], tokens, pop_braces(state)) end defp handle_maybe_tag_close_end(_, line, column, _tokens, state) do message = "missing closing `>` for tag" raise_syntax_error!(message, {line, column}, state) end ## handle_tag_name defp handle_tag_name(<> = text, column, buffer) when c in @stop_chars do done_tag_name(text, column, buffer) end defp handle_tag_name(<>, column, buffer) do handle_tag_name(rest, column + 1, [char_or_bin(c) | buffer]) end defp handle_tag_name(<<>>, column, buffer) do done_tag_name(<<>>, column, buffer) end defp done_tag_name(_rest, _column, []) do :error end defp done_tag_name(rest, column, buffer) do name = buffer_to_string(buffer) {:ok, name, column, rest} end ## handle_maybe_tag_open_end defp handle_maybe_tag_open_end("\r\n" <> rest, line, _column, tokens, state) do handle_maybe_tag_open_end(rest, line + 1, state.indentation + 1, tokens, state) end defp handle_maybe_tag_open_end("\n" <> rest, line, _column, tokens, state) do handle_maybe_tag_open_end(rest, line + 1, state.indentation + 1, tokens, state) end defp handle_maybe_tag_open_end(<>, line, column, tokens, state) when c in @space_chars do handle_maybe_tag_open_end(rest, line, column + 1, tokens, state) end defp handle_maybe_tag_open_end("/>" <> rest, line, column, tokens, state) do tokens = normalize_tag(tokens, line, column + 2, true) handle_text(rest, line, column + 2, [], tokens, state) end defp handle_maybe_tag_open_end(">" <> rest, line, column, tokens, state) do case normalize_tag(tokens, line, column + 1, false) do [{:htag, "script", _, _} | _] = tokens -> handle_script(rest, line, column + 1, [], tokens, state) [{:htag, "style", _, _} | _] = tokens -> handle_style(rest, line, column + 1, [], tokens, state) tokens -> handle_text(rest, line, column + 1, [], tokens, push_braces(state)) end end defp handle_maybe_tag_open_end("{" <> rest, line, column, tokens, state) do handle_root_attribute(rest, line, column + 1, tokens, state) end defp handle_maybe_tag_open_end(<<>>, line, column, _tokens, state) do message = ~S""" missing closing `>` or `/>` for tag Make sure the tag is properly closed. This may happen if there is an EEx interpolation inside a tag, which is not supported. For instance, instead of
content
do
content
If @id is nil or false, then no attribute is sent at all. Inside {...} you can place any Elixir expression. If you want to interpolate in the middle of an attribute value, instead of
content
you can pass an Elixir string with interpolation:
content
""" raise_syntax_error!(message, {line, column}, state) end defp handle_maybe_tag_open_end(text, line, column, tokens, state) do handle_attribute(text, line, column, tokens, state) end ## handle_root_attribute defp handle_root_attribute(text, line, column, tokens, state) do case handle_interpolation(text, line, column, [], 0, state) do {:ok, expr, new_line, new_column, rest} -> meta = %{line: line, column: column} tokens = put_attr(tokens, :root, {:expr, expr, meta}, meta) handle_maybe_tag_open_end(rest, new_line, new_column, tokens, state) {:error, message} -> # use column - 1 to point to the opening { raise_syntax_error!(message, {line, column - 1}, state) end end ## handle_attribute defp handle_attribute(text, line, column, tokens, state) do case handle_attr_name(text, column, []) do {:ok, name, new_column, rest} -> attr_meta = %{line: line, column: column} {rest, line, column, value} = handle_maybe_attr_value(rest, line, new_column, state) tokens = put_attr(tokens, name, value, attr_meta) state = if name == "ceex-no-curly-interpolation" and state.braces == :enabled and not style_or_script?(tokens) do %{state | braces: 0} else state end handle_maybe_tag_open_end(rest, line, column, tokens, state) {:error, message, column} -> raise_syntax_error!(message, {line, column}, state) end end defp style_or_script?([{:htag, name, _, _} | _]) when name in ~w(style script), do: true defp style_or_script?(_), do: false ## handle_attr_name defp handle_attr_name(<>, column, _buffer) when c in @quote_chars do {:error, "invalid character in attribute name, got: #{<>}", column} end defp handle_attr_name(<>, column, []) when c in @stop_chars do {:error, "missing attribute name", column} end defp handle_attr_name(<> = text, column, buffer) when c in @stop_chars do {:ok, buffer_to_string(buffer), column, text} end defp handle_attr_name(<>, column, buffer) do handle_attr_name(rest, column + 1, [char_or_bin(c) | buffer]) end defp handle_attr_name(<<>>, column, _buffer) do {:error, "unexpected end of string inside tag", column} end ## handle_maybe_attr_value defp handle_maybe_attr_value("\r\n" <> rest, line, _column, state) do handle_maybe_attr_value(rest, line + 1, state.indentation + 1, state) end defp handle_maybe_attr_value("\n" <> rest, line, _column, state) do handle_maybe_attr_value(rest, line + 1, state.indentation + 1, state) end defp handle_maybe_attr_value(<>, line, column, state) when c in @space_chars do handle_maybe_attr_value(rest, line, column + 1, state) end defp handle_maybe_attr_value("=" <> rest, line, column, state) do handle_attr_value_begin(rest, line, column + 1, state) end defp handle_maybe_attr_value(rest, line, column, _state) do {rest, line, column, nil} end ## handle_attr_value_begin defp handle_attr_value_begin("\r\n" <> rest, line, _column, state) do handle_attr_value_begin(rest, line + 1, state.indentation + 1, state) end defp handle_attr_value_begin("\n" <> rest, line, _column, state) do handle_attr_value_begin(rest, line + 1, state.indentation + 1, state) end defp handle_attr_value_begin(<>, line, column, state) when c in @space_chars do handle_attr_value_begin(rest, line, column + 1, state) end defp handle_attr_value_begin("\"" <> rest, line, column, state) do handle_attr_value_quote(rest, ?", line, column + 1, [], state) end defp handle_attr_value_begin("'" <> rest, line, column, state) do handle_attr_value_quote(rest, ?', line, column + 1, [], state) end defp handle_attr_value_begin("{" <> rest, line, column, state) do handle_attr_value_brace(rest, line, column + 1, state) end defp handle_attr_value_begin(_text, line, column, state) do message = """ invalid attribute value after `=` Expected a value between quotes (such as "value" or 'value') \ or an Elixir expression between curly braces (such as `{expr}`).\ """ raise_syntax_error!(message, {line, column}, state) end ## handle_attr_value_quote defp handle_attr_value_quote("\r\n" <> rest, delimiter, line, _column, buffer, state) do handle_attr_value_quote( rest, delimiter, line + 1, state.indentation + 1, ["\r\n" | buffer], state ) end defp handle_attr_value_quote("\n" <> rest, delimiter, line, _column, buffer, state) do handle_attr_value_quote( rest, delimiter, line + 1, state.indentation + 1, ["\n" | buffer], state ) end defp handle_attr_value_quote( <>, delimiter, line, column, buffer, _state ) do value = buffer_to_string(buffer) {rest, line, column + 1, {:string, value, %{delimiter: delimiter}}} end defp handle_attr_value_quote(<>, delimiter, line, column, buffer, state) do handle_attr_value_quote(rest, delimiter, line, column + 1, [char_or_bin(c) | buffer], state) end defp handle_attr_value_quote(<<>>, delimiter, line, column, _buffer, state) do message = """ missing closing `#{<>}` for attribute value Make sure the attribute is properly closed. This may also happen if there is an EEx interpolation inside a tag, which is not supported. Instead of
>
do
Where @attrs must be a keyword list or a map. """ raise_syntax_error!(message, {line, column}, state) end ## handle_attr_value_brace defp handle_attr_value_brace(text, line, column, state) do case handle_interpolation(text, line, column, [], 0, state) do {:ok, expr, new_line, new_column, rest} -> {rest, new_line, new_column, {:expr, expr, %{line: line, column: column}}} {:error, message} -> # use column - 1 to point to the opening { raise_syntax_error!(message, {line, column - 1}, state) end end ## handle_interpolation defp handle_interpolation("\r\n" <> rest, line, _column, buffer, braces, state) do handle_interpolation(rest, line + 1, state.indentation + 1, ["\r\n" | buffer], braces, state) end defp handle_interpolation("\n" <> rest, line, _column, buffer, braces, state) do handle_interpolation(rest, line + 1, state.indentation + 1, ["\n" | buffer], braces, state) end defp handle_interpolation("}" <> rest, line, column, buffer, 0, _state) do expr = buffer_to_string(buffer) {:ok, expr, line, column + 1, rest} end defp handle_interpolation(~S(\}) <> rest, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 2, [~S(\}) | buffer], braces, state) end defp handle_interpolation(~S(\{) <> rest, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 2, [~S(\{) | buffer], braces, state) end defp handle_interpolation("}" <> rest, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 1, ["}" | buffer], braces - 1, state) end defp handle_interpolation("{" <> rest, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 1, ["{" | buffer], braces + 1, state) end defp handle_interpolation(<>, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 1, [char_or_bin(c) | buffer], braces, state) end defp handle_interpolation(<<>>, _line, _column, _buffer, _braces, _state) do {:error, """ missing closing `}` for expression In case you don't want `{` to begin a new interpolation, \ you may write it using `{` or using `<%= "{" %>`.\ """} end ## helpers @compile {:inline, ok: 2, char_or_bin: 1} defp ok(tokens, cont), do: {tokens, cont} defp char_or_bin(c) when c <= 127, do: c defp char_or_bin(c), do: <> defp buffer_to_string(buffer) do IO.iodata_to_binary(Enum.reverse(buffer)) end defp tokenize_buffer(buffer, tokens, line, column, context) defp tokenize_buffer([], tokens, _line, _column, _context), do: tokens defp tokenize_buffer(buffer, tokens, line, column, context) do meta = %{line_end: line, column_end: column} meta = if context == [] do meta else Map.put(meta, :context, trim_context(context)) end [{:text, buffer_to_string(buffer), meta} | tokens] end defp trim_context([:comment_end, :comment_start | [_ | _] = rest]), do: trim_context(rest) defp trim_context(rest), do: Enum.reverse(rest) defp push_braces(%{braces: :enabled} = state), do: state defp push_braces(%{braces: braces} = state), do: %{state | braces: braces + 1} defp pop_braces(%{braces: :enabled} = state), do: state defp pop_braces(%{braces: 1} = state), do: %{state | braces: :enabled} defp pop_braces(%{braces: braces} = state), do: %{state | braces: braces - 1} defp put_attr([{type, name, attrs, meta} | rest], attr_name, attr_value, attr_meta) do new_attrs = [{attr_name, attr_value, attr_meta} | attrs] [{type, name, new_attrs, meta} | rest] end defp classify_tag_name(<> = name) when first in ?A..?Z do if valid_remote_component_name?(name), do: {:ok, {:remote_component, name}}, # The original intent might have been use HTML tags, but the first letter # was incorrectly capitalized, or it could have been an attempt to call # a remote component, but with the name in wrong format. # Due to this dual possibility, the error message given here is not # "invalid remote component name". else: {:error, "invalid tag name"} end defp classify_tag_name("."), do: {:error, "missing local component name after ."} defp classify_tag_name("." <> name) do if valid_local_component_name?(name), do: {:ok, {:local_component, name}}, else: {:error, "invalid local component name after ."} end defp classify_tag_name(":"), do: {:error, "missing slot name after :"} defp classify_tag_name(":inner_block"), do: {:error, "the slot name `:inner_block` is reserved"} defp classify_tag_name(":" <> name) do if valid_slot_name?(name), do: {:ok, {:slot, name}}, else: {:error, "invalid slot name after :"} end defp classify_tag_name(name), do: {:ok, {:htag, name}} @remote_component_name_pattern ~r/^[A-Z][a-zA-Z0-9_]*(\.[A-Z][a-zA-Z0-9_]*)*\.[a-z_][a-zA-Z0-9_]*[!?]?$/ defp valid_remote_component_name?(name) do Regex.match?(@remote_component_name_pattern, name) end @local_component_name_pattern ~r/^[a-z_][a-zA-Z0-9_]*[!?]?$/ defp valid_local_component_name?(name) do Regex.match?(@local_component_name_pattern, name) end @slot_name_pattern ~r/^[a-z_][a-zA-Z0-9_]*[!?]?$/ defp valid_slot_name?(name) do Regex.match?(@slot_name_pattern, name) end for name <- ~w(area base br col hr img input link meta param command keygen source) do defp void_tag?(unquote(name)), do: true end defp void_tag?(_), do: false defp normalize_tag([{type, name, attrs, meta} | rest], line, column, self_closing?) do attrs = Enum.reverse(attrs) meta = %{meta | inner_location: {line, column}, self_closing?: self_closing?} [{type, name, attrs, meta} | rest] end defp trim_leading_whitespace_tokens(tokens) do with [{:text, text, _} | rest] <- tokens, "" <- String.trim(text) do trim_leading_whitespace_tokens(rest) else _ -> tokens end end defp raise_syntax_error!(message, {line, column}, state) do %{source: source, file: file, indentation: indentation} = state raise SyntaxError, file: file, line: line, column: column, description: message <> SyntaxError.code_snippet(source, indentation, {line, column}) end end