lib/gcode/model/block.ex

defmodule Gcode.Model.Block do
  use Gcode.Option
  use Gcode.Result
  defstruct words: [], comment: none()
  import Gcode.Model.Expr.Helpers
  alias Gcode.Model.{Block, Comment, Expr, Skip, Word}

  defguardp is_pushable(value)
            when is_struct(value, Block) or is_struct(value, Comment) or is_struct(value, Skip) or
                   is_struct(value, Word) or is_expression(value)

  @moduledoc """
  A sequence of G-code words on a single line.
  """

  @type t :: %Block{words: [block_contents], comment: Option.t(Comment)}
  @typedoc "Any error results in this module will return this type"
  @type block_error :: {:block_error, String.t()}
  @type block_contents :: Word.t() | Skip.t() | Expr.t()

  @doc """
  Initialise a new empty G-code program.

  ## Example

      iex> Block.init()
      {:ok, %Block{words: [], comment: none()}}
  """
  @spec init :: Result.t(t)
  def init, do: ok(%Block{words: [], comment: none()})

  @doc """
  Set a comment on the block (this is just a sugar to make sure that the comment
  is rendered on the same line as the block).

  *Note:* Once a block has a comment set, it cannot be overwritten.

  ## Examples

      iex> {:ok, comment} = Comment.init("Jen, in the swing seat, with her night terrors")
      ...> {:ok, block} = Block.init()
      ...> {:ok, block} = Block.comment(block, comment)
      ...> Result.ok?(block.comment)
      true
  """
  @spec comment(t, Comment.t()) :: Result.t(t, block_error)
  def comment(%Block{} = block, comment),
    do: ok(%Block{block | comment: some(comment)})

  @doc """
  Pushes a `Word` onto the word list.

  *Note:* `Block` stores the words in reverse order because of Erlang list
  semantics, you should pretty much always use `words/1` to retrieve them in the
  correct order.

  ## Example

      iex> {:ok, block} = Block.init()
      ...> {:ok, word} = Word.init("G", 0)
      ...> {:ok, block} = Block.push(block, word)
      ...> {:ok, word} = Word.init("N", 100)
      ...> Block.push(block, word)
      {:ok, %Block{words: [%Word{word: "N", address: %Integer{i: 100}}, %Word{word: "G", address: %Integer{i: 0}}]}}
  """
  @spec push(t, block_contents) :: Result.t(t, block_error)
  def push(%Block{words: words} = block, pushable)
      when is_pushable(pushable) and is_list(words),
      do: ok(%Block{block | words: [pushable | words]})

  def push(%Block{words: words}, pushable) when is_pushable(pushable),
    do:
      error(
        {:block_error,
         "Expected block to contain a list of words, but it contains #{inspect(words)}"}
      )

  def push(%Block{words: words}, pushable) when is_list(words),
    do:
      error(
        {:block_error,
         "Expected element to be pushable, but it is not. Received #{inspect(pushable)}"}
      )

  @doc """
  An accessor which returns the block's words in the correct order.

      iex> {:ok, block} = Block.init()
      ...> {:ok, word} = Word.init("G", 0)
      ...> {:ok, block} = Block.push(block, word)
      ...> {:ok, word} = Word.init("N", 100)
      ...> {:ok, block} = Block.push(block, word)
      ...> Block.words(block)
      {:ok, [%Word{word: "G", address: %Integer{i: 0}}, %Word{word: "N", address: %Integer{i: 100}}]}
  """
  @spec words(t) :: Result.t([Word.t()], block_error)
  def words(%Block{words: words}) when is_list(words), do: ok(Enum.reverse(words))

  def words(%Block{words: words}),
    do:
      error(
        {:block_error,
         "Expected block to contain a list of words, but it contains #{inspect(words)}"}
      )
end