Current section

Files

Jump to
cssex lib parser.ex~
Raw

lib/parser.ex~

defmodule CSSEx.Parser do
@behaviour :gen_statem
@timeout 15_000
@enforce_keys [:ets, :line, :column]
defstruct [
:ets,
:line,
:column,
:error,
:answer_to,
pass: 1,
scope: %{},
local_scope: %{},
assigns: %{},
local_assigns: %{},
current_chain: [],
valid: true,
current_key: "",
current_value: "",
current_var: "",
current_assign: "",
current_scope: nil,
level: 0
]
@white_space CSSEx.Helpers.WhiteSpace.code_points()
@line_terminators CSSEx.Helpers.LineTerminators.code_points()
@doc """
Takes a binary or an IO.device(), parses it into a final CSS representation and returns either:
{:ok, final_binary, term}
{:error, term}
"""
def parse(content) do
{:ok, pid} = __MODULE__.start_link()
:gen_statem.call(pid, {:start, content})
end
@impl :gen_statem
def callback_mode(), do: :handle_event_function
def start_link() do
:gen_statem.start_link(__MODULE__, nil, [])
end
def init(content) do
table_ref = :ets.new(:base, [])
{:ok, :waiting, %__MODULE__{ets: table_ref, line: 0, column: 0}, [@timeout]}
end
def handle_event({:call, from}, {:start, content}, :waiting, data) do
new_data = %__MODULE__{data | answer_to: from}
{:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, content}}]}
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, {state, 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, {state, data}}}]}
end
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::binary>>},
{:parse, :next},
%{current_chain: [_|_] = cc} = data
) do
[_ | new_cc_1] = :lists.reverse(cc)
new_cc_2 = :lists.reverse(new_cc_1)
new_data =
%{data | current_chain: new_cc_2}
|> inc_col()
{:keep_state, new_data, [{:next_event, :internal, {:parse, rem}}]}
end
def handle_event(:internal,
{:parse, <<125, _::binary>>},
{:parse, :next} = state,
%{answer_to: from} = data
) do
new_data = %{data | valid: false, error: "Mismatched }"}
{:stop_and_reply, :normal, [{:reply, from, {:error, {state, new_data}}}]}
end
# we reached a new line char, reset the col, inc the line and continue
Enum.each(@line_terminators, fn(char) ->
def handle_event(:internal,
{:parse, <<unquote(char), rem::binary>>},
_,
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::binary>>},
{:parse, :next},
data
), do: {:keep_state, inc_col(data), [{:next_event, :internal, {:parse, rem}}]}
# we reached a white-space while building either a variable or an assign, move to parse the value now
def handle_event(:internal,
{:parse, <<unquote(char), rem::binary>>},
{:parse, type},
data
) when type in [:current_var, :current_assign],
do: {:next_state, {:parse, :value, type}, inc_col(data), [{: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::binary>>},
{: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
"" -> {:next_state, {:parse, :value, type}, new_data, [{:next_event, :internal, {:parse, rem}}]}
val ->
new_data_2 = Map.put(new_data, type, [val | unquote(char)])
{:next_state, {:parse, :value, type}, new_data_2, [{:next_event, :internal, {:parse, rem}}]}
end
end
end)
# We found a var assignment when searching for the next token, prepare for parsing it
def handle_event(:internal,
{:parse, <<"@!", rem::binary>>},
{:parse, :next},
data
) do
new_data =
data
|> set_scope(:global)
|> inc_col(2)
{:next_state, {:parse, :current_var}, new_data, [{:next_event, :internal, {:parse, rem}}]}
end
def handle_event(:internal,
{:parse, <<"@()", rem::binary>>},
{:parse, :next},
data
) do
new_data =
data
|> set_scope(:local)
|> inc_col(3)
{:next_state, {:parse, :current_var}, new_data, [{:next_event, :internal, {:parse, rem}}]}
end
def handle_event(:internal,
{:parse, <<"@?", rem::binary>>},
{:parse, :next},
data
) do
new_data =
data
|> set_scope(:conditional)
|> inc_col(2)
{: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::binary>>},
{:parse, :current_key},
data
) do
new_data =
data
|> add_current_selector()
|> reset_current()
|> inc_line()
{:next_state, {:parse, :next}, new_data, [{:next_event, :internal, {:parse, rem}}]}
end
# we found the key separator : while searching for the :current_key, which means this is an attribute that we were parsing, add it and start searching for the next token which will be the value
def handle_event(:internal,
{:parse, <<?:, rem::binary>>},
{:parse, :current_key},
data
) do
{:next_state, {:parse, :value, :current_value}, inc_col(data), [{:next_event, :internal, {:parse, rem}}]}
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::binary>>},
{:parse, :next},
data
) do
new_data =
data
|> Map.put(:current_key, [char])
|> inc_col()
{:next_state, {:parse, :current_key}, 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::binary>>},
{: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 variable, cleanup and add it to the correct scopes
def handle_event(:internal,
{:parse, <<?;, rem::binary>>},
{:parse, :value, :current_var},
%{current_var: current_var, current_value: current_value} = data
) do
cvar = IO.iodata_to_binary(current_var)
# we trim the trailing because the end could theoretically have white-spaces/new lines
cval = String.trim_trailing(IO.iodata_to_binary(current_value))
new_data =
data
|> add_to_var(cvar, cval)
|> 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::binary>>},
{:parse, :value, :current_value},
%{current_key: current_key, current_value: current_value} = data
) do
ckey = IO.iodata_to_binary(current_key)
cval = String.trim_trailing(IO.iodata_to_binary(current_value))
new_data =
data
|> add_to_attributes(ckey, cval)
|> 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::binary>>},
{:parse, :value, _type},
%{current_value: cval} = data
), do: {:keep_state, %{inc_col(data) | current_value: [cval, char]}, [{:next_event, :internal, {:parse, rem}}]}
# increment the column token count
def inc_col(%{column: column} = data, amount \\ 1),
do: %{data | column: column + amount}
# increment the line and reset the column
def inc_line(%{line: line} = data, amount \\ 1),
do: %{data | column: 0, line: line + amount}
# 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}
# 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
# add attribute to the ETS table
def add_to_attributes(%{ets: ets, current_chain: cc} = data, key, val) do
case maybe_replace_val(val, data) do
{:ok, new_val} ->
case :ets.lookup(ets, cc) do
[{_, existing}] -> :ets.insert(ets, {cc, [existing, key, ":", new_val, ";"]})
[] -> :ets.insert(ets, {cc, [key, ":", new_val, ";"]})
end
data
{:error, :not_declared} ->
%{data | valid: false, error: "#{val} was not declared"}
end
end
# add the current_selector to the current_chain
def add_current_selector(%{current_chain: cc, current_key: cs} = data) do
current_selector = String.trim_trailing(IO.iodata_to_binary(cs))
Map.put(data, :current_chain, cc ++ [current_selector])
end
# reset the accumulators and scope
def reset_current(data),
do: %{
data |
current_key: "",
current_value: "",
current_var: "",
current_assign: "",
current_scope: nil
}
# replaces the value if it mentions a cssex variable and that variable is bound in either the local_scope (first match) or the global scope (second match)
def maybe_replace_val(<<"@::", var_name::binary>>, %{local_scope: ls}) when is_map_key(ls, var_name),
do: {:ok, Map.fetch!(ls, var_name)}
def maybe_replace_val(<<"@::", var_name::binary>>, %{scope: scope}) when is_map_key(scope, var_name),
do: {:ok, Map.fetch!(scope, var_name)}
def maybe_replace_val(<<"@::", _::binary>>, _), do: {:error, :not_declared}
def maybe_replace_val(val, _), do: val
# reply back according to the level
def reply_finish(%{answer_to: from, ets: ets, level: 0} = data) do
css = :ets.foldl(
fn({selector, attributes}, acc) ->
[acc, selector, "{", attributes, "}\n"]
end,
[],
ets
)
{:stop_and_reply, :normal, [{:reply, from, {:ok, data, IO.iodata_to_binary(css)}}]}
end
end