lib/liquex/parser/literal.ex

defmodule Liquex.Parser.Literal do
  @moduledoc """
  Helper parsers for parsing literal values in Liquid
  """

  import NimbleParsec

  alias Liquex.Parser.Argument

  @doc """
  Parses white space, given a minimum.

  ## Examples

      * "  "
      * "\r\n\t"
  """
  @spec whitespace(NimbleParsec.t(), non_neg_integer()) :: NimbleParsec.t()
  def whitespace(combinator \\ empty(), min \\ 0) do
    combinator
    |> utf8_string([?\s, ?\n, ?\r, ?\t], min: min)
  end

  @doc """
  Parses not line breaking white space, given a minimum.

  ## Examples

      * "  "
      * "\t"
  """
  @spec non_breaking_whitespace(NimbleParsec.t(), non_neg_integer()) :: NimbleParsec.t()
  def non_breaking_whitespace(combinator \\ empty(), min \\ 0) do
    combinator
    |> utf8_string([?\s, ?\t], min: min)
  end

  @doc """
  Parses a range

  ## Examples

      * "(1..5)"
      * "(1..num)"
  """
  @spec range(NimbleParsec.t()) :: NimbleParsec.t()
  def range(combinator \\ empty()) do
    combinator
    |> ignore(string("("))
    |> ignore(whitespace())
    |> tag(Argument.argument(), :begin)
    |> ignore(string(".."))
    |> tag(Argument.argument(), :end)
    |> ignore(whitespace())
    |> ignore(string(")"))
    |> tag(:inclusive_range)
  end

  @doc """
  Parses a literal

  ## Examples

      * "true"
      * "false"
      * "nil"
      * "3.14"
      * "3"
      * "'Hello World!'"
      * "\"Hello World!\""
  """
  @spec literal(NimbleParsec.t()) :: NimbleParsec.t()
  def literal(combinator \\ empty()) do
    true_value = replace(string("true"), true)
    false_value = replace(string("false"), false)

    nil_value = replace(string("nil"), nil)

    combinator
    |> choice([
      true_value,
      false_value,
      nil_value,
      float(),
      int(),
      quoted_string()
    ])
    |> unwrap_and_tag(:literal)
  end

  @doc """
  Parses everything outside of the Liquid tags. We call this text but it's any
  unstructed data not specifically parsed by Liquex.
  """
  @spec text(NimbleParsec.t()) :: NimbleParsec.t()
  def text(combinator \\ empty()) do
    opening_tag =
      choice([
        whitespace(empty(), 1) |> string("{{-"),
        whitespace(empty(), 1) |> string("{%-"),
        string("{{"),
        string("{%")
      ])

    combinator
    |> lookahead_not(opening_tag)
    |> utf8_char([])
    |> times(min: 1)
    |> reduce({Kernel, :to_string, []})
    |> unwrap_and_tag(:text)
  end

  @doc """
  Parses a single or double quoted string.

  Strings may have escaped quotes within them.

  ## Examples

  Examples here include the quotes as given, as opposed to other examples.

      * "Hello World"
      * 'Hello World'
      * "Hello \"World\""
      * 'Hello "World"'
      * 'Hello \'World\''
  """
  def quoted_string do
    single_quote_string =
      ignore(utf8_char([?']))
      |> repeat(
        lookahead_not(ascii_char([?']))
        |> choice([string(~s{\'}), utf8_char([])])
      )
      |> ignore(utf8_char([?']))
      |> reduce({List, :to_string, []})

    double_quote_string =
      ignore(utf8_char([?"]))
      |> repeat(
        lookahead_not(ascii_char([?"]))
        |> choice([string(~s{\"}), utf8_char([])])
      )
      |> ignore(utf8_char([?"]))
      |> reduce({List, :to_string, []})

    choice([single_quote_string, double_quote_string])
  end

  defp int do
    optional(string("-"))
    |> concat(integer(min: 1))
    |> reduce({Enum, :join, []})
    |> map({String, :to_integer, []})
  end

  defp float do
    exponent =
      utf8_string([?e, ?E], 1)
      |> optional(utf8_string([?+, ?-], 1))
      |> integer(min: 1)

    optional(string("-"))
    |> concat(integer(min: 1))
    |> string(".")
    |> concat(integer(min: 1))
    |> optional(exponent)
    |> reduce({Enum, :join, []})
    |> map({String, :to_float, []})
  end
end