Skip to main content

lib/sv_port_sim.ex

defmodule SvPortSim do
  @moduledoc """
  A `SvPortSim` process owns exactly one simulator transport. The process
  implementation lives in `SvPortSim.Server`; this module is the public facade
  that keeps the stable user-facing API, validates command options, normalizes
  command bodies, and delegates normalized runtime operations to the server.

  In production the default transport opens one external wrapper process with
  the port framing documented by `SvPortSim.Protocol`; tests and alternative
  runtimes may provide their own `SvPortSim.Transport` implementation.

  ## Public contract

  The initial stable public API surface is returned by `public_functions/0`:

    * `start_link/1` and `start/1`
    * `child_spec/1`
    * `reset/2`
    * `tick/2`
    * `poke/4`
    * `peek/3`
    * `stop/2`

  Default-argument arities such as `reset/1`, `tick/1`, `poke/3`, `peek/2`,
  and `stop/1` are exported for convenience.

  Runtime commands return `{:ok, body}` for a successful wrapper response or
  `{:error, error_body}` for wrapper-side and Elixir-side failures. Error
  bodies follow the canonical shape documented by `SvPortSim.Protocol` and
  include string keys such as `"code"`, `"message"`, `"details"`, and
  `"fatal"`. `stop/2` is terminal and returns `:ok` after a successful
  shutdown.

  ## Options

  `start_link/1` and `start/1` accept these simulation options:

     * `:executable` - path to the generated simulator wrapper executable.
      This is required when using the default `SvPortSim.Transport.Port`
      transport.
   * `:args` - command-line arguments passed to the wrapper executable.
      Defaults to `[]`.
    * `:transport` - module implementing `SvPortSim.Transport`. Defaults to
      `SvPortSim.Transport.Port`.
    * `:transport_opts` - extra keyword options passed to the transport.
      Defaults to `[]`.
    * `:default_timeout` - runtime command timeout in milliseconds or
      `:infinity`. Defaults to `5_000`.

  Standard `GenServer` start options `:name`, `:debug`, `:spawn_opt`,
  `:hibernate_after`, and `:timeout` are also accepted.

  Runtime calls accept `:timeout` to override the instance default. `reset/2`
  accepts `:cycles` and `:reset`; `tick/2` accepts `:cycles` and `:clock`.
  `poke/4` accepts encoded bit-vector values in the shape `%{bits: bits,
  width: width}` or `%{"bits" => bits, "width" => width}`. `bits` must be a
  string of `0`, `1`, `x`, or `z` characters whose length equals `width`.

  ## Examples

      iex> defmodule SvPortSim.DocTransport do
      ...>   @behaviour Elixir.SvPortSim.Transport
      ...>
      ...>   def open(_opts), do: {:ok, %{signals: %{}}}
      ...>
      ...>   def request(%{"op" => "reset"} = request, state, _timeout) do
      ...>     body = %{"cycles" => request["body"]["cycles"]}
      ...>     {:ok, response(request, body), %{state | signals: %{}}}
      ...>   end
      ...>
      ...>   def request(%{"op" => "tick"} = request, state, _timeout) do
      ...>     body = %{"cycles" => request["body"]["cycles"]}
      ...>     {:ok, response(request, body), state}
      ...>   end
      ...>
      ...>   def request(%{"op" => "poke"} = request, state, _timeout) do
      ...>     signal = request["body"]["signal"]
      ...>     value = request["body"]["value"]
      ...>     state = %{state | signals: Map.put(state.signals, signal, value)}
      ...>     {:ok, response(request, %{"signal" => signal}), state}
      ...>   end
      ...>
      ...>   def request(%{"op" => "peek"} = request, state, _timeout) do
      ...>     signal = request["body"]["signal"]
      ...>     value = Map.get(state.signals, signal, %{"bits" => "0", "width" => 1})
      ...>     {:ok, response(request, %{"value" => value}), state}
      ...>   end
      ...>
      ...>   def request(%{"op" => "shutdown"} = request, state, _timeout) do
      ...>     {:ok, response(request, %{}), state}
      ...>   end
      ...>
      ...>   def close(_state), do: :ok
      ...>
      ...>   defp response(request, body) do
      ...>     %{
      ...>       "v" => 1,
      ...>       "id" => request["id"],
      ...>       "kind" => "response",
      ...>       "op" => request["op"],
      ...>       "body" => body
      ...>     }
      ...>   end
      ...> end
      iex> {:ok, sim} = Elixir.SvPortSim.start_link(transport: SvPortSim.DocTransport)
      iex> Elixir.SvPortSim.reset(sim, cycles: 2)
      {:ok, %{"cycles" => 2}}
      iex> Elixir.SvPortSim.tick(sim)
      {:ok, %{"cycles" => 1}}
      iex> Elixir.SvPortSim.poke(sim, "enable", %{bits: "1", width: 1})
      {:ok, %{"signal" => "enable"}}
      iex> Elixir.SvPortSim.peek(sim, "enable")
      {:ok, %{"value" => %{"bits" => "1", "width" => 1}}}
      iex> Elixir.SvPortSim.stop(sim)
      :ok
      iex> Elixir.SvPortSim.start_link([])
      {:error, {:missing_required_option, :executable}}
      iex> {:reset, 2} in Elixir.SvPortSim.public_functions()
      true
  """

  alias SvPortSim.Protocol
  alias SvPortSim.Server

  @public_functions [
    {:child_spec, 1},
    {:start_link, 1},
    {:start, 1},
    {:reset, 1},
    {:reset, 2},
    {:tick, 1},
    {:tick, 2},
    {:poke, 3},
    {:poke, 4},
    {:peek, 2},
    {:peek, 3},
    {:stop, 1},
    {:stop, 2},
    {:public_functions, 0}
  ]

  @typedoc "A running simulation instance pid, registered name, or via tuple."
  @type instance :: GenServer.server()

  @typedoc "A top-level SystemVerilog signal name exposed by the wrapper."
  @type signal :: atom() | String.t()

  @typedoc "Encoded runtime bit-vector value accepted by `poke/4`."
  @type encoded_value :: %{
          optional(:bits) => String.t(),
          optional(:width) => pos_integer(),
          optional(String.t()) => String.t() | pos_integer()
        }

  @typedoc "Command response body returned by the wrapper."
  @type response_body :: %{required(String.t()) => term()}

  @typedoc "Canonical runtime error body documented by `SvPortSim.Protocol`."
  @type error_body :: %{required(String.t()) => term()}

  @typedoc "Public API timeout in milliseconds or `:infinity`."
  @type timeout_ms :: pos_integer() | :infinity

  @typedoc "Public API result returned by runtime commands except `stop/2`."
  @type api_result :: {:ok, response_body()} | {:error, error_body()}

  @typedoc "Options accepted by `start_link/1`, `start/1`, and `child_spec/1`."
  @type start_option ::
          {:executable, Path.t()}
          | {:args, [String.t()]}
          | {:transport, module()}
          | {:transport_opts, keyword()}
          | {:default_timeout, timeout()}
          | {:name, GenServer.name()}
          | {:debug, term()}
          | {:spawn_opt, [term()]}
          | {:hibernate_after, non_neg_integer()}
          | {:timeout, timeout_ms()}
          | {:id, term()}

  @typedoc "Per-command options accepted by `poke/4`, `peek/3`, and `stop/2`."
  @type command_option :: {:timeout, timeout_ms()}

  @typedoc "Options accepted by `reset/2`."
  @type reset_option ::
          command_option()
          | {:cycles, pos_integer()}
          | {:reset, signal()}
          | {:clock, signal()}

  @typedoc "Options accepted by `tick/2`."
  @type tick_option :: command_option() | {:cycles, pos_integer()} | {:clock, signal()}

  @doc """
  Returns the stable public API surface for one simulation instance.

  Internal server functions are intentionally not included in this list.

  ## Examples

      iex> SvPortSim.public_functions() |> Enum.member?({:start_link, 1})
      true
      iex> SvPortSim.public_functions() |> Enum.member?({:poke, 4})
      true
  """
  @spec public_functions() :: [{atom(), non_neg_integer()}]
  def public_functions(), do: @public_functions

  @doc """
  Starts one simulation instance and links it to the caller.
  """
  @spec start_link([start_option()]) :: GenServer.on_start()
  def start_link(opts) when is_list(opts), do: Server.start_link(opts)
  def start_link(opts), do: {:error, {:invalid_options, opts}}

  @doc """
  Starts one simulation instance without linking it to the caller.
  """
  @spec start([start_option()]) :: GenServer.on_start()
  def start(opts) when is_list(opts), do: Server.start(opts)
  def start(opts), do: {:error, {:invalid_options, opts}}

  @doc """
  Returns a child spec for supervising one simulation instance.

  Pass `:id` to override the child id. Other options are passed to
  `start_link/1`.
  """
  @spec child_spec([start_option()]) :: Supervisor.child_spec()
  def child_spec(opts) when is_list(opts) do
    if Keyword.keyword?(opts) do
      id = Keyword.get(opts, :id, __MODULE__)
      start_opts = Keyword.delete(opts, :id)

      %{
        id: id,
        start: {__MODULE__, :start_link, [start_opts]},
        type: :worker,
        restart: :permanent,
        shutdown: 5_000
      }
    else
      raise ArgumentError, "child_spec/1 expects a keyword list"
    end
  end

  @doc """
  Resets the simulator.

  Options:

    * `:cycles` - number of reset cycles. Defaults to `1`.
    * `:reset` - reset signal name. Defaults to wrapper policy.
    * `:clock` - clock signal name. Defaults to wrapper policy.
    * `:timeout` - per-request timeout.
  """
  @spec reset(instance(), [reset_option()]) :: api_result()
  def reset(instance, opts \\ []) do
    with {:ok, body} <- build_reset_body(opts) do
      request(instance, "reset", body, opts)
    end
  end

  @doc """
  Advances the simulator by one or more clock cycles.

  Options:

    * `:cycles` - number of cycles. Defaults to `1`.
    * `:clock` - clock signal name. Defaults to wrapper policy.
    * `:timeout` - per-request timeout.
  """
  @spec tick(instance(), [tick_option()]) :: api_result()
  def tick(instance, opts \\ []) do
    with {:ok, body} <- build_tick_body(opts) do
      request(instance, "tick", body, opts)
    end
  end

  @doc """
  Writes an encoded value to a signal.

  `encoded_value` must be `%{bits: bits, width: width}` or
  `%{"bits" => bits, "width" => width}`. The value is normalized to
  string-keyed JSON-compatible data before it is sent to the transport.
  """
  @spec poke(instance(), signal(), encoded_value(), [command_option()]) :: api_result()
  def poke(instance, signal, encoded_value, opts \\ []) do
    with {:ok, _opts} <- validate_keyword_options(opts),
         {:ok, _opts} <- validate_allowed_options(opts, [:timeout]),
         {:ok, signal} <- normalize_signal(signal),
         {:ok, value} <- normalize_encoded_value(encoded_value) do
      request(instance, "poke", %{"signal" => signal, "value" => value}, opts)
    end
  end

  @doc """
  Reads a signal from the simulator.
  """
  @spec peek(instance(), signal(), [command_option()]) :: api_result()
  def peek(instance, signal, opts \\ []) do
    with {:ok, _opts} <- validate_keyword_options(opts),
         {:ok, _opts} <- validate_allowed_options(opts, [:timeout]),
         {:ok, signal} <- normalize_signal(signal) do
      request(instance, "peek", %{"signal" => signal}, opts)
    end
  end

  @doc """
  Sends the terminal shutdown command and stops the simulation instance.

  Returns `:ok` after the wrapper acknowledges shutdown and the transport is
  closed. Returns `{:error, error_body}` if the shutdown request fails.
  """
  @spec stop(instance(), [command_option()]) :: :ok | {:error, error_body()}
  def stop(instance, opts \\ []) do
    with {:ok, _opts} <- validate_keyword_options(opts),
         {:ok, _opts} <- validate_allowed_options(opts, [:timeout]),
         {:ok, timeout_override} <- command_timeout_override(opts) do
      Server.stop(instance, timeout_override)
    end
  end

  defp request(instance, op, body, opts) do
    with {:ok, timeout_override} <- command_timeout_override(opts) do
      Server.request(instance, op, body, timeout_override)
    end
  end

  defp build_reset_body(opts) do
    with {:ok, _opts} <- validate_keyword_options(opts),
         {:ok, _opts} <- validate_allowed_options(opts, [:cycles, :reset, :clock, :timeout]),
         {:ok, cycles} <- positive_integer_option(opts, :cycles, 1),
         {:ok, body} <- optional_signal(%{"cycles" => cycles}, opts, :reset) do
      optional_signal(body, opts, :clock)
    end
  end

  defp build_tick_body(opts) do
    with {:ok, _opts} <- validate_keyword_options(opts),
         {:ok, _opts} <- validate_allowed_options(opts, [:cycles, :clock, :timeout]),
         {:ok, cycles} <- positive_integer_option(opts, :cycles, 1) do
      optional_signal(%{"cycles" => cycles}, opts, :clock)
    end
  end

  defp optional_signal(body, opts, key) do
    case Keyword.fetch(opts, key) do
      :error ->
        {:ok, body}

      {:ok, signal} ->
        with {:ok, signal} <- normalize_signal(signal) do
          {:ok, Map.put(body, Atom.to_string(key), signal)}
        end
    end
  end

  defp validate_keyword_options(opts) when is_list(opts) do
    if Keyword.keyword?(opts) do
      {:ok, opts}
    else
      validation_error("options must be a keyword list", %{"options" => inspect(opts)})
    end
  end

  defp validate_keyword_options(opts) do
    validation_error("options must be a keyword list", %{"options" => inspect(opts)})
  end

  defp validate_allowed_options(opts, allowed) do
    unknown = Keyword.keys(opts) -- allowed

    if unknown == [] do
      {:ok, opts}
    else
      validation_error("unknown option", %{
        "allowed" => Enum.map(allowed, &Atom.to_string/1),
        "unknown" => Enum.map(unknown, &Atom.to_string/1)
      })
    end
  end

  defp positive_integer_option(opts, key, default) do
    value = Keyword.get(opts, key, default)

    if is_integer(value) and value > 0 do
      {:ok, value}
    else
      validation_error("#{key} must be a positive integer", %{Atom.to_string(key) => value})
    end
  end

  defp command_timeout_override(opts) do
    command_timeout_override_case(validate_keyword_options(opts))
  end

  defp command_timeout_override_case({:ok, opts}) do
    case Keyword.fetch(opts, :timeout) do
      :error ->
        {:ok, :default}

      {:ok, timeout} ->
        case Protocol.normalize_timeout(timeout) do
          {:ok, normalized} ->
            {:ok, normalized}

          {:error, reason} ->
            validation_error("timeout must be a positive integer or :infinity", %{
              "reason" => inspect(reason),
              "timeout" => inspect(timeout)
            })
        end
    end
  end

  defp command_timeout_override_case({:error, _error_body} = error), do: error

  defp normalize_signal(signal) when is_atom(signal) and signal not in [nil, true, false] do
    signal
    |> Atom.to_string()
    |> normalize_signal()
  end

  defp normalize_signal(signal) when is_binary(signal) and byte_size(signal) > 0 do
    {:ok, signal}
  end

  defp normalize_signal(signal) do
    validation_error("signal must be a non-empty string or atom", %{"signal" => inspect(signal)})
  end

  defp normalize_encoded_value(%{} = encoded_value) do
    bits = Map.get(encoded_value, :bits, Map.get(encoded_value, "bits"))
    width = Map.get(encoded_value, :width, Map.get(encoded_value, "width"))

    cond do
      not is_binary(bits) ->
        validation_error("encoded value bits must be a string", %{"bits" => inspect(bits)})

      not (is_integer(width) and width > 0) ->
        validation_error("encoded value width must be a positive integer", %{
          "width" => inspect(width)
        })

      String.length(bits) != width ->
        validation_error("encoded value width must match bit-string length", %{
          "bits" => bits,
          "width" => width
        })

      not valid_bit_string?(bits) ->
        validation_error("encoded value bits may only contain 0, 1, x, or z", %{"bits" => bits})

      true ->
        {:ok, %{"bits" => bits, "width" => width}}
    end
  end

  defp normalize_encoded_value(value) do
    validation_error("encoded value must be a map", %{"value" => inspect(value)})
  end

  defp valid_bit_string?(bits) do
    bits
    |> String.to_charlist()
    |> Enum.all?(&(&1 in ~c"01xz"))
  end

  defp validation_error(message, details) do
    case Protocol.error_body("invalid_request", message, details) do
      {:ok, body} ->
        {:error, body}

      {:error, reason} ->
        {:error,
         %{
           "code" => "invalid_request",
           "message" => inspect(reason),
           "details" => details,
           "fatal" => false
         }}
    end
  end
end