Current section
Files
Jump to
Current section
Files
lib/parser.ex
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}
]
@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 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
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: [_ | _]} = data
) do
{:keep_state, 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(":", parts: 2)
|> 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]},
{: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, [existing, ";", key, ":", new_val]})
[] -> :ets.insert(ets, {ffc, [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: cs} = data) do
current_selector = String.trim(IO.chardata_to_string(cs))
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: []
} = data,
key,
val
) do
check_if_to_add =
case current_scope do
:conditional -> !(Map.get(scope, key, false) and Map.get(local_scope, key, false))
_ -> true
end
case check_if_to_add do
true ->
case maybe_replace_val(val, data) do
{:ok, new_val} ->
add_css_var(data, [":root"], key, new_val)
{:error, {:not_declared, _, _} = error} ->
add_error(data, error_msg(error))
end
_ ->
data
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, [existing, ";--", key, ":", val]})
om
[] ->
:ets.insert(ets, {cc, ["--", 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)]
folded_css = Output.fold_attributes_table(inner_ets)
new_data = Output.write_keyframe(data, full_path, folded_css)
: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} = 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