Skip to main content

lib/vize/css.ex

defmodule Vize.CSS do
  @moduledoc """
  CSS parsing, printing, and AST traversal helpers.

  The AST is produced by LightningCSS and represented as Elixir maps, lists,
  strings, numbers, booleans, and `nil`. Use `parse_ast/2` to parse CSS,
  transform the returned AST with `prewalk/2` or `postwalk/2`, and then use
  `print_ast/2` to serialize it back to CSS.
  """

  @type css_result :: %{
          optional(:exports) => %{optional(String.t()) => String.t()} | nil,
          code: String.t(),
          css_vars: [String.t()],
          errors: [String.t()],
          warnings: [String.t()]
        }

  @type ast :: map() | list() | String.t() | number() | boolean() | nil

  @type ast_result :: %{
          ast: map() | nil,
          errors: [String.t()],
          warnings: [String.t()]
        }

  @type url_ref :: Vize.CSS.URL.t()

  @doc """
  Compile CSS using LightningCSS.

  Parses, autoprefixes, and optionally minifies CSS. Also handles Vue scoped CSS
  transformation, `v-bind()` extraction, and CSS Modules.

  ## Options

    * `:minify` — minify the output (default: `false`)
    * `:scoped` — apply Vue scoped CSS transformation (default: `false`)
    * `:scope_id` — scope ID for scoped CSS (e.g. `"data-v-abc123"`)
    * `:filename` — filename for error reporting
    * `:css_modules` — enable CSS Modules scoping (default: `false`)
    * `:targets` — browser targets for autoprefixing, map with optional
      `:chrome`, `:firefox`, `:safari` keys as major version integers

  ## Examples

      iex> {:ok, result} = Vize.CSS.compile(".foo { color: red }")
      iex> result.code =~ "color"
      true
      iex> result.errors
      []
  """
  @spec compile(String.t(), keyword()) :: {:ok, css_result()}
  def compile(source, opts \\ []) do
    minify = Keyword.get(opts, :minify, false)
    scoped = Keyword.get(opts, :scoped, false)
    scope_id = Keyword.get(opts, :scope_id, "")
    filename = Keyword.get(opts, :filename, "")
    css_modules = Keyword.get(opts, :css_modules, false)
    targets = Keyword.get(opts, :targets, %{})
    chrome = Map.get(targets, :chrome, -1)
    firefox = Map.get(targets, :firefox, -1)
    safari = Map.get(targets, :safari, -1)

    Vize.Native.compile_css_nif(
      source,
      minify,
      scoped,
      scope_id,
      filename,
      chrome,
      firefox,
      safari,
      css_modules
    )
  end

  @doc "Like `compile/2` but raises on errors."
  @spec compile!(String.t(), keyword()) :: css_result()
  def compile!(source, opts \\ []) do
    case compile(source, opts) do
      {:ok, result} ->
        if result.errors != [] do
          raise "Vize CSS compile error: #{inspect(result.errors)}"
        end

        result
    end
  end

  @doc """
  Bundle a CSS file and all its `@import` dependencies into a single stylesheet.

  Reads the entry file and all imported files from disk, resolving `@import`
  rules recursively. The result is a single merged stylesheet with all imports
  inlined, wrapped in the appropriate `@media`, `@supports`, and `@layer` rules.

  ## Options

    * `:minify` — minify the output (default: `false`)
    * `:css_modules` — enable CSS Modules scoping (default: `false`)
    * `:targets` — browser targets for autoprefixing
  """
  @spec bundle(String.t(), keyword()) :: {:ok, css_result()}
  def bundle(entry_path, opts \\ []) do
    minify = Keyword.get(opts, :minify, false)
    css_modules = Keyword.get(opts, :css_modules, false)
    targets = Keyword.get(opts, :targets, %{})
    chrome = Map.get(targets, :chrome, -1)
    firefox = Map.get(targets, :firefox, -1)
    safari = Map.get(targets, :safari, -1)

    Vize.Native.bundle_css_nif(
      Path.expand(entry_path),
      minify,
      chrome,
      firefox,
      safari,
      css_modules
    )
  end

  @doc "Like `bundle/2` but raises on errors."
  @spec bundle!(String.t(), keyword()) :: css_result()
  def bundle!(entry_path, opts \\ []) do
    case bundle(entry_path, opts) do
      {:ok, result} ->
        if result.errors != [] do
          raise "Vize CSS bundle error: #{inspect(result.errors)}"
        end

        result
    end
  end

  @doc """
  Select compact parser events from CSS source by name.

  Available selectors:

    * `:urls` — `url()` references with source byte ranges and source locations

  ## Options

    * `:filename` — filename for parser locations and error reporting
    * `:css_modules` — enable CSS Modules parsing (default: `false`)
    * `:custom_media` — enable custom media parsing (default: `false`)
  """
  @spec select(String.t(), atom(), keyword()) :: {:ok, [map()]} | {:error, Vize.Error.t()}
  def select(source, selector, opts \\ []) when is_atom(selector) do
    filename = Keyword.get(opts, :filename, "")
    css_modules = Keyword.get(opts, :css_modules, false)
    custom_media = Keyword.get(opts, :custom_media, false)

    case Vize.Native.select_css_nif(
           source,
           filename,
           custom_media,
           css_modules,
           selector_spec(selector)
         ) do
      {:ok, events} -> {:ok, events}
      {:error, errors} -> {:error, error("Vize CSS selection error", errors)}
    end
  end

  @doc """
  Collect parser-backed `url()` references from CSS source.

  Returns byte offsets for the URL value inside the original source, suitable for
  source patching without round-tripping through the serialized CSS AST.
  """
  @spec collect_urls(String.t(), keyword()) :: {:ok, [url_ref()]} | {:error, Vize.Error.t()}
  def collect_urls(source, opts \\ []) do
    case select(source, :urls, opts) do
      {:ok, urls} -> {:ok, Enum.map(urls, &Vize.CSS.URL.new/1)}
      {:error, _} = error -> error
    end
  end

  defp selector_spec(:urls) do
    [
      {{:css_url, :"$1", :"$2", :"$3", :"$4", :"$5", :"$6", :"$7"}, [],
       [
         %{
           url: :"$1",
           start: :"$2",
           end: :"$3",
           start_line: :"$4",
           start_column: :"$5",
           end_line: :"$6",
           end_column: :"$7"
         }
       ]}
    ]
  end

  defp selector_spec(selector) do
    raise ArgumentError, "unknown Vize CSS selector #{inspect(selector)}"
  end

  @doc "Like `collect_urls/2` but raises `Vize.Error` on errors."
  @spec collect_urls!(String.t(), keyword()) :: [url_ref()]
  def collect_urls!(source, opts \\ []) do
    case collect_urls(source, opts) do
      {:ok, urls} -> urls
      {:error, error} -> raise error
    end
  end

  @doc """
  Rewrite parser-backed `url()` references in CSS source.

  The callback receives each URL string and returns either `:keep` or
  `{:rewrite, new_url}`. Rewrites are applied to the original source using byte
  ranges reported by the native parser.
  """
  @spec rewrite_urls(String.t(), keyword(), (String.t() -> :keep | {:rewrite, iodata()})) ::
          {:ok, String.t()} | {:error, Vize.Error.t()}
  def rewrite_urls(source, fun) when is_function(fun, 1), do: rewrite_urls(source, [], fun)

  def rewrite_urls(source, opts, fun) when is_function(fun, 1) do
    with {:ok, urls} <- collect_urls(source, opts) do
      patches =
        Enum.reduce(urls, [], fn %Vize.CSS.URL{url: url, range: range}, acc ->
          case fun.(url) do
            {:rewrite, replacement} ->
              [%{start: range.start, end: range.end, change: replacement} | acc]

            :keep ->
              acc
          end
        end)

      {:ok, patch_string(source, patches)}
    end
  end

  @doc "Like `rewrite_urls/3` but raises `Vize.Error` on errors."
  @spec rewrite_urls!(String.t(), keyword(), (String.t() -> :keep | {:rewrite, iodata()})) ::
          String.t()
  def rewrite_urls!(source, fun) when is_function(fun, 1), do: rewrite_urls!(source, [], fun)

  def rewrite_urls!(source, opts, fun) when is_function(fun, 1) do
    case rewrite_urls(source, opts, fun) do
      {:ok, source} -> source
      {:error, error} -> raise error
    end
  end

  @doc """
  Parse CSS into a LightningCSS-backed AST represented as Elixir maps and lists.

  ## Options

    * `:filename` — filename for parser locations and error reporting
    * `:css_modules` — enable CSS Modules parsing (default: `false`)
    * `:custom_media` — enable custom media parsing (default: `false`)

  ## Examples

      iex> {:ok, result} = Vize.CSS.parse_ast(".foo { background: url('./logo.svg') }")
      iex> is_map(result.ast)
      true
      iex> result.errors
      []
  """
  @spec parse_ast(String.t(), keyword()) :: {:ok, ast_result()}
  def parse_ast(source, opts \\ []) do
    filename = Keyword.get(opts, :filename, "")
    css_modules = Keyword.get(opts, :css_modules, false)
    custom_media = Keyword.get(opts, :custom_media, false)

    Vize.Native.parse_css_ast_nif(source, filename, custom_media, css_modules)
  end

  @doc "Like `parse_ast/2` but raises on errors."
  @spec parse_ast!(String.t(), keyword()) :: ast_result()
  def parse_ast!(source, opts \\ []) do
    case parse_ast(source, opts) do
      {:ok, result} ->
        if result.errors != [] do
          raise "Vize CSS parse error: #{inspect(result.errors)}"
        end

        result
    end
  end

  @doc """
  Print CSS from an AST returned by `parse_ast/2`.

  ## Options

    * `:minify` — minify the output (default: `false`)
    * `:targets` — browser targets for autoprefixing, map with optional
      `:chrome`, `:firefox`, `:safari` keys as major version integers

  ## Examples

      iex> {:ok, parsed} = Vize.CSS.parse_ast(".foo { color: red }")
      iex> {:ok, printed} = Vize.CSS.print_ast(parsed.ast)
      iex> printed.code =~ "color"
      true
  """
  @spec print_ast(map(), keyword()) :: {:ok, css_result()}
  def print_ast(ast, opts \\ []) do
    minify = Keyword.get(opts, :minify, false)
    targets = Keyword.get(opts, :targets, %{})
    chrome = Map.get(targets, :chrome, -1)
    firefox = Map.get(targets, :firefox, -1)
    safari = Map.get(targets, :safari, -1)

    Vize.Native.print_css_ast_nif(ast, minify, chrome, firefox, safari)
  end

  @doc "Like `print_ast/2` but raises on errors."
  @spec print_ast!(map(), keyword()) :: css_result()
  def print_ast!(ast, opts \\ []) do
    case print_ast(ast, opts) do
      {:ok, result} ->
        if result.errors != [] do
          raise "Vize CSS print error: #{inspect(result.errors)}"
        end

        result
    end
  end

  @doc """
  Traverse the AST in pre-order and call `fun` for every map node.

  Returns `:ok`. Use `prewalk/2` or `postwalk/2` when you need to transform
  the AST.
  """
  @spec walk(ast(), (map() -> any())) :: :ok
  def walk(value, fun) when is_function(fun, 1) do
    prewalk(value, fn
      node when is_map(node) ->
        fun.(node)
        node

      other ->
        other
    end)

    :ok
  end

  @doc """
  Depth-first pre-order traversal, like `Macro.prewalk/2`.

  The callback receives every map node before its children and must return the
  node to continue traversing.
  """
  @spec prewalk(ast(), (map() -> map())) :: ast()
  def prewalk(value, fun) when is_function(fun, 1) do
    do_prewalk(value, fn node, acc -> {fun.(node), acc} end, nil) |> elem(0)
  end

  @doc """
  Depth-first pre-order traversal with accumulator, like `Macro.prewalk/3`.
  """
  @spec prewalk(ast(), acc, (map(), acc -> {map(), acc})) :: {ast(), acc} when acc: term()
  def prewalk(value, acc, fun) when is_function(fun, 2) do
    do_prewalk(value, fun, acc)
  end

  @doc """
  Depth-first post-order traversal, like `Macro.postwalk/2`.

  The callback receives every map node after its children and must return the
  transformed node.
  """
  @spec postwalk(ast(), (map() -> map())) :: ast()
  def postwalk(value, fun) when is_function(fun, 1) do
    do_postwalk(value, fn node, acc -> {fun.(node), acc} end, nil) |> elem(0)
  end

  @doc """
  Depth-first post-order traversal with accumulator, like `Macro.postwalk/3`.
  """
  @spec postwalk(ast(), acc, (map(), acc -> {map(), acc})) :: {ast(), acc} when acc: term()
  def postwalk(value, acc, fun) when is_function(fun, 2) do
    do_postwalk(value, fun, acc)
  end

  @doc """
  Collect values from map nodes that match `fun`.

  `fun` should return `{:keep, value}` to include a value, or `:skip` to ignore
  the node.
  """
  @spec collect(ast(), (map() -> {:keep, term()} | :skip)) :: [term()]
  def collect(value, fun) when is_function(fun, 1) do
    {_value, collected} =
      postwalk(value, [], fn node, acc ->
        case fun.(node) do
          {:keep, value} -> {node, [value | acc]}
          :skip -> {node, acc}
        end
      end)

    Enum.reverse(collected)
  end

  defp error(message, errors), do: Vize.Error.new(message, errors)

  defp patch_string(source, patches) do
    {chunks, offset} =
      patches
      |> Enum.uniq_by(fn %{start: start, end: end_offset} -> {start, end_offset} end)
      |> Enum.sort_by(fn %{start: start} -> start end)
      |> Enum.reduce({[], 0}, fn %{start: start, end: end_offset, change: replacement},
                                 {chunks, offset} ->
        chunk = binary_part(source, offset, start - offset)
        {[replacement, chunk | chunks], end_offset}
      end)

    tail = binary_part(source, offset, byte_size(source) - offset)
    IO.iodata_to_binary(Enum.reverse([tail | chunks]))
  end

  defp do_prewalk(value, fun, acc) when is_map(value) do
    {value, acc} = fun.(value, acc)

    Enum.reduce(value, {value, acc}, fn {key, child}, {node, acc} ->
      {child, acc} = do_prewalk(child, fun, acc)
      {Map.put(node, key, child), acc}
    end)
  end

  defp do_prewalk(value, fun, acc) when is_list(value) do
    Enum.map_reduce(value, acc, &do_prewalk(&1, fun, &2))
  end

  defp do_prewalk(value, _fun, acc), do: {value, acc}

  defp do_postwalk(value, fun, acc) when is_map(value) do
    {value, acc} =
      Enum.reduce(value, {value, acc}, fn {key, child}, {node, acc} ->
        {child, acc} = do_postwalk(child, fun, acc)
        {Map.put(node, key, child), acc}
      end)

    fun.(value, acc)
  end

  defp do_postwalk(value, fun, acc) when is_list(value) do
    Enum.map_reduce(value, acc, &do_postwalk(&1, fun, &2))
  end

  defp do_postwalk(value, _fun, acc), do: {value, acc}
end