defmodule Expug.Tokenizer do
  @moduledoc ~S"""
  Tokenizes a Pug template into a list of tokens. The main entry point is
  `tokenize/1`.

      iex> Expug.Tokenizer.tokenize("title= name")
      [
        {{1, 8}, :buffered_text, "name"},
        {{1, 1}, :element_name, "title"},
        {{1, 1}, :indent, 0}
      ]

  Note that the tokens are reversed! It's easier to append to the top of a list
  rather than to the end, making it more efficient.

  This output is the consumed next by `Expug.Compiler`, which turns them into
  an Abstract Syntax Tree.

  ## Token types

  ```
  div.blue#box
  ```

    - `:indent` - 0
    - `:element_name` - `"div"`
    - `:element_class` - `"blue"`
    - `:element_id` - `"box"`

  ```
  div(name="en")
  ```

    - `:attribute_open` - `"("`
    - `:attribute_key` - `"name"`
    - `:attribute_value` - `"\"en\""`
    - `:attribute_close` - `")"`

  ```
  div= hello
  ```

    - `:buffered_text` - `hello`

  ```
  div!= hello
  ```

    - `:unescaped_text` - `hello`

  ```
  div hello
  ```

    - `:raw_text` - `"hello"`

  ```
  | Hello there
  ```

    - `:raw_text` - `"Hello there"`

  ```
  = Hello there
  ```

    - `:buffered_text` - `"Hello there"`

  ```
  - foo = bar
  ```

    - `:statement` - `foo = bar`

  ```
  doctype html5
  ```

    - `:doctype` - `html5`

  ```
  -# comment
    more comments
  ```

    - `:line_comment` - `comment`
    - `:subindent` - `more comments`

  ```
  // comment
    more comments
  ```

    - `:html_comment` - `comment`
    - `:subindent` - `more comments`

  ## Also see
  - `Expug.TokenizerTools` has the functions used by this tokenizer.
  - `Expug.Compiler` uses the output of this tokenizer to build an AST.
  - `Expug.ExpressionTokenizer` is used to tokenize expressions.
  """

  import Expug.TokenizerTools
  alias Expug.TokenizerTools.State

  @doc """
  Tokenizes a string.
  Returns a list of tokens. Each token is in the format `{position, token, value}`.
  """
  def tokenize(source, opts \\ []) do
    source = trim_trailing(source)
    run(source, opts, &document/1)
  end

  @doc """
  Matches an entire document.
  """
  def document(state) do
    state
    |> optional(&newlines/1)
    |> optional(&doctype/1)
    |> many_of(
      &(&1 |> element_or_text() |> newlines()),
      &(&1 |> element_or_text()))
  end

  @doc """
  Matches `doctype html`.
  """
  def doctype(state) do
    state
    |> discard(~r/^doctype/, :doctype_prelude)
    |> whitespace()
    |> eat(~r/^[^\n]+/, :doctype)
    |> optional(&newlines/1)
  end

  @doc """
  Matches an HTML element, text node, or, you know... the basic statements.
  I don't know what to call this.
  """
  def element_or_text(state) do
    state
    |> indent()
    |> one_of([
      &line_comment/1,   # `-# hello`
      &html_comment/1,   # `// hello`
      &buffered_text/1,  # `= hello`
      &unescaped_text/1, # `!= hello`
      &raw_text/1,       # `| hello`
      &statement/1,      # `- hello`
      &element/1         # `div.blue hello`
    ])
  end

  @doc """
  Matches any number of blank newlines. Whitespaces are accounted for.
  """
  def newlines(state) do
    state
    |> discard(~r/^\n(?:[ \t]*\n)*/, :newlines)
  end

  @doc """
  Matches an indentation. Gives a token that looks like `{_, :indent, 2}`
  where the last number is the number of spaces/tabs.

  Doesn't really care if you use spaces or tabs; a tab is treated like a single
  space.
  """
  def indent(state) do
    state
    |> eat(~r/^\s*/, :indent, &[{&3, :indent, String.length(&2)} | &1])
  end

  @doc """
  Matches `div.foo[id="name"]= Hello world`
  """
  def element(state) do
    state
    |> element_descriptor()
    |> optional(&attributes_block/1)
    |> optional(fn s -> s
      |> one_of([
        &sole_buffered_text/1,
        &sole_unescaped_text/1,
        &sole_raw_text/1,
        &block_text/1
      ])
    end)
  end

  @doc """
  Matches `div`, `div.foo` `div.foo.bar#baz`, etc
  """
  def element_descriptor(state) do
    state
    |> one_of([
      &element_descriptor_full/1,
      &element_name/1,
      &element_class_or_id_list/1
    ])
  end

  @doc """
  Matches `div.foo.bar#baz`
  """
  def element_descriptor_full(state) do
    state
    |> element_name()
    |> element_class_or_id_list()
  end

  @doc """
  Matches `.foo.bar#baz`
  """
  def element_class_or_id_list(state) do
    state
    |> many_of(&element_class_or_id/1)
  end

  @doc """
  Matches `.foo` or `#id` (just one)
  """
  def element_class_or_id(state) do
    state
    |> one_of([ &element_class/1, &element_id/1 ])
  end

  @doc """
  Matches `.foo`
  """
  def element_class(state) do
    state
    |> discard(~r/^\./, :dot)
    |> eat(~r/^[A-Za-z0-9_\-]+/, :element_class)
  end

  @doc """
  Matches `#id`
  """
  def element_id(state) do
    state
    |> discard(~r/^#/, :hash)
    |> eat(~r/^[A-Za-z0-9_\-]+/, :element_id)
  end

  @doc """
  Matches `[name='foo' ...]`
  """
  def attributes_block(state) do
    state
    |> optional_whitespace()
    |> one_of([
      &attribute_bracket/1,
      &attribute_paren/1,
      &attribute_brace/1
    ])
  end

  def attribute_bracket(state) do
    state
    |> eat(~r/^\[/, :attribute_open)
    |> optional_whitespace()
    |> optional(&attribute_list/1)
    |> eat(~r/^\]/, :attribute_close)
  end

  def attribute_paren(state) do
    state
    |> eat(~r/^\(/, :attribute_open)
    |> optional_whitespace()
    |> optional(&attribute_list/1)
    |> eat(~r/^\)/, :attribute_close)
  end

  def attribute_brace(state) do
    state
    |> eat(~r/^\{/, :attribute_open)
    |> optional_whitespace()
    |> optional(&attribute_list/1)
    |> eat(~r/^\}/, :attribute_close)
  end

  @doc """
  Matches `foo='val' bar='val'`
  """
  def attribute_list(state) do
    state
    |> optional_whitespace_or_newline()
    |> many_of(
      &(&1 |> attribute() |> attribute_separator() |> whitespace_or_newline()),
      &(&1 |> attribute()))
    |> optional_whitespace_or_newline()
  end

  @doc """
  Matches an optional comma in between attributes.

      div(id=a class=b)
      div(id=a, class=b)
  """
  def attribute_separator(state) do
    state
    |> discard(~r/^,?/, :comma)
  end

  @doc """
  Matches `foo='val'` or `foo`
  """
  def attribute(state) do
    state
    |> one_of([
      &attribute_key_value/1,
      &attribute_key/1
    ])
  end

  def attribute_key_value(state) do
    state
    |> attribute_key()
    |> optional_whitespace()
    |> attribute_equal()
    |> optional_whitespace()
    |> attribute_value()
  end

  def attribute_key(state) do
    state
    |> eat(~r/^[A-Za-z][A-Za-z\-0-9:]*/, :attribute_key)
  end

  def attribute_value(state) do
    state
    |> Expug.ExpressionTokenizer.expression(:attribute_value)
  end

  def attribute_equal(state) do
    state
    |> discard(~r/^=/, :eq)
  end

  @doc "Matches whitespace; no tokens emitted"
  def whitespace(state) do
    state
    |> discard(~r/^[ \t]+/, :whitespace)
  end

  @doc "Matches whitespace or newline; no tokens emitted"
  def whitespace_or_newline(state) do
    state
    |> discard(~r/^[ \t\n]+/, :whitespace_or_newline)
  end

  def optional_whitespace(state) do
    state
    |> discard(~r/^[ \t]*/, :whitespace)
  end

  def optional_whitespace_or_newline(state) do
    state
    |> discard(~r/^[ \t\n]*/, :whitespace_or_newline)
  end

  @doc "Matches `=`"
  def sole_buffered_text(state) do
    state
    |> optional_whitespace()
    |> buffered_text()
  end

  @doc "Matches `!=`"
  def sole_unescaped_text(state) do
    state
    |> optional_whitespace()
    |> unescaped_text()
  end

  @doc "Matches text"
  def sole_raw_text(state) do
    state
    |> whitespace()
    |> eat(~r/^[^\n]+/, :raw_text)
  end

  @doc "Matches `title` in `title= hello`"
  def element_name(state) do
    state
    |> eat(~r/^[A-Za-z_][A-Za-z0-9:_\-]*/, :element_name)
  end

  def line_comment(state) do
    state
    |> one_of([
      &(&1 |> discard(~r/^\/\/-/, :line_comment)),
      &(&1 |> discard(~r/^-\s*(?:#|\/\/)/, :line_comment))
    ])
    |> optional_whitespace()
    |> eat(~r/^[^\n]*/, :line_comment)
    |> optional(&subindent_block/1)
  end

  def block_text(state) do
    state
    |> eat(~r/^\./, :block_text)
    |> subindent_block()
  end

  def subindent_block(state) do
    sublevel = state |> get_next_indent()
    state
    |> many_of(& &1 |> newlines() |> subindent(sublevel))
  end

  def subindent(state, level) do
    state
    |> discard(~r/^[ \t]{#{level}}/, :whitespace)
    |> eat(~r/^[^\n]*/, :subindent)
  end

  def get_indent([{_, :indent, text} | _]) do
    text
  end

  def get_indent([_ | rest]) do
    get_indent(rest)
  end

  def get_indent([]) do
    ""
  end

  def html_comment(state) do
    state
    |> discard(~r[^//], :html_comment)
    |> optional_whitespace()
    |> eat(~r/^[^\n$]*/, :html_comment)
    |> optional(&subindent_block/1)
  end

  def buffered_text(state) do
    state
    |> one_of([
      &one_line_buffered_text/1,
      &multiline_buffered_text/1
    ])
  end

  def one_line_buffered_text(state) do
    state
    |> discard(~r/^=/, :eq)
    |> optional_whitespace()
    |> eat(~r/^(?:[,\[\(\{]\s*\n|[^\n$])+/, :buffered_text)
  end

  def multiline_buffered_text(state) do
    state
    |> discard(~r/^=/, :eq)
    |> start_empty(:buffered_text)
    |> subindent_block()
  end

  def unescaped_text(state) do
    state
    |> one_of([
      &one_line_unescaped_text/1,
      &multiline_unescaped_text/1
    ])
  end

  def one_line_unescaped_text(state) do
    state
    |> discard(~r/^!=/, :bang_eq)
    |> optional_whitespace()
    |> eat(~r/^(?:[,\[\(\{]\s*\n|[^\n$])+/, :unescaped_text)
  end

  def multiline_unescaped_text(state) do
    state
    |> discard(~r/^!=/, :bang_eq)
    |> start_empty(:unescaped_text)
    |> subindent_block()
  end

  def raw_text(state) do
    state
    |> discard(~r/^\|/, :pipe)
    |> optional_whitespace()
    |> eat(~r/^[^\n]+/, :raw_text)
  end

  def statement(state) do
    state
    |> one_of([
      &one_line_statement/1,
      &multiline_statement/1
    ])
  end

  def one_line_statement(state) do
    state
    |> discard(~r/^\-/, :dash)
    |> optional_whitespace()
    |> eat(~r/^(?:[,\[\(\{]\s*\n|[^\n$])+/, :statement)
  end

  def multiline_statement(state) do
    state
    |> discard(~r/^\-/, :dash)
    |> start_empty(:statement)
    |> subindent_block()
  end

  @doc ~S"""
  Returns the next indentation level after some newlines.
  Infers the last indentation level based on `doc`.

      iex> source = "-#\n  span"
      iex> doc = [{0, :indent, 0}]
      iex> Expug.Tokenizer.get_next_indent(%{tokens: doc, source: source, position: 2}, 0)
      2
  """
  def get_next_indent(%State{tokens: doc} = state) do
    level = get_indent(doc)
    get_next_indent(state, level)
  end

  @doc ~S"""
  Returns the next indentation level after some newlines.

      iex> source = "-#\n  span"
      iex> Expug.Tokenizer.get_next_indent(%{tokens: [], source: source, position: 2}, 0)
      2

      iex> source = "-#\n\n\n  span"
      iex> Expug.Tokenizer.get_next_indent(%{tokens: [], source: source, position: 2}, 0)
      2
  """
  def get_next_indent(state, level) do
    %{tokens: [{_, :indent, sublevel} |_], position: pos} =
      state |> newlines() |> indent()
    if sublevel <= level, do: throw {:parse_error, pos, [:indent]}
    sublevel
  end

  # Shim for String.trim_trailing/1, which doesn't exist in Elixir 1.2.6. It
  # falls back to String.rstrip/1 in these cases.
  if Keyword.has_key?(String.__info__(:functions), :trim_trailing) do
    defp trim_trailing(source) do
      String.trim_trailing(source)
    end
  else
    defp trim_trailing(source) do
      String.rstrip(source)
    end
  end
end
