Packages

PostCSS for Elixir - A tool for transforming CSS. An Elixir implementation of the popular PostCSS JavaScript library, providing CSS parsing, AST manipulation, and stringification capabilities.

Current section

Files

Jump to
postcss lib post_css parser.ex
Raw

lib/post_css/parser.ex

defmodule PostCSS.Parser do
@moduledoc """
CSS Parser for PostCSS.
Converts tokens from the tokenizer into an Abstract Syntax Tree (AST).
"""
alias PostCSS.{Tokenizer, Root, Rule, Declaration, AtRule, Comment, CssSyntaxError, Raws}
@doc """
Parses CSS string into a PostCSS AST.
"""
def parse(css) when is_binary(css) do
css
|> Tokenizer.tokenize()
|> parse_tokens()
end
defp parse_tokens(tokens) do
tokens
|> filter_meaningful_tokens()
|> parse_root()
end
defp filter_meaningful_tokens(tokens) do
# Keep spaces for selector parsing, remove only standalone spaces not between words
tokens
end
defp parse_root(tokens) do
{nodes, remaining} = parse_nodes(tokens)
# Collect any trailing whitespace
trailing_whitespace = collect_trailing_whitespace(remaining, "")
unless Enum.empty?(remaining) and trailing_whitespace == "" do
# If we have trailing whitespace, that's fine
# If we have other tokens, that's an error
non_whitespace_remaining =
Enum.reject(remaining, fn
{:space, _, _} -> true
{:space, _, _, _} -> true
_ -> false
end)
unless Enum.empty?(non_whitespace_remaining) do
raise CssSyntaxError.new("Unexpected tokens at end of input")
end
end
root = %Root{nodes: nodes}
# Add trailing whitespace to raws.after if present
if trailing_whitespace != "" do
Raws.put(root, :after, trailing_whitespace)
else
root
end
end
defp collect_trailing_whitespace([], acc), do: acc
defp collect_trailing_whitespace([{:space, space, _} | rest], acc) do
collect_trailing_whitespace(rest, acc <> space)
end
defp collect_trailing_whitespace([{:space, space, _, _} | rest], acc) do
collect_trailing_whitespace(rest, acc <> space)
end
defp collect_trailing_whitespace(_, acc), do: acc
defp parse_nodes(tokens) do
parse_nodes_with_whitespace(tokens, [], "")
end
defp parse_nodes_with_whitespace([], acc, whitespace) do
# If we have trailing whitespace, preserve it as remaining space tokens
remaining =
if whitespace != "" do
[{:space, whitespace, 0}]
else
[]
end
{Enum.reverse(acc), remaining}
end
defp parse_nodes_with_whitespace([{:close_brace, _, _} | _] = tokens, acc, _whitespace) do
{Enum.reverse(acc), tokens}
end
defp parse_nodes_with_whitespace([{:space, space, _} | rest], acc, whitespace) do
# Collect whitespace for the next node
parse_nodes_with_whitespace(rest, acc, whitespace <> space)
end
defp parse_nodes_with_whitespace([{:space, space, _, _} | rest], acc, whitespace) do
# Collect whitespace for the next node
parse_nodes_with_whitespace(rest, acc, whitespace <> space)
end
defp parse_nodes_with_whitespace(tokens, acc, whitespace) do
case parse_node(tokens) do
{node, remaining} ->
# Add collected whitespace to node's raws.before
node_with_raws =
if whitespace != "" do
Raws.put(node, :before, whitespace)
else
node
end
parse_nodes_with_whitespace(remaining, [node_with_raws | acc], "")
:error ->
raise CssSyntaxError.new("Failed to parse node")
end
end
defp parse_node(tokens) do
case tokens do
# Comment
[{:comment, comment_text, _, _} | rest] ->
parse_comment(comment_text, rest)
# At-rule: @import, @media, etc.
[{:at_word, at_rule_text, _, _} | rest] ->
parse_at_rule(at_rule_text, rest)
# Simple declaration: word : value
[{:word, prop, _, _} | rest] ->
rest = skip_spaces(rest)
case rest do
[{:colon, _, _} | value_tokens] ->
# This is a declaration
case parse_declaration_value(value_tokens) do
{value, important, remaining} ->
decl = %Declaration{prop: prop, value: value, important: important}
{decl, remaining}
:error ->
raise CssSyntaxError.new("Failed to parse declaration value")
end
_ ->
# This might be a selector, collect all tokens until we find an open brace
case collect_selector_tokens(tokens) do
{[_ | _] = selector_tokens, [{:open_brace, _, _} | rule_rest]} ->
selector = extract_selector_from_tokens(selector_tokens)
{declarations, remaining} = parse_declarations(rule_rest, [])
case remaining do
[{:close_brace, _, _} | rest_tokens] ->
rule = %Rule{selector: selector, nodes: declarations}
{rule, rest_tokens}
[] ->
raise CssSyntaxError.new("Unclosed rule: missing '}'")
_ ->
raise CssSyntaxError.new("Expected '}' after rule declarations")
end
{[_ | _] = tokens_found, _} ->
[first_token | _] = tokens_found
raise CssSyntaxError.new("Unexpected token: #{inspect(first_token)}")
_ ->
:error
end
end
# Other tokens - try to parse as selector
_ ->
case collect_selector_tokens(tokens) do
{[_ | _] = selector_tokens, [{:open_brace, _, _} | rule_rest]} ->
selector = extract_selector_from_tokens(selector_tokens)
{declarations, remaining} = parse_declarations(rule_rest, [])
case remaining do
[{:close_brace, _, _} | rest_tokens] ->
rule = %Rule{selector: selector, nodes: declarations}
{rule, rest_tokens}
[] ->
raise CssSyntaxError.new("Unclosed rule: missing '}'")
_ ->
raise CssSyntaxError.new("Expected '}' after rule declarations")
end
{[_ | _] = tokens_found, _} ->
[first_token | _] = tokens_found
raise CssSyntaxError.new("Unexpected token: #{inspect(first_token)}")
_ ->
:error
end
end
end
defp collect_selector_tokens(tokens) do
collect_selector_tokens(tokens, [])
end
defp collect_selector_tokens([], acc), do: {Enum.reverse(acc), []}
defp collect_selector_tokens([{:open_brace, _, _} | _] = tokens, acc),
do: {Enum.reverse(acc), tokens}
defp collect_selector_tokens([token | rest], acc),
do: collect_selector_tokens(rest, [token | acc])
defp extract_selector_from_tokens(tokens) do
tokens
|> Enum.map(fn
{:word, value, _, _} -> value
{:at_word, value, _, _} -> value
{:space, value, _} -> value
{:space, value, _, _} -> value
{:comma, value, _} -> value
{:colon, value, _} -> value
{_, value, _} -> value
{_, value, _, _} -> value
end)
|> Enum.join("")
|> String.trim()
end
defp parse_declarations(tokens, acc) do
parse_declarations_with_whitespace(tokens, acc, "")
end
defp parse_declarations_with_whitespace(tokens, acc, whitespace) do
case tokens do
[{:close_brace, _, _} | _] ->
{Enum.reverse(acc), tokens}
[] ->
{Enum.reverse(acc), []}
[{:space, space, _} | rest] ->
# Collect whitespace for the next node
parse_declarations_with_whitespace(rest, acc, whitespace <> space)
[{:space, space, _, _} | rest] ->
# Collect whitespace for the next node
parse_declarations_with_whitespace(rest, acc, whitespace <> space)
[{:comment, comment_text, _, _} | rest] ->
# Handle comments inside rule blocks
{comment, remaining} = parse_comment(comment_text, rest)
comment_with_raws =
if whitespace != "" do
Raws.put(comment, :before, whitespace)
else
comment
end
parse_declarations_with_whitespace(remaining, [comment_with_raws | acc], "")
_ ->
case parse_declaration(tokens) do
{decl, remaining} ->
# Add collected whitespace to declaration's raws.before
decl_with_raws =
if whitespace != "" do
Raws.put(decl, :before, whitespace)
else
decl
end
# Skip semicolon if present, but preserve whitespace for next declaration
remaining = skip_semicolon_preserve_whitespace(remaining)
parse_declarations_with_whitespace(remaining, [decl_with_raws | acc], "")
:error ->
{Enum.reverse(acc), tokens}
end
end
end
defp parse_declaration([{:word, prop, _, _} | rest]) do
# Collect spaces before colon
{before_colon, after_spaces} = collect_spaces_before_colon(rest, "")
case after_spaces do
[{:colon, _, _} | value_tokens] ->
# Collect spaces after colon
{after_colon, value_tokens} = collect_spaces(value_tokens, "")
case parse_declaration_value(value_tokens) do
{value, important, remaining} ->
# Build the between string (spaces + colon + spaces)
between = before_colon <> ":" <> after_colon
decl = %Declaration{prop: prop, value: value, important: important}
decl_with_raws = Raws.put(decl, :between, between)
{decl_with_raws, remaining}
:error ->
:error
end
_ ->
:error
end
end
defp collect_spaces_before_colon([{:space, space, _} | rest], acc) do
collect_spaces_before_colon(rest, acc <> space)
end
defp collect_spaces_before_colon([{:space, space, _, _} | rest], acc) do
collect_spaces_before_colon(rest, acc <> space)
end
defp collect_spaces_before_colon(tokens, acc), do: {acc, tokens}
defp collect_spaces([{:space, space, _} | rest], acc) do
collect_spaces(rest, acc <> space)
end
defp collect_spaces([{:space, space, _, _} | rest], acc) do
collect_spaces(rest, acc <> space)
end
defp collect_spaces(tokens, acc), do: {acc, tokens}
defp skip_spaces([{:space, _, _} | rest]), do: skip_spaces(rest)
defp skip_spaces([{:space, _, _, _} | rest]), do: skip_spaces(rest)
defp skip_spaces(tokens), do: tokens
defp parse_declaration_value(tokens) do
case collect_value_tokens(tokens, []) do
{value_tokens, remaining} ->
{value, important} = process_value_tokens(value_tokens)
{value, important, remaining}
:error ->
:error
end
end
defp collect_value_tokens([], acc), do: {Enum.reverse(acc), []}
defp collect_value_tokens([{:semicolon, _, _} | _] = tokens, acc) do
{Enum.reverse(acc), tokens}
end
defp collect_value_tokens([{:close_brace, _, _} | _] = tokens, acc) do
{Enum.reverse(acc), tokens}
end
defp collect_value_tokens([token | rest], acc) do
collect_value_tokens(rest, [token | acc])
end
defp process_value_tokens(tokens) do
# Check for !important
case Enum.reverse(tokens) do
[{:word, "!important", _, _} | rest_tokens] ->
value = extract_value_from_tokens(Enum.reverse(rest_tokens))
{value, true}
_ ->
value = extract_value_from_tokens(tokens)
{value, false}
end
end
defp extract_value_from_tokens(tokens) do
tokens
|> Enum.map(fn
{:word, value, _, _} -> value
{:string, value, _, _} -> value
{:colon, value, _} -> value
{:at_word, value, _, _} -> value
{_, value, _} -> value
{_, value, _, _} -> value
end)
|> Enum.join("")
|> String.trim()
end
# Skip semicolon but preserve whitespace (for use in parse_declarations_with_whitespace)
defp skip_semicolon_preserve_whitespace([{:semicolon, _, _} | rest]), do: rest
defp skip_semicolon_preserve_whitespace(tokens), do: tokens
defp parse_at_rule(at_rule_text, tokens) do
# Extract the rule name (remove the @ symbol)
name = String.slice(at_rule_text, 1..-1//1)
# Collect parameters until we hit a semicolon or opening brace
{param_tokens, remaining} = collect_at_rule_params(tokens, [])
# Convert parameter tokens to a string
params =
if Enum.empty?(param_tokens) do
nil
else
extract_params_from_tokens(param_tokens)
end
case remaining do
[{:semicolon, _, _} | rest] ->
# Simple at-rule like @import url(...);
at_rule = %AtRule{name: name, params: params, nodes: []}
{at_rule, rest}
[{:open_brace, _, _} | rest] ->
# Block at-rule like @media (...) { ... }
# Use parse_nodes to handle both rules and declarations inside at-rules
{nodes, remaining} = parse_nodes_with_whitespace(rest, [], "")
case remaining do
[{:close_brace, _, _} | rest_tokens] ->
at_rule = %AtRule{name: name, params: params, nodes: nodes}
{at_rule, rest_tokens}
[] ->
raise CssSyntaxError.new("Unclosed at-rule: missing '}'")
_ ->
raise CssSyntaxError.new("Expected '}' after at-rule content")
end
[] ->
# At-rule at end of input
at_rule = %AtRule{name: name, params: params, nodes: []}
{at_rule, []}
_ ->
raise CssSyntaxError.new("Expected ';' or '{' after at-rule")
end
end
defp collect_at_rule_params([], acc), do: {Enum.reverse(acc), []}
defp collect_at_rule_params([{:semicolon, _, _} | _] = tokens, acc),
do: {Enum.reverse(acc), tokens}
defp collect_at_rule_params([{:open_brace, _, _} | _] = tokens, acc),
do: {Enum.reverse(acc), tokens}
defp collect_at_rule_params([token | rest], acc),
do: collect_at_rule_params(rest, [token | acc])
defp extract_params_from_tokens(tokens) do
tokens
|> Enum.map(fn
{:word, value, _, _} -> value
{:string, value, _, _} -> value
{:open_paren, value, _} -> value
{:close_paren, value, _} -> value
{:space, value, _} -> value
{:space, value, _, _} -> value
{:comma, value, _} -> value
{:colon, value, _} -> value
{_, value, _} -> value
{_, value, _, _} -> value
end)
|> Enum.join("")
|> String.trim()
end
defp parse_comment(comment_text, remaining_tokens) do
# Extract comment content (remove /* and */)
# comment_text is like "/* comment */"
text =
comment_text
# Remove /* and */
|> String.slice(2..-3//1)
# Parse whitespace like PostCSS does
if String.trim(text) == "" do
# Empty comment, preserve all whitespace as left
comment = %Comment{text: "", raws: %{left: text, right: ""}}
{comment, remaining_tokens}
else
# Parse leading and trailing whitespace
case Regex.run(~r/^(\s*)(.*?\S)(\s*)$/s, text) do
[_, left, content, right] ->
comment = %Comment{text: content, raws: %{left: left, right: right}}
{comment, remaining_tokens}
nil ->
# Fallback if regex doesn't match
comment = %Comment{text: text, raws: %{}}
{comment, remaining_tokens}
end
end
end
end