lib/gcode/parser.ex

defmodule Gcode.Parser do
  use Gcode.Result
  alias Gcode.Model.{Block, Comment, Expr, Program, Word}
  alias Gcode.Parser.{Engine, Error}

  @moduledoc """
  A parser for G-code programs.

  This parser converts G-code input (in UTF-8 encoding) into representations
  with the contents of `Gcode.Model`.
  """

  @doc """
  Attempt to parse a G-code program from a string.
  """
  @spec parse_string(String.t()) :: Result.t(Program.t(), {:parse_error, String.t()})
  def parse_string(input) do
    with ok(tokens) <- Engine.parse(input),
         ok(program) <- hydrate(tokens) do
      ok(program)
    else
      error(reason) ->
        error({:parse_error, reason})

      error({message, unexpected, _, {line, _}, col}) ->
        error(
          {:parse_error,
           "Unexpected #{inspect(unexpected)} at line: #{line}:#{col + 1}. #{message}."}
        )
    end
  end

  @doc """
  Attempt to parse the G-code program at the given path.
  """
  @spec parse_file(Path.t()) :: Result.t(Program.t(), {:parse_error, String.t()})
  def parse_file(path) do
    with ok(input) <- File.read(path),
         ok(tokens) <- Engine.parse(input),
         ok(program) <- hydrate(tokens) do
      ok(program)
    else
      error(reason) ->
        error({:parse_error, reason})

      error({message, unexpected, _, {line, _}, col}) ->
        error(
          {:parse_error,
           "Unexpected #{inspect(unexpected)} at line: #{line}:#{col + 1}. #{message}."}
        )
    end
  end

  @doc """
  Parse and stream the G-code program from a string.

  Note that this function doesn't yield `Program` objects, but blocks, comments,
  etc.
  """
  @spec stream_string!(String.t()) :: Enumerable.t() | no_return
  def stream_string!(input) do
    input
    |> String.split(~r/\r?\n/)
    |> Stream.with_index()
    |> ParallelStream.map(&trim_line/1)
    |> ParallelStream.reject(&(elem(&1, 0) == ""))
    |> ParallelStream.map(&parse_line!/1)
  end

  @doc """
  Parse and stream the G-code program at the given location.

  Note that this function doesn't yield `Program` objects, but blocks, comments,
  etc.
  """
  @spec stream_file!(Path.t()) :: Enumerable.t() | no_return
  def stream_file!(path) do
    path
    |> File.stream!()
    |> Stream.with_index()
    |> ParallelStream.map(&trim_line/1)
    |> ParallelStream.reject(&(elem(&1, 0) == ""))
    |> ParallelStream.map(&parse_line!/1)
  end

  defp trim_line({input, line_no}), do: {String.trim(input), line_no}

  defp parse_line!({input, line_no}) do
    with ok(tokens) <- Engine.parse(input),
         ok(%Program{elements: [element]}) <- hydrate(tokens) do
      element
    else
      ok(%Program{elements: elements}) ->
        raise Error, "Expected line to result in 1 element, but contained #{length(elements)}"

      ok(%Block{}) ->
        raise Error, "Expected parser to return a program, but returned a block instead."

      error({:block_error, reason}) ->
        raise Error, "Block error #{reason}"

      error({:comment_error, reason}) ->
        raise Error, "Comment error #{reason}"

      error({:expression_error, reason}) ->
        raise Error, "Expression error #{reason}"

      error({:parse_error, reason}) ->
        raise Error, "Parse error: #{reason}"

      error({:program_error, reason}) ->
        raise Error, "Program error: #{reason}"

      error({:string_error, reason}) ->
        raise Error, "String error: #{reason}"

      error({:word_error, reason}) ->
        raise Error, "Word error: #{reason}"

      error({message, unexpected, _, _, col}) ->
        raise Error,
              "Unexpected #{inspect(unexpected)} at line: #{line_no + 1}:#{col + 1}. #{message}."
    end
  end

  defp hydrate(tokens) do
    with ok(program) <- Program.init(),
         do: hydrate(tokens, program)
  end

  defp hydrate([], result), do: ok(result)

  defp hydrate([{:comment, comment} | remaining], %Program{} = program) do
    with ok(comment) <- Comment.init(List.to_string(comment)),
         ok(program) <- Program.push(program, comment),
         do: hydrate(remaining, program)
  end

  defp hydrate([{:comment, comment} | remaining], %Block{} = block) do
    with ok(comment) <- Comment.init(List.to_string(comment)),
         ok(block) <- Block.comment(block, comment),
         do: hydrate(remaining, block)
  end

  defp hydrate([{:block, words} | remaining], %Program{} = program) do
    with ok(block) <- Block.init(),
         ok(block) <- hydrate(words, block),
         ok(program) <- Program.push(program, block),
         do: hydrate(remaining, program)
  end

  defp hydrate([{:word, contents} | remaining], %Block{} = block) do
    with ok(command) <- Keyword.fetch(contents, :command),
         ok(address) <- Keyword.fetch(contents, :address),
         ok(address) <- expression(address),
         ok(word) <- Word.init(List.to_string(command), address),
         ok(block) <- Block.push(block, word),
         do: hydrate(remaining, block)
  end

  defp hydrate([{:string, _} = str | remaining], %Block{} = block) do
    with ok(str) <- expression([str]),
         ok(block) <- Block.push(block, str),
         do: hydrate(remaining, block)
  end

  defp hydrate([{:newline, _} | remaining], %Program{} = program), do: hydrate(remaining, program)
  defp hydrate([{:newline, _}], %Block{} = block), do: ok(block)

  defp expression([{:-, inner}]) do
    with ok(inner) <- expression(inner),
         do: Expr.Unary.init(:-, inner)
  end

  defp expression([{:+, inner}]) do
    with ok(inner) <- expression(inner),
         do: Expr.Unary.init(:+, inner)
  end

  defp expression([{:!, inner}]) do
    with ok(inner) <- expression(inner),
         do: Expr.Unary.init(:!, inner)
  end

  defp expression([{:"#", inner}]) do
    with ok(inner) <- expression(inner),
         do: Expr.Unary.init(:"#", inner)
  end

  defp expression(integer: value) do
    value =
      value
      |> List.to_string()
      |> String.to_integer()

    Expr.Integer.init(value)
  end

  defp expression(float: value) do
    value =
      value
      |> List.to_string()
      |> String.to_float()

    Expr.Float.init(value)
  end

  defp expression(string: value) do
    value =
      value
      |> List.to_string()

    Expr.String.init(value)
  end
end