Skip to main content

lib/oxide.ex

defmodule Oxide do
  @moduledoc """
  Elixir bindings for Tailwind CSS Oxide — the Rust-powered content scanner.

  Oxide scans source files in parallel to extract Tailwind CSS candidate
  class names. It powers the fast incremental rebuilds in Tailwind CSS v4.

  ## Usage

  Create a scanner, scan for candidates, then feed them to the Tailwind
  compiler to generate CSS.

      # Create a scanner for your project
      scanner = Oxide.new(sources: [%{base: "lib/", pattern: "**/*.heex"}])

      # Full scan — returns all candidates found across all files
      candidates = Oxide.scan(scanner)
      # ["flex", "sm:bg-red-500", "hover:text-white", ...]

      # Incremental scan — only returns NEW candidates from changed files
      new = Oxide.scan_files(scanner, [%{file: "lib/app_web/live/page.ex", extension: "ex"}])

  For one-off extraction without a scanner:

      candidates = Oxide.extract(~s(class="flex bg-red-500"), "html")
      # [%{value: "class", position: 0}, %{value: "flex", position: 7}, ...]
  """

  defmodule Source do
    @moduledoc "Source entry for scanner configuration."
    @type t :: %__MODULE__{base: String.t(), pattern: String.t(), negated: boolean()}
    defstruct base: "", pattern: "", negated: false
  end

  defmodule Glob do
    @moduledoc "Glob entry returned by the scanner."
    @type t :: %__MODULE__{base: String.t(), pattern: String.t()}
    defstruct base: "", pattern: ""
  end

  defmodule Changed do
    @moduledoc "Changed content for incremental scanning."
    @type t :: %__MODULE__{
            file: String.t() | nil,
            content: String.t() | nil,
            extension: String.t()
          }
    defstruct file: nil, content: nil, extension: ""
  end

  defmodule Candidate do
    @moduledoc "Extracted candidate with byte position."
    @type t :: %__MODULE__{value: String.t(), position: non_neg_integer()}
    defstruct value: "", position: 0
  end

  @doc """
  Create a new scanner.

  ## Options

    * `:sources` — list of source entries, each a map or `%Oxide.Source{}`
      with `:base`, `:pattern`, and optional `:negated` (default `false`)

  ## Examples

      scanner = Oxide.new(sources: [
        %{base: "lib/", pattern: "**/*.{ex,heex}"},
        %{base: "assets/", pattern: "**/*.vue"}
      ])
  """
  @spec new(keyword()) :: reference()
  def new(opts \\ []) do
    sources =
      opts
      |> Keyword.get(:sources, [])
      |> Enum.map(fn
        %Source{} = s -> s
        map -> struct!(Source, Map.put_new(map, :negated, false))
      end)

    Oxide.Native.new_scanner(sources)
  end

  @doc """
  Full scan — walks filesystem, extracts all candidates.

  Returns a sorted, deduplicated list of candidate strings.
  Subsequent calls only return candidates from files changed since the last scan.
  """
  @spec scan(reference()) :: [String.t()]
  def scan(scanner) do
    Oxide.Native.scan(scanner)
  end

  @doc """
  Incremental scan — extract candidates from specific changed files.

  Returns only NEW candidates not seen in previous scans.
  This is what makes HMR fast.

  ## Examples

      new_candidates = Oxide.scan_files(scanner, [
        %{file: "lib/app_web/live/page.ex", extension: "ex"}
      ])

      # Or with inline content (no filesystem read):
      new_candidates = Oxide.scan_files(scanner, [
        %{content: ~s(class="flex mt-4"), extension: "html"}
      ])
  """
  @spec scan_files(reference(), [map()]) :: [String.t()]
  def scan_files(scanner, changed) do
    changed =
      Enum.map(changed, fn
        %Changed{} = c -> c
        map -> struct!(Changed, map)
      end)

    Oxide.Native.scan_files(scanner, changed)
  end

  @doc """
  Extract candidates with byte positions from a string.

  Stateless — does not require a scanner. Useful for one-off extraction.

  ## Examples

      candidates = Oxide.extract(~s(class="flex bg-red-500"), "html")
      [%Oxide.Candidate{value: "class", position: 0}, ...]
  """
  @spec extract(String.t(), String.t()) :: [Candidate.t()]
  def extract(content, extension) do
    Oxide.Native.get_candidates(content, extension)
  end

  @doc """
  Get all files discovered by the scanner.
  """
  @spec files(reference()) :: [String.t()]
  def files(scanner) do
    Oxide.Native.get_files(scanner)
  end

  @doc """
  Get generated glob patterns for setting up file watchers.
  """
  @spec globs(reference()) :: [Glob.t()]
  def globs(scanner) do
    Oxide.Native.get_globs(scanner)
  end
end