defmodule CSSEx.Parser do @moduledoc """ The parser module that generates or writes a CSS file based on an entry file. """ import CSSEx.Helpers.Shared, only: [inc_col: 1, inc_col: 2, inc_line: 1, remove_last_from_chain: 1, inc_no_count: 2] import CSSEx.Helpers.Interpolations, only: [maybe_replace_val: 2] import CSSEx.Helpers.Error, only: [error_msg: 1] alias CSSEx.Helpers.Shared, as: HShared alias CSSEx.Helpers.Output @behaviour :gen_statem @timeout 15_000 @functions Enum.reduce(CSSEx.Helpers.Functions.__info__(:functions), %{}, fn {fun, arity}, acc -> Map.put( acc, Atom.to_string(fun), Function.capture(CSSEx.Helpers.Functions, fun, arity) ) end) @enforce_keys [:ets, :ets_fontface, :ets_keyframes, :line, :column] defstruct [ :ets, :ets_fontface, :ets_keyframes, :line, :column, :error, :answer_to, :to_file, no_count: 0, current_reg: [], base_path: nil, file: nil, file_list: [], pass: 1, scope: %{}, local_scope: %{}, assigns: %{}, local_assigns: %{}, current_chain: [], split_chain: [[]], valid?: true, current_key: [], current_value: [], current_var: [], current_assign: [], current_scope: nil, current_add_var: false, current_function: [], functions: @functions, level: 0, charset: nil, first_rule: true, warnings: [], media: %{}, media_parent: "", source_pid: nil, prefix: nil, font_face: false, font_face_count: 0, imports: [], dependencies: [], search_acc: [], order_map: %{c: 0}, keyframes_order_map: %{c: 0}, expandables: %{}, expandables_order_map: %{c: 0} ] @white_space CSSEx.Helpers.WhiteSpace.code_points() @line_terminators CSSEx.Helpers.LineTerminators.code_points() ## TODO # "@font-feature-values", # Allows authors to use a common name in font-variant-alternate for feature activated differently in OpenType @doc """ Takes a file path to a cssex or css file and parses it into a final CSS representation returning either: ``` {:ok, %CSSEx.Parser{}, final_binary} {:error, %CSSEx.Parser{}} ``` Additionally a `%CSSEx.Parser{}` struct with prefilled details can be passed as the first argument in which case the parser will use it as its configuration. You can also pass a file path as the last argument and instead of returning the final binary on the `:ok` tuple it will write the css directly into that file path and return an empty list instead of the final binary """ @spec parse_file(path :: String.t(), file_path :: String.t()) :: {:ok, %CSSEx.Parser{}, String.t()} | {:error, %CSSEx.Parser{error: String.t(), valid?: false}} def parse_file(base_path, file_path), do: parse_file(nil, base_path, file_path, nil) @spec parse_file(%CSSEx.Parser{} | String.t(), String.t(), String.t()) :: {:ok, %CSSEx.Parser{}, String.t() | []} | {:error, %CSSEx.Parser{error: String.t(), valid?: false}} def parse_file(%CSSEx.Parser{} = data, base_path, file_path), do: parse_file(data, base_path, file_path, nil) @spec parse_file( %CSSEx.Parser{} | nil, path :: String.t(), file_path :: String.t(), output_path :: String.t() | nil ) :: {:ok, %CSSEx.Parser{}, String.t() | []} | {:error, %CSSEx.Parser{error: String.t(), valid?: false}} def parse_file(base_path, file_path, parse_to_file), do: parse_file(nil, base_path, file_path, parse_to_file) def parse_file(data, base_path, file_path, parse_to_file) do {:ok, pid} = __MODULE__.start_link(data) :gen_statem.call(pid, {:start_file, base_path, file_path, parse_to_file}) end @doc """ Parses a `String.t` or a `charlist` and returns `{:ok, %CSSEx.Parser{}, content_or_empty_list}` or `{:error, %CSSEx.Parser{}}`. If a file path is passed as the final argument it returns the `:ok` tuple with an empty list instead of the content and writes into the file path. On error it returns an :error tuple with the `%CSSEx.Parser{}` having its `:error` key populated. If the first argument is a prefilled `%CSSEx.Parser{}` struct the parser uses that as its basis allowing to provide an `ETS` table that can be retrieved in the end, or passing predefined functions, assigns or variables, prefixes and etc into the context of the parser. """ @spec parse(content :: String.t() | charlist) :: {:ok, %CSSEx.Parser{valid?: true}, String.t() | []} | {:error, %CSSEx.Parser{error: String.t(), valid?: false}} def parse(content), do: parse(nil, content, nil) @spec parse(base_config :: %CSSEx.Parser{} | nil, content :: String.t() | charlist) :: {:ok, %CSSEx.Parser{valid?: true}, String.t() | []} | {:error, %CSSEx.Parser{error: String.t(), valid?: false}} def parse(%__MODULE__{} = data, content), do: parse(data, content, nil) def parse(content, file), do: parse(nil, content, file) @spec parse( base_config :: %CSSEx.Parser{} | nil, content :: String.t() | charlist, output_file :: String.t() | nil ) :: {:ok, %CSSEx.Parser{valid?: true}, String.t() | []} | {:error, %CSSEx.Parser{error: String.t(), valid?: false}} def parse(data, content, parse_to_file) when is_binary(content), do: parse(data, to_charlist(content), parse_to_file) def parse(data, content, parse_to_file) do {:ok, pid} = __MODULE__.start_link(data) :gen_statem.call(pid, {:start, content, parse_to_file}) end @impl :gen_statem def callback_mode(), do: :handle_event_function @doc false def start_link(nil) do :gen_statem.start_link(__MODULE__, nil, []) end def start_link(%__MODULE__{} = starting_data) do :gen_statem.start_link(__MODULE__, starting_data, []) end @impl :gen_statem def init(nil) do table_ref = :ets.new(:base, [:public]) table_font_face_ref = :ets.new(:font_face, [:public]) table_keyframes_ref = :ets.new(:keyframes, [:public]) {:ok, :waiting, %__MODULE__{ ets: table_ref, line: 1, column: 1, ets_fontface: table_font_face_ref, ets_keyframes: table_keyframes_ref, source_pid: self() }, [@timeout]} end def init(%__MODULE__{} = starting_data) do {:ok, :waiting, starting_data, [@timeout]} end @impl :gen_statem def handle_event({:call, from}, {:start, content, parse_to_file}, :waiting, data) do new_data = %__MODULE__{data | answer_to: from, to_file: parse_to_file} {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, content}}]} end def handle_event( {:call, from}, {:start_file, base_path, file_path, parse_to_file}, :waiting, %{file_list: file_list} = data ) do path = Path.expand(file_path, base_path) case path in file_list do true -> {:stop_and_reply, :normal, [ { :reply, from, {:error, add_error(data, error_msg({:cyclic_reference, path, file_list}))} } ]} _ -> case File.open(path, [:read, :charlist]) do {:ok, device} -> new_data = %__MODULE__{ data | answer_to: from, file: path, file_list: [path | file_list], base_path: base_path, to_file: parse_to_file } {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, IO.read(device, :all)}}]} {:error, :enoent} -> {:stop_and_reply, :normal, [ { :reply, from, {:error, add_error(data, error_msg({:enoent, path}))} } ]} end end end # we are in an invalid parsing state, abort and return an error with the current # state and data def handle_event(:internal, {:parse, _}, _state, %{valid?: false, answer_to: from} = data), do: {:stop_and_reply, :normal, [{:reply, from, {:error, data}}]} # we have reached the end of the binary, there's nothing else to do except answer # the caller, if we're in something else than {:parse, :next} it's an error def handle_event(:internal, {:parse, []}, state, %{answer_to: from} = data) do case state do {:parse, :next} -> reply_finish(data) _ -> {:stop_and_reply, :normal, [ {:reply, from, {:error, add_error(data)}} ]} end end # handle no_count null byte def handle_event(:internal, {:parse, [?$, 0, ?$, 0, ?$ | rem]}, _state, data), do: {:keep_state, inc_no_count(data, -1), [{:next_event, :internal, {:parse, rem}}]} # handle comments ['//', '/*'] |> Enum.each(fn chars -> def handle_event( :internal, {:parse, unquote(chars) ++ rem}, _state, data ) do new_data = data |> inc_col(2) |> open_current(:comment) case CSSEx.Helpers.Comments.parse(rem, new_data, unquote(chars)) do {:ok, {new_data, new_rem}} -> {:keep_state, close_current(new_data), [{:next_event, :internal, {:parse, new_rem}}]} {:error, new_data} -> {:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]} end end end) # Handle a function call, this is on top of everything as when outside EEx blocks, # meaning normal parsing, it should be replaced by the return value of the function # we parse from @fn:: ... to the end of the declaration ")", we do it in the Function # module as it has its own parsing nuances def handle_event( :internal, {:parse, '@fn::' ++ rem}, _state, data ) do new_data = data |> open_current(:function_call) |> inc_col(2) case CSSEx.Helpers.Function.parse_call(new_data, rem) do {:ok, {new_data_2, new_rem}} -> new_data_3 = new_data_2 |> close_current() {:keep_state, new_data_3, [{:next_event, :internal, {:parse, new_rem}}]} {:error, %{valid?: false} = error_data} -> {:keep_state, error_data, [{:next_event, :internal, {:parse, rem}}]} end end # Handle an @expandable declaration, this is on top of everything as it's only allowed on top level and should be handled specifically def handle_event( :internal, {:parse, '@expandable' ++ rem}, {:parse, :next}, %{current_chain: []} = data ) do new_data = data |> open_current(:expandable) |> inc_col(11) case CSSEx.Helpers.Expandable.parse(rem, new_data) do {:ok, {new_data_2, new_rem}} -> {:keep_state, reset_current(new_data_2), [{:next_event, :internal, {:parse, new_rem}}]} {:error, error} -> {:keep_state, add_error(new_data, error_msg(error)), [{:next_event, :internal, {:parse, rem}}]} end end def handle_event( :internal, {:parse, '@expandable' ++ rem}, state, data ) do new_data = add_error(data, error_msg({:expandable, state, data})) {:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]} end # Handle an @apply declaration def handle_event( :internal, {:parse, '@apply' ++ rem}, {:parse, :next}, data ) do new_data = data |> open_current(:apply) |> inc_col(6) case CSSEx.Helpers.Expandable.make_apply(rem, new_data) do {:ok, new_rem} -> new_data_2 = new_data |> close_current() |> inc_no_count(1) |> reset_current() {:keep_state, new_data_2, [{:next_event, :internal, {:parse, new_rem}}]} {:error, error} -> {:keep_state, add_error(new_data, error_msg(error)), [{:next_event, :internal, {:parse, rem}}]} end end Enum.each([{?], ?[}, {?), ?(}, {?", ?"}, {?', ?'}], fn {char, opening} -> def handle_event( :internal, {:parse, [unquote(char) | rem]}, {:find_terminator, unquote(opening), [], state}, %{search_acc: acc} = data ) do new_data = %{data | search_acc: [acc, unquote(char)]} |> close_current() |> inc_col(1) {:next_state, {:after_terminator, state}, new_data, [{:next_event, :internal, {:terminate, rem}}]} end def handle_event( :internal, {:parse, [unquote(char) | rem]}, {:find_terminator, unquote(opening), [next_search | old_search], state}, %{search_acc: acc} = data ) do new_data = %{data | search_acc: [acc, unquote(char)]} |> close_current() |> inc_col(1) {:next_state, {:find_terminator, next_search, old_search, state}, new_data, [{:next_event, :internal, {:parse, rem}}]} end end) Enum.each([?[, ?(, ?", ?'], fn char -> def handle_event( :internal, {:parse, [unquote(char) | rem]}, state, %{search_acc: acc} = data ) do new_data = %{data | search_acc: [acc, unquote(char)]} |> inc_col(1) |> open_current({:terminator, unquote(char)}) case state do {:find_terminator, prev_search, old_search, old_state} -> {:next_state, {:find_terminator, unquote(char), [prev_search | old_search], old_state}, new_data, [{:next_event, :internal, {:parse, rem}}]} _ -> {:next_state, {:find_terminator, unquote(char), [], state}, new_data, [{:next_event, :internal, {:parse, rem}}]} end end end) Enum.each(@line_terminators, fn char -> def handle_event( :internal, {:parse, [unquote(char) | rem]}, {:find_terminator, _, _, _}, %{search_acc: acc} = data ) do new_data = %{data | search_acc: [acc, unquote(char)]} |> inc_line() {:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]} end end) def handle_event( :internal, {:parse, [char | rem]}, {:find_terminator, _, _, _}, %{search_acc: acc} = data ) do new_data = %{data | search_acc: [acc, char]} |> inc_col(1) {:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@fn' ++ rem}, {:parse, :next}, data ) do new_data = data |> open_current(:function) |> inc_col(3) {:next_state, {:parse, :value, :current_function}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@include' ++ rem}, {:parse, :next}, data ) do new_data = data |> open_current(:include) |> inc_col(8) {:next_state, {:parse, :value, :include}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@import' ++ rem}, {:parse, :next}, data ) do new_data = data |> open_current(:import) |> inc_col(7) {:next_state, {:parse, :value, :import}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@charset' ++ rem}, {:parse, :next}, data ) do new_data = data |> open_current(:charset) |> inc_col(8) {:next_state, {:parse, :value, :charset}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@media' ++ rem}, {:parse, :next}, data ) do new_data = data |> open_current(:media) |> inc_col(6) {:next_state, {:parse, :value, :media}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@keyframes' ++ rem}, {:parse, :next}, data ) do new_data = data |> open_current(:keyframes) |> inc_col(10) {:next_state, {:parse, :value, :keyframes}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@font-face' ++ rem}, {:parse, :next}, %{current_chain: [], font_face: false, font_face_count: ffc} = data ) do new_data = %{data | font_face: true, font_face_count: ffc + 1} |> open_current(:fontface) |> inc_col(10) |> first_rule() {:next_state, {:parse, :current_key}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we have reached a closing bracket } while accumulating a font-face, reset the # font-face toggle and resume normal parsing def handle_event( :internal, {:parse, [125 | rem]}, {:parse, :next}, %{font_face: true} = data ) do new_data = %{data | font_face: false} |> close_current() |> inc_col(1) {:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we have reached a closing bracket } which means we should move back up in the # chain ditching our last value in it, and start searching for the next token def handle_event( :internal, {:parse, [125 | rem]}, {:parse, :next}, %{current_chain: [_ | _]} = data ) do new_data = data |> remove_last_from_chain() |> close_current() |> inc_col() {:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we reached a closing bracket without being inside a block and at the top level, # error out def handle_event( :internal, {:parse, [125 | _]}, {:parse, :next}, %{answer_to: from, current_chain: [], level: 0} = data ) do new_data = add_error(data, error_msg({:mismatched, "}"})) {:stop_and_reply, :normal, [{:reply, from, {:error, new_data}}]} end # we reached a closing bracket without being inside a block in an inner level, inc # the col and return to original the current data and the remaining text def handle_event( :internal, {:parse, [125 | rem]}, {:parse, :next}, %{answer_to: from, current_chain: []} = data ), do: {:stop_and_reply, :normal, [{:reply, from, {:finished, {inc_col(data), rem}}}]} # we reached a closing bracket when searching for a key/attribute ditch whatever we # have and add a warning def handle_event( :internal, {:parse, [125 | rem]}, {:parse, :current_var} = state, data ) do new_data = data |> add_warning(:missing, state) |> close_current() |> reset_current() |> inc_col() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we reached a closing bracket } while we were searching for a value to an attribute # inside a previously opened block (we have a current chain), meaning there's no ; # char, this is allowed on the last attr:val of a block, so we will do as if it was # there and just reparse adding the ; to the beggining def handle_event( :internal, {:parse, [125 | _] = full}, {:parse, :current_key}, %{current_chain: [_ | _]} ) do {:keep_state_and_data, [{:next_event, :internal, {:parse, [?; | full]}}]} end def handle_event( :internal, {:parse, [125 | _] = full}, {:parse, :current_key}, %{split_chain: [_ | _]} ) do {:keep_state_and_data, [{:next_event, :internal, {:parse, [?; | full]}}]} end # we reached an eex opening tag, because it requires dedicated handling and parsing # we move to a different parse step def handle_event( :internal, {:parse, '<%' ++ _ = full}, _state, data ) do case CSSEx.Helpers.EEX.parse(full, data) do {:error, new_data} -> {:keep_state, add_error(new_data), [{:next_event, :internal, {:parse, []}}]} {new_rem, %__MODULE__{} = new_data} -> {:keep_state, new_data, [{:next_event, :internal, {:parse, new_rem}}]} end end # we reached a new line char, reset the col, inc the line and continue Enum.each(@line_terminators, fn char -> # if we are parsing a var this is an error though def handle_event( :internal, {:parse, [unquote(char) | rem]}, state, data ) when state == {:parse, :current_var} or state == {:parse, :value, :current_var} do {:keep_state, add_error(inc_col(data)), [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, [unquote(char) | rem]}, _state, data ), do: {:keep_state, inc_line(data), [{:next_event, :internal, {:parse, rem}}]} end) Enum.each(@white_space, fn char -> # we reached a white-space char while searching for the next token, inc the column, # keep searching def handle_event( :internal, {:parse, [unquote(char) | rem]}, {:parse, :next}, data ), do: {:keep_state, inc_col(data), [{:next_event, :internal, {:parse, rem}}]} # we reached a white-space while building a variable, move to parse the value now def handle_event( :internal, {:parse, [unquote(char) | rem]}, {:parse, :current_var}, data ), do: {:next_state, {:parse, :value, :current_var}, inc_col(data), [{:next_event, :internal, {:parse, rem}}]} # we reached a white-space while building an assign, move to parse the value # now, the assign is special because it can be any term and needs to be # validated by compiling it so we do it in a special parse step def handle_event( :internal, {:parse, [unquote(char) | rem]}, {:parse, :current_assign}, data ) do {new_rem, new_data} = CSSEx.Helpers.Assigns.parse(rem, inc_col(data)) {:next_state, {:parse, :next}, reset_current(new_data), [{:next_event, :internal, {:parse, new_rem}}]} end # we reached a white-space while building a media query or keyframe name, include # the whitespace in the value def handle_event( :internal, {:parse, [unquote(char) | rem]}, {:parse, :value, type}, %{current_value: cval} = data ) when type in [:media, :keyframes, :import, :include], do: { :keep_state, %{data | current_value: [cval, unquote(char)]}, [{:next_event, :internal, {:parse, rem}}] } # we reached a white-space while building a value parsing - ditching the # white-space depends on if we're in the middle of a value or in the beginning # and the type of key we're searching def handle_event( :internal, {:parse, [unquote(char) | rem]}, {:parse, :value, type}, data ) do # we'll always inc the column counter no matter what new_data = inc_col(data) case Map.fetch!(data, type) do [] -> {:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]} nil when type in [:charset] -> {:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]} val -> new_data_2 = Map.put(new_data, type, [val, unquote(char)]) {:keep_state, new_data_2, [{:next_event, :internal, {:parse, rem}}]} end end end) # We found an assign assigment when searching for the next token, prepare for # parsing it def handle_event( :internal, {:parse, '@!' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:global) |> open_current(:assign) |> inc_col(2) {:next_state, {:parse, :current_assign}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@()' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:local) |> open_current(:assign) |> inc_col(3) {:next_state, {:parse, :current_assign}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '@?' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:conditional) |> open_current(:assign) |> inc_col(3) {:next_state, {:parse, :current_assign}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # We found a var assignment when searching for the next token, prepare for parsing it def handle_event( :internal, {:parse, '$!' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:global) |> open_current(:variable) |> inc_col(2) {:next_state, {:parse, :current_var}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '$*!' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:global) |> open_current(:variable) |> set_add_var() |> inc_col(3) {:next_state, {:parse, :current_var}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '$()' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:local) |> open_current(:variable) |> inc_col(3) {:next_state, {:parse, :current_var}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '$*()' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:local) |> open_current(:variable) |> set_add_var() |> inc_col(4) {:next_state, {:parse, :current_var}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '$?' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:conditional) |> open_current(:variable) |> inc_col(2) {:next_state, {:parse, :current_var}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:parse, '$*?' ++ rem}, {:parse, :next}, data ) do new_data = data |> set_scope(:conditional) |> open_current(:variable) |> set_add_var() |> inc_col(3) {:next_state, {:parse, :current_var}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we found the selector end char { opening a css inner context while searching for # the :current_key, which means that this is a selector that we were parsing, add it # and start searching for the next token (we use 123 because ?{ borks the # text-editor identation) def handle_event( :internal, {:parse, [123 | rem]}, {:parse, :current_key}, data ) do ## TODO validate it's a valid selector, error if not new_data = data |> add_current_selector() |> close_current() |> open_current(:selector) |> reset_current() |> inc_col() |> first_rule() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we found the selector end char { opening a css inner context while parsing the # @media attributes, we start a subsequent gen_statem to continue which will # accumulate itself and answer back to this one where it will merge what was found # there def handle_event( :internal, {:parse, [123 | rem]}, {:parse, :value, :media}, data ) do inner_data = %{data | order_map: %{c: 0}} |> create_data_for_inner(false, nil) |> add_media_parent(data) case __MODULE__.parse(inner_data, rem) do {:finished, {%{column: n_col, line: n_line} = new_inner_data, new_rem}} -> new_data = %{data | line: n_line, column: n_col} |> add_media_query(new_inner_data) |> close_current() |> reset_current() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, new_rem}}]} ## TODO error needs to stop correctly error -> stop_with_error(data, error) end end # we found the selector end char { opening a css inner context while parsing the # @keyframes name, we'll do the parsing for those in a new parser gen_statem because # we can't construct the key path correctly from this level, but inside the keyframe # block they're in all similar to a normal css selector + block, then with the # result of that parser we'll put all those elements inside a single selector # @keyframes + animation name def handle_event( :internal, {:parse, [123 | rem]}, {:parse, :value, :keyframes}, data ) do # create a new ets table, public, so that the new started process can write to it inner_data = create_data_for_inner(data) case __MODULE__.parse(inner_data, rem) do {:finished, {%{column: n_col, line: n_line} = new_inner_data, new_rem}} -> new_data = %{data | line: n_line, column: n_col} |> add_keyframe(new_inner_data) |> close_current() |> reset_current() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, new_rem}}]} ## TODO error needs to stop correctly error -> stop_with_error(data, error) end end # we found the selector end char { opening an inner content while parsing a @fn, # we'll do the parsing for those in a another module as it needs to evaluate the # parsed content and create an anonymous fun def handle_event( :internal, {:parse, '->' ++ rem}, {:parse, :value, :current_function}, data ) do case CSSEx.Helpers.Function.parse(inc_col(data, 2), rem) do {:ok, {new_data, new_rem}} -> new_data_2 = new_data |> close_current() |> reset_current() {:next_state, {:parse, :next}, new_data_2, [{:next_event, :internal, {:parse, new_rem}}]} error -> stop_with_error(data, error) end end # we found a non-white-space/line-end char while searching for the next token, # which means it's a regular css rule start, prepare for parsing it def handle_event( :internal, {:parse, [char | rem]}, {:parse, :next}, data ) do new_data = data |> Map.put(:current_key, [char]) |> open_current(:rule) |> inc_col() |> first_rule() {:next_state, {:parse, :current_key}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we reached the termination ; char while assembling an include statement, start a # new parser with the current ets table def handle_event( :internal, {:parse, [?; | rem]}, {:parse, :value, :include}, %{current_value: current_key, ets: ets, base_path: base_path} = data ) do file_path = current_key |> IO.chardata_to_string() |> String.replace(~r/\"?/, "") |> String.trim() inner_data = create_data_for_inner(%{data | line: 0, column: 0}, ets) case __MODULE__.parse_file(inner_data, base_path, file_path) do {:finished, %{file: file} = new_inner_data} -> new_data = data |> merge_inner_data(new_inner_data) |> close_current() |> reset_current() |> add_to_dependencies(file) |> merge_dependencies(new_inner_data) {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]} # TODO error needs to stop correctly error -> stop_with_error(data, error) end end # we reached the termination ; char while assembling a variable, cleanup and add it # to the correct scopes def handle_event( :internal, {:parse, [?; | rem]}, {:parse, :value, :current_var}, %{current_var: current_var, current_value: current_value} = data ) do cvar = IO.chardata_to_string(current_var) cval = String.trim_trailing(IO.chardata_to_string(current_value)) ## TODO add checks on var name && and value, emit warnings if invalid; new_data = data |> maybe_add_css_var(cvar, cval) |> add_to_var(cvar, cval) |> close_current() |> reset_current() |> inc_col() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we reached the termination ; char while assembling an attribute, cleanup and add # it to the correct ets slot def handle_event( :internal, {:parse, [?; | rem]}, {:parse, :current_key}, %{current_key: current_key} = data ) do current_key |> IO.chardata_to_string() |> String.split(":") |> case do [key, val] -> ckey = String.trim(key) cval = String.trim(val) ## TODO add checks on attribute & value, emit warnings if invalid; new_data = data |> add_to_attributes(ckey, cval) |> close_current() |> reset_current() |> inc_col() |> first_rule() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]} # this is probably a misplaced token we should error out unexpected -> error_msg = error_msg({:unexpected, IO.iodata_to_binary(unexpected)}) {:next_state, {:parse, :next}, add_error(data, error_msg), [{:next_event, :internal, {:parse, rem}}]} end end def handle_event( :internal, {:parse, [?; | rem]}, {:parse, :value, :current_value}, %{current_key: current_key, current_value: current_value} = data ) do ckey = IO.chardata_to_string(current_key) cval = String.trim_trailing(IO.chardata_to_string(current_value)) ## TODO add checks on attribute & value, emit warnings if invalid; new_data = data |> add_to_attributes(ckey, cval) |> close_current() |> reset_current() |> inc_col() |> first_rule() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:terminate, rem}, {:after_terminator, {:parse, type} = next}, %{search_acc: acc} = data ) do new_data = case type != :next do true -> Map.put(data, type, [Map.fetch!(data, type), acc]) _ -> data end {:next_state, next, %{new_data | search_acc: []}, [{:next_event, :internal, {:parse, rem}}]} end def handle_event( :internal, {:terminate, rem}, {:after_terminator, {:parse, :value, _type} = next}, %{search_acc: acc, current_value: cval} = data ) do new_data = %{data | search_acc: [], current_value: [cval, acc]} {:next_state, next, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we're accumulating on something, add the value to that type we're accumulating def handle_event( :internal, {:parse, [char | rem] = full}, {:parse, type}, data ), do: {:keep_state, Map.put(data, type, [Map.fetch!(data, type), char]), [{:next_event, :internal, {:parse, rem}}]} # we reached the termination ; char while assembling a special attribute, # @charset, cleanup and verify it's valid to add def handle_event( :internal, {:parse, [?; | rem]}, {:parse, :value, :charset}, data ) do new_data = data |> validate_charset() |> close_current() |> reset_current() |> inc_col() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # we reached the termination ; char while assembling a special attribute, @import, # cleanup and verify it's valid to add def handle_event( :internal, {:parse, [?; | rem]}, {:parse, :value, :import}, data ) do new_data = data |> validate_import() |> close_current() |> reset_current() |> inc_col() {:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]} end # a valid char while we're accumulating for a value, add it and continue def handle_event( :internal, {:parse, [char | rem]}, {:parse, :value, _type}, %{current_value: cval} = data ), do: {:keep_state, %{inc_col(data) | current_value: [cval, char]}, [{:next_event, :internal, {:parse, rem}}]} @doc false # set the scope for whatever we're doing, scopes can only be set by when # parsing variables or assigns if it's not nil there's a problem def set_scope(%{current_scope: nil} = data, scope), do: %{data | current_scope: scope} @doc false # set var, it should be always false when this is called for the same reasons as # set_scope def set_add_var(%{current_add_var: false} = data), do: %{data | current_add_var: true} @doc false # add the variable to the global and local scopes def add_to_var( %{current_scope: :global, scope: scope, local_scope: local_scope} = data, key, val ), do: %{ data | scope: Map.put(scope, key, val), local_scope: Map.put(local_scope, key, val) } # add the variable only to the local scope def add_to_var( %{current_scope: :local, local_scope: local_scope} = data, key, val ), do: %{data | local_scope: Map.put(local_scope, key, val)} # conditionally add variable to the local scope if it's not in scope def add_to_var( %{current_scope: :conditional, local_scope: local_scope, scope: scope} = data, key, val ) do case Map.get(scope, key) do nil -> case Map.get(local_scope, key) do nil -> %{data | local_scope: Map.put(local_scope, key, val)} _ -> data end _ -> data end end @doc false # add to font-face ETS table when dealing with a font-face block def add_to_attributes( %{font_face: true, ets_fontface: ets, font_face_count: ffc} = data, key, val ) do case maybe_replace_val(val, data) do {:ok, new_val} -> case HShared.valid_attribute_kv?(key, new_val) do true -> case :ets.lookup(ets, ffc) do [{_, existing}] -> :ets.insert(ets, {ffc, Map.put(existing, key, new_val)}) [] -> :ets.insert(ets, {ffc, Map.put(%{}, key, new_val)}) end data false -> add_error(data, error_msg({:invalid_declaration, key, new_val})) end {:error, {:not_declared, _, _} = error} -> add_error(data, error_msg(error)) end end # add attribute to the ETS table def add_to_attributes(data, key, val) do case maybe_replace_val(val, data) do {:ok, new_val} -> case HShared.valid_attribute_kv?(key, new_val) do true -> Output.write_element(data, key, new_val) false -> add_error(data, error_msg({:invalid_declaration, key, new_val})) end {:error, {:not_declared, _, _} = error} -> add_error(data, error_msg(error)) end end @doc false # add a special case for when parsing a font-face def add_current_selector(%{font_face: true} = data), do: data # add the current_selector to the current_chain def add_current_selector(%{current_key: ck} = data) do current_selector = String.trim(IO.chardata_to_string(ck)) case maybe_replace_val(current_selector, data) do {:ok, replaced_selector} -> HShared.add_selector_to_chain(data, replaced_selector) _ -> %{data | valid?: false, error: "variable in #{current_selector} was not declared"} end end @doc false # reset the accumulators and scope def reset_current(data), do: %{ data | current_key: [], current_value: [], current_var: [], current_assign: [], current_scope: nil, current_add_var: false, current_function: [] } @doc false # TODO when tightening the scopes this has to take into account creating a variable in a given selector, right now it will crash when variables that create css vars (@*) are declared inside elements def maybe_add_css_var(%{current_add_var: false} = data, _, _), do: data def maybe_add_css_var( %{ current_add_var: true, current_scope: current_scope, local_scope: local_scope, scope: scope, current_chain: current_chain, split_chain: split_chain } = data, key, val ) do new_val = case current_scope do :conditional -> Map.get(local_scope, key, Map.get(scope, key, false)) || val _ -> val end new_cc = case current_chain do [] -> ":root" _ -> split_chain end case maybe_replace_val(new_val, data) do {:ok, new_val_2} -> add_css_var(data, new_cc, key, new_val_2) {:error, {:not_declared, _, _} = error} -> add_error(data, error_msg(error)) end end @doc false def add_css_var(%{ets: ets, order_map: %{c: c} = om} = data, cc, key, val) do new_om = case :ets.lookup(ets, cc) do [{_, existing}] -> :ets.insert(ets, {cc, Map.put(existing, "--#{key}", val)}) om [] -> :ets.insert(ets, {cc, Map.put(%{}, "--#{key}", val)}) om |> Map.put(:c, c + 1) |> Map.put(cc, c) |> Map.put(c, cc) end %{data | order_map: new_om} end @doc false def validate_charset(%{current_value: charset, charset: nil, first_rule: true} = data) do new_charset = charset |> IO.chardata_to_string() |> String.trim(~s(")) %{data | charset: ~s("#{new_charset}")} end def validate_charset( %{charset: charset, first_rule: first_rule, line: line, column: col, warnings: warnings} = data ) do warning_1 = case charset do nil -> [] _ -> ["Only a single @charset declaration is valid l:#{line} c:#{col}"] end warning_2 = case first_rule do true -> warning_1 _ -> [ "@charset declaration must be the first rule in a spreadsheet l:#{line} c:#{col}" | warning_1 ] end %{data | warnings: :lists.flatten([warning_2 | warnings])} |> reset_current() end @doc false def validate_import(%{current_value: current_value, first_rule: true, imports: imports} = data) do {:ok, cval} = current_value |> IO.chardata_to_string() |> String.trim() |> maybe_replace_val(data) no_quotes = String.trim(cval, "\"") %{data | imports: [imports | ["@import", " ", cval, ";"]]} |> add_to_dependencies(no_quotes) end def validate_import(%{first_rule: false, line: line, column: col, warnings: warnings} = data) do %{ data | warnings: [ "@import declarations must be at the top level of a file, with exception of the @charset declaration that comes always first if present l:#{ line } c:#{col}" | warnings ] } end @doc false def first_rule(%{first_rule: false} = data), do: data def first_rule(data), do: %{data | first_rule: false} @doc false def add_warning(%{warnings: warnings, column: col, line: line} = data, :missing, {:parse, _}), do: %{ data | warnings: [ "Incomplete declaration at l:#{line} and c:#{col} - this line was removed" | warnings ] } @doc false # reply back according to the level def reply_finish(%{answer_to: from, level: 0} = data) do reply = case Output.do_finish(data) do {:ok, %Output{acc: final_css}} -> {:ok, data, final_css} {:error, %Output{data: data}} -> {:error, data} end { :stop_and_reply, :normal, [{:reply, from, reply}] } end def reply_finish(%{answer_to: from} = data) do { :stop_and_reply, :normal, [{:reply, from, {:finished, data}}] } end @doc false def add_media_query( %{current_value: current_value, media_parent: media_p} = data, %{ets: inner_ets, media: media, order_map: om} ) do parsed = current_value |> IO.chardata_to_string() |> String.trim() |> to_charlist() {parsed_2, data} = CSSEx.Helpers.Media.parse(parsed, data) case maybe_replace_val(parsed_2, data) do {:ok, cval} -> media_query = ["@media", media_p, cval] |> Enum.filter(fn element -> String.length(element) > 0 end) |> Enum.join(" ") new_media = case Map.get(media, media_query) do nil -> Map.put(media, media_query, {inner_ets, om}) {original_ets, existing_om} -> new_om = Output.write_media(inner_ets, original_ets, existing_om) :ets.delete(inner_ets) Map.put(media, media_query, {original_ets, new_om}) end %{data | media: new_media} {:error, {:not_declared, _, _} = error} -> add_error(data, error_msg(error)) end end @doc false def add_keyframe( %{current_value: current_value} = data, %{ets: inner_ets} = _inner_data ) do parsed = IO.chardata_to_string(current_value) case maybe_replace_val(parsed, data) do {:ok, cval} -> full_path = "@keyframes #{String.trim(cval)}" new_data = Output.write_keyframe(data, full_path, inner_ets) :ets.delete(inner_ets) new_data {:error, {:not_declared, _, _} = error} -> add_error(data, error_msg(error)) end end @doc false def create_data_for_inner( %{ line: line, column: col, level: level, ets_fontface: etsff, ets_keyframes: etskf, font_face_count: ffc, assigns: assigns, local_assigns: l_assigns, scope: scope, local_scope: l_scope, functions: functions, media: media, media_parent: media_parent, source_pid: source_pid, order_map: order_map, keyframes_order_map: keyframe_order_map, no_count: no_count } = data, ets \\ nil, prefix \\ nil ) do inner_ets = if(ets, do: ets, else: :ets.new(:inner, [:public, {:heir, source_pid, "INNER_ETS"}])) inner_prefix = if(prefix, do: prefix, else: if(prefix == false, do: nil, else: CSSEx.Helpers.Shared.generate_prefix(data)) ) inner_assigns = Map.merge(assigns, l_assigns) inner_scope = Map.merge(scope, l_scope) inner_split_chain = [if(inner_prefix, do: inner_prefix, else: [])] %__MODULE__{ ets: inner_ets, line: line, column: col, no_count: no_count, level: level + 1, prefix: inner_prefix, ets_fontface: etsff, ets_keyframes: etskf, font_face_count: ffc, local_assigns: inner_assigns, local_scope: inner_scope, functions: functions, split_chain: inner_split_chain, media: media, source_pid: source_pid, media_parent: media_parent, order_map: order_map, keyframes_order_map: keyframe_order_map } end @doc false def add_media_parent( data, %{current_value: current_value, media_parent: media_parent} = _parent_data ) do {parsed_2, data} = CSSEx.Helpers.Media.parse(:lists.flatten(current_value), data) new_media_parent = [ media_parent, IO.chardata_to_string(parsed_2) ] |> Enum.map(fn element -> String.trim(element) end) |> Enum.join(" ") |> String.trim() %{data | media_parent: new_media_parent} end @doc false def merge_inner_data( %{ warnings: existing_warnings, scope: existing_scope, assigns: existing_assigns, functions: existing_functions # media: media } = data, %{ warnings: warnings, media: media, scope: scope, assigns: assigns, valid?: valid?, font_face_count: ffc, error: error, functions: functions, order_map: om, keyframes_order_map: kom } ) do %__MODULE__{ data | valid?: valid?, font_face_count: ffc, warnings: :lists.concat([existing_warnings, warnings]), scope: Map.merge(existing_scope, scope), assigns: Map.merge(existing_assigns, assigns), functions: Map.merge(existing_functions, functions), error: error, media: media, order_map: om, keyframes_order_map: kom } end @doc false def merge_dependencies(%{dependencies: deps} = data, %__MODULE__{dependencies: new_deps}), do: %{data | dependencies: Enum.concat(deps, new_deps)} @doc false def add_to_dependencies(%{dependencies: deps, file: file} = data, val) do new_deps = case not is_nil(file) and not is_nil(val) do true -> base_path = Path.dirname(file) final_path = CSSEx.assemble_path(val, base_path) [final_path | deps] _ -> deps end %{data | dependencies: new_deps} end @doc false def stop_with_error(%{answer_to: from}, {:error, %__MODULE__{} = invalid}), do: {:stop_and_reply, invalid, [{:reply, from, {:error, invalid}}]} def stop_with_error(%{answer_to: from} = data, {:error, error}) do new_data = add_error(data, error_msg(error)) {:stop_and_reply, new_data, [{:reply, from, {:error, new_data}}]} end @doc false def add_error(%{current_reg: [{s_line, s_col, step} | _]} = data) do final_error = "#{error_msg({:terminator, step})} at l:#{s_line} col:#{s_col} to" add_error(%{data | current_reg: []}, final_error) end @doc false def add_error(%{line: line, column: column, current_key: current_key} = data, error) do %{data | valid?: false, error: "#{inspect(error)} :: l:#{line} c:#{column}"} |> finish_error() end @doc false def finish_error(%{file_list: file_list, error: error} = data) do %{ data | error: Enum.reduce(file_list, error, fn file, acc -> acc <> "\n on file: " <> file end) } end @doc false def open_current(%{current_reg: creg, line: l, column: c} = data, element) do %{data | current_reg: [{l, c, element} | creg]} end @doc false def close_current(%{current_reg: [_ | t]} = data), do: %{data | current_reg: t} def close_current(%{current_reg: [], level: level} = data) when level > 0, do: data end