Skip to main content

lib/egglog/program.ex

defmodule Egglog.Program do
  @moduledoc """
  Loaded egglog program used for query-local runs.

  A `Program` is a plain handle to a loaded native egglog base e-graph
  containing static declarations, rulesets, rules, and any persistent theory
  facts. Each `run/3` call clones that base inside native egglog and executes
  the supplied input as query-local work.

  Concurrent calls against the same `Program` are safe and query-local, but
  they are serialized by that native resource. This protects the loaded base
  and the clone/run operation for one handle without introducing a process-wide
  lock or an Elixir worker pool. Load multiple `Program` handles explicitly
  when you need parallel throughput.
  """

  alias Egglog.{Commands, Common, Native}

  @enforce_keys [:ref]
  defstruct [:ref, modes: %{}, default_budget: %{rounds: 0}, name: nil]

  @opaque t :: %__MODULE__{
            ref: reference(),
            modes: %{optional(atom() | String.t()) => String.t()},
            default_budget: map(),
            name: String.t() | nil
          }

  @typedoc """
  Decoded value returned by `eval/4` and `lookup/5`.

  `:value` contains a native egglog e-class value rendered as text. Primitive
  egglog values are decoded to ordinary Elixir values when possible:
  integers, floats, booleans, strings, and `nil` for unit.
  """
  @type value :: %{
          required(:sort) => String.t(),
          required(:type) => atom(),
          required(:value) => term()
        }

  @typedoc """
  One output-producing egglog request.

  A request may be written directly as an egglog command string, or as a small
  Elixir map that the wrapper renders to native egglog syntax.
  """
  @type request ::
          String.t()
          | %{required(:type) => :check, required(:expr) => String.t()}
          | %{
              required(:type) => :extract,
              required(:expr) => String.t(),
              optional(:variants) => pos_integer(),
              optional(:limit) => pos_integer()
            }
          | %{required(:type) => :print_size, optional(:name) => String.t()}
          | %{
              required(:type) => :print_function,
              required(:name) => String.t(),
              optional(:limit) => pos_integer()
            }
          | %{optional(String.t()) => term()}

  @typedoc """
  Query-local input for `run/3`, `check/4`, `extract/4`, `eval/4`, and
  `lookup/5`.

  Strings are passed as raw egglog source. Lists are joined as egglog commands.
  Parsed `Egglog.Commands` handles can be reused when the same source is run or
  inspected repeatedly. Maps let Elixir code build common egglog commands
  without string concatenation.
  """
  @type input ::
          %{
            optional(:source) => String.t(),
            optional(:program) => String.t(),
            optional(:commands) => [String.t()],
            optional(:facts) => [String.t()],
            optional(:terms) => %{String.t() => String.t()},
            optional(:sets) => [{String.t(), String.t()}],
            optional(:unions) => [{String.t(), String.t()}],
            optional(:requests) => [request()],
            optional(:schedule) => String.t(),
            optional(:snapshot) => :dot | :json | :none | String.t() | boolean(),
            optional(:snapshot_max_functions) => non_neg_integer(),
            optional(:snapshot_max_calls_per_function) => non_neg_integer(),
            optional(:snapshot_inline_leaves) => non_neg_integer(),
            optional(:snapshot_split_primitive_outputs) => boolean(),
            optional(:mode) => atom() | String.t(),
            optional(:budget) => map()
          }
          | %{optional(String.t()) => term()}
          | Commands.t()

  @typedoc """
  Result returned by `run/3`.
  """
  @type run_result :: %{
          required(:status) => atom(),
          required(:outputs) => [%{required(:type) => atom(), required(:text) => String.t()}],
          required(:stats) => map(),
          required(:report) => map(),
          optional(:snapshot) => map()
        }

  @load_doc """
  Loads a static egglog program into a reusable native handle.

  The theory may be a raw egglog source string or a map containing `:source` or
  `:program`.

  Supported options:

    * `:name` - human-readable program name stored in the returned struct.
      Defaults to `nil`; it is not passed to native egglog.

    * `:proofs` - create the native e-graph with proof support enabled.
      Defaults to `false`. Truthy values are `true`, `"true"`, `"1"`, and `1`;
      all other values are treated as false.

    * `:modes` - map from mode names to egglog schedule forms or commands.
      Defaults to `%{}`. During `run/3`, the selected `:mode` is looked up by
      the mode value and by `to_string(mode)`.

    * `:default_budget` - map merged into each non-parsed `run/3` input
      budget. Defaults to `%{}`. Currently `:rounds`/`"rounds"` and
      `:output_limit`/`"output_limit"` are read by `run/3`.

  String option keys are also accepted, matching the rest of the wrapper.
  """

  @run_doc """
  Runs query-local egglog code against the loaded program.

  The loaded base program is cloned inside native egglog for each run. The
  clone receives query-local input and the loaded base is not mutated.

  `input` may be:

    * a raw egglog source string,
    * a list of raw egglog command strings,
    * a parsed `%Egglog.Commands{}` handle returned by `parse/1`, or
    * a map with structured fields rendered to egglog source.

  Structured input map fields:

    * `:source` or `:program` - raw egglog source emitted first. Defaults to
      unset.
    * `:commands` - list of raw egglog commands emitted after source.
      Defaults to `[]`.
    * `:terms` - map rendered as `(let name expr)` commands. Defaults to `%{}`.
    * `:facts` - list of raw fact/command strings. Defaults to `[]`.
    * `:sets` - list of `{call, value}` tuples rendered as `(set call value)`.
      Defaults to `[]`.
    * `:unions` - list of `{left, right}` tuples rendered as
      `(union left right)`. Defaults to `[]`.
    * `:requests` - list of output-producing requests emitted last. Defaults
      to `[]`. Supported request maps are `:check`, `:extract`,
      `:print_size`, and `:print_function`; raw request strings are also
      accepted.
    * `:schedule` - egglog schedule string. Defaults to unset. Blank strings
      emit no schedule; strings starting with `(run` are emitted unchanged;
      other nonblank strings are wrapped as `(run-schedule schedule)`.
    * `:mode` - mode name used when no `:schedule` is supplied. Defaults to
      `:default`.
    * `:budget` - map merged after the program default budget and before the
      `opts[:budget]` map. Defaults to `%{}`.
    * snapshot fields listed below may also be supplied in the input map; opts
      take precedence over input map snapshot fields.

  Supported options:

    * `:mode` - selected mode when no input `:schedule` is supplied. Defaults
      to input `:mode`, then `:default`. The selected mode is looked up in the
      program's `:modes` map before `:budget` rounds are considered.

    * `:budget` - map merged after `program.default_budget` and input
      `:budget`. Defaults to `%{}`. Currently `:rounds`/`"rounds"` emits
      `(run rounds)` when positive and no selected mode schedule exists.
      `:output_limit`/`"output_limit"` is used as the default output limit for
      non-parsed inputs.

    * `:output_limit` - maximum number of native command outputs to keep in
      the returned `:outputs` list. Defaults to `budget[:output_limit]` for
      non-parsed inputs and unset for parsed command handles. Values must be
      positive integers; invalid values are ignored. When outputs are
      truncated, the returned result has `status: :limit`.

    * `:snapshot` - request a native snapshot after the input has run.
      Defaults to input `:snapshot`, then `:none`.

      Accepted values:

      * `:none`, `"none"`, `false`, or `nil` - do not include a snapshot.
      * `:dot`, `"dot"`, or `true` - include native Graphviz DOT text.
      * `:json` or `"json"` - include native JSON snapshot text.

      `run/3` does not render DOT to SVG. Use `snapshot/3` when you want
      `render: :auto` or `render: :svg` post-processing.

    * `:snapshot_max_functions` - value passed to native egglog's
      `SerializeConfig.max_functions` when `:snapshot` is enabled. Defaults to
      input `:snapshot_max_functions`, then `0`. Values greater than `0` become
      `Some(value)`; `0` and invalid values become `None`.

    * `:snapshot_max_calls_per_function` - value passed to native egglog's
      `SerializeConfig.max_calls_per_function` when `:snapshot` is enabled.
      Defaults to input `:snapshot_max_calls_per_function`, then `0`. Values
      greater than `0` become `Some(value)`; `0` and invalid values become
      `None`.

    * `:snapshot_inline_leaves` - number of native leaf-inlining passes to use
      on the serialized snapshot before producing DOT or JSON text. Defaults
      to input `:snapshot_inline_leaves`, then `0`. Negative or invalid values
      behave like `0`.

    * `:snapshot_split_primitive_outputs` - whether the native serialized
      snapshot should split primitive outputs into separate classes before
      producing DOT or JSON text. Defaults to input
      `:snapshot_split_primitive_outputs`, then `false`.

  For parsed `%Egglog.Commands{}` input, `:output_limit` and the snapshot
  options above are used. `:mode` is passed through to native result metadata,
  but no mode schedule is injected because the command sequence is already
  parsed. Structured input fields and budget-derived schedules are not rebuilt.

  The returned result map contains:

    * `:status` - native run status, or `:limit` when `:output_limit`
      truncated outputs.
    * `:outputs` - list of `%{type: atom(), text: String.t()}` command
      outputs.
    * `:stats` - compact numeric/text stats from native egglog.
    * `:report` - per-rule/per-ruleset reporting data from native egglog.
    * `:snapshot` - present only when `:snapshot` is `:dot` or `:json`;
      contains `%{format: :dot | :json, text: binary(), omitted: term()}`.

  String option keys are also accepted, matching the rest of the wrapper.
  """

  @program_query_opts_doc """
  Supported options are the query-construction subset of `run/3` options:

    * `:mode` - selected mode when no input `:schedule` is supplied. Defaults
      to input `:mode`, then `:default`.
    * `:budget` - map merged after program default budget and input `:budget`.
      Defaults to `%{}`. Currently `:rounds`/`"rounds"` can emit a `(run n)`
      command when no selected mode schedule exists.

  `:output_limit` and snapshot options are not used by this function because it
  does not return command outputs or snapshots.

  String option keys are also accepted, matching the rest of the wrapper.
  """

  @program_check_doc """
  Runs a native egglog check and returns `{:ok, true}` or `{:ok, false}`.

  Supported options are the same as `run/3`; they are passed through to the
  internal run after appending `(check fact)` to the input requests. Defaults
  are therefore the `run/3` defaults.

  Only a failed egglog check is converted to `false`; parse/type/native errors
  are returned as `{:error, reason}`. Snapshot and output options may affect
  the internal run, but their result data is not exposed by this function.
  """

  @program_extract_doc """
  Extracts the cheapest native egglog term for `expr`.

  Supported options:

    * `:variants` - optional value inserted as the second argument to native
      egglog's `(extract expr variants)` command. Defaults to unset, which uses
      `(extract expr)`.

  All `run/3` options are also accepted and passed through after appending the
  extract request to the input. Defaults are the `run/3` defaults. Snapshot and
  output options may affect the internal run, but `extract/4` returns only the
  extracted text.

  String option keys are also accepted, matching the rest of the wrapper.
  """

  @snapshot_doc """
  Builds a query-local e-graph snapshot.

  This is a convenience wrapper around `run/3`: it runs the query-local input
  against a native clone of the loaded program, requests a native DOT or JSON
  snapshot, then optionally renders or writes the serialized graph.

  Supported options:

    * `:render` - output format to return. Defaults to `:auto`.

      Accepted values:

      * `:auto`, `"auto"`, or `true` - render SVG with Graphviz when the
        `dot` executable is available on `PATH`; otherwise return DOT text.
      * `:svg` or `"svg"` - require Graphviz SVG rendering. Returns
        `{:error, {:graphviz_missing, message}}` if `dot` is unavailable.
      * `:dot`, `"dot"`, `false`, or `nil` - return Graphviz DOT text.
      * `:json` or `"json"` - return egglog's native JSON snapshot.

      Internally, `:render` determines the native `:snapshot` format passed to
      `run/3`: `:json` requests JSON; every other render value requests DOT.

    * `:mode` - selected mode when no input `:schedule` is supplied. Defaults
      to input `:mode`, then `:default`.

    * `:budget` - map merged after program default budget and input `:budget`.
      Defaults to `%{}`. Currently `:rounds`/`"rounds"` can emit a `(run n)`
      command when no selected mode schedule exists.

    * `:output_limit` - passed to the underlying `run/3`; it can truncate
      `snapshot.result.outputs`. Defaults to `budget[:output_limit]` for
      non-parsed inputs and unset for parsed command handles.

    * `:snapshot_max_functions` - value passed to native egglog's
      `SerializeConfig.max_functions`. Defaults to input
      `:snapshot_max_functions`, then `0`. Values greater than `0` become
      `Some(value)`; `0` and invalid values become `None`.

    * `:snapshot_max_calls_per_function` - value passed to native egglog's
      `SerializeConfig.max_calls_per_function`. Defaults to input
      `:snapshot_max_calls_per_function`, then `0`. Values greater than `0`
      become `Some(value)`; `0` and invalid values become `None`.

    * `:snapshot_inline_leaves` - number of native leaf-inlining passes to use
      on the serialized snapshot before producing DOT or JSON text. Defaults
      to input `:snapshot_inline_leaves`, then `0`. Negative or invalid values
      behave like `0`.

    * `:snapshot_split_primitive_outputs` - whether the native serialized
      snapshot should split primitive outputs into separate classes before
      producing DOT or JSON text. Defaults to input
      `:snapshot_split_primitive_outputs`, then `false`.

    * `:path` - optional file path where the returned `:text` payload is
      written. Defaults to unset.

    * `:dot_path` - optional file path where DOT is written when DOT text is
      available. Defaults to unset. For `render: :json`, this key is recorded
      as `nil` if provided because no DOT text is produced.

    * `:svg_path` - optional file path where SVG is written when SVG rendering
      succeeds. Defaults to unset. For non-SVG renders, this key is recorded as
      `nil` if provided.

    * `:json_path` - optional file path where JSON is written when
      `render: :json` is used. Defaults to unset. For non-JSON renders, this
      key is recorded as `nil` if provided.

  The returned snapshot map contains:

    * `:format` - one of `:dot`, `:svg`, or `:json`.
    * `:text` - the primary payload for the selected render.
    * `:dot`, `:svg`, or `:json` - the format-specific payload when present.
    * `:omitted` - native omission metadata for capped snapshots.
    * `:stats` - snapshot-specific native stats, currently
      `:snapshot_nodes` and `:snapshot_classes` when available.
    * `:result` - the underlying `run/3` result used to obtain the native
      serialization.
    * any provided artifact path keys after successful writes.

  String option keys are also accepted, matching the rest of the wrapper.
  """

  @doc """
  Parses egglog source into a reusable command handle.

  Parsed commands can be passed to `run/3`. This is useful when the same command
  sequence is executed repeatedly; native egglog parsing happens once, while
  execution still happens against a fresh clone of the loaded base program.
  """
  @spec parse(String.t()) :: {:ok, Commands.t()} | {:error, term()}
  defdelegate parse(source), to: Commands

  @doc """
  Bang variant of `parse/1`.
  """
  @spec parse!(String.t()) :: Commands.t()
  defdelegate parse!(source), to: Commands

  @doc @load_doc
  @spec load(String.t() | map(), keyword() | map()) :: {:ok, t()} | {:error, term()}
  def load(theory_spec, opts \\ []) do
    opts = Common.option_map(opts)
    source = source_from(theory_spec)
    proofs? = Common.truthy?(Common.get(opts, :proofs, false))

    with {:ok, ref} <- Native.load_program(source, proofs?) |> Common.from_native_load() do
      {:ok,
       %__MODULE__{
         ref: ref,
         modes: normalize_map(Common.get(opts, :modes, %{})),
         default_budget: normalize_map(Common.get(opts, :default_budget, %{})),
         name: Common.get(opts, :name)
       }}
    end
  end

  @doc """
  Bang variant of `load/2`.

  #{@load_doc}

  Raises if native egglog rejects the loaded theory.
  """
  @spec load!(String.t() | map(), keyword() | map()) :: t()
  def load!(theory_spec, opts \\ []) do
    theory_spec
    |> load(opts)
    |> Common.bang("failed to load egglog program")
  end

  @doc @run_doc
  @spec run(t(), input(), keyword() | map()) :: {:ok, run_result()} | {:error, term()}
  def run(program, input, opts \\ [])

  def run(%__MODULE__{} = program, %Commands{} = commands, opts) do
    opts = Common.option_map(opts)
    mode = Common.get(opts, :mode, :default)
    snapshot = Common.snapshot_options(opts)

    program.ref
    |> Native.run_parsed_program(
      commands.ref,
      to_string(mode || :default),
      snapshot.format,
      snapshot.max_functions,
      snapshot.max_calls_per_function,
      snapshot.inline_leaves,
      snapshot.split_primitive_outputs?
    )
    |> Common.from_native_result(Common.get(opts, :output_limit))
  end

  def run(%__MODULE__{} = program, input, opts) do
    opts = Common.option_map(opts)
    input_map = normalize_input(input)
    budget = budget(program, input_map, opts)
    mode = mode(input_map, opts)
    source = build_run_source(program, input_map, budget, mode)

    output_limit = Common.get(opts, :output_limit, Common.get(budget, :output_limit))

    snapshot = Common.snapshot_options(opts, input_map)

    program.ref
    |> Native.run_program(
      source,
      to_string(mode || :default),
      snapshot.format,
      snapshot.max_functions,
      snapshot.max_calls_per_function,
      snapshot.inline_leaves,
      snapshot.split_primitive_outputs?
    )
    |> Common.from_native_result(output_limit)
  end

  @doc """
  Bang variant of `run/3`.

  #{@run_doc}

  Raises if native egglog rejects the input or any option validation raises.
  """
  @spec run!(t(), input(), keyword() | map()) :: run_result()
  def run!(%__MODULE__{} = program, input, opts \\ []) do
    program
    |> run(input, opts)
    |> Common.bang("failed to run egglog program")
  end

  @doc @program_check_doc
  @spec check(t(), input(), String.t(), keyword() | map()) ::
          {:ok, boolean()} | {:error, term()}
  def check(%__MODULE__{} = program, input, fact, opts \\ []) when is_binary(fact) do
    program
    |> run(append_request(input, "(check #{fact})"), opts)
    |> Common.check_result()
  end

  @doc """
  Boolean variant of `check/4`.

  #{@program_check_doc}

  It raises on non-check errors so syntax/type mistakes are not silently treated
  as false mathematical assertions.
  """
  @spec check?(t(), input(), String.t(), keyword() | map()) :: boolean()
  def check?(%__MODULE__{} = program, input, fact, opts \\ []) do
    program
    |> check(input, fact, opts)
    |> Common.bang("failed to check egglog fact")
  end

  @doc """
  Asserts that a native egglog check fails.

  This maps to egglog's `(fail (check ...))` form and is useful for tests and
  tutorials that want to show a negative example without surfacing an exception.

  Supported options are the same as `run/3`; they are passed through to the
  internal run after appending `(fail (check fact))` to the input requests.
  Defaults are therefore the `run/3` defaults. Only `:ok` or `{:error, reason}`
  is returned.
  """
  @spec check_fail(t(), input(), String.t(), keyword() | map()) :: :ok | {:error, term()}
  def check_fail(%__MODULE__{} = program, input, fact, opts \\ []) when is_binary(fact) do
    program
    |> run(append_request(input, "(fail (check #{fact}))"), opts)
    |> Common.ok_only()
  end

  @doc @program_extract_doc
  @spec extract(t(), input(), String.t(), keyword() | map()) ::
          {:ok, String.t()} | {:error, term()}
  def extract(%__MODULE__{} = program, input, expr, opts \\ []) when is_binary(expr) do
    program
    |> run(append_request(input, Common.extract_request(expr, opts)), opts)
    |> Common.extraction()
  end

  @doc """
  Bang variant of `extract/4`.

  #{@program_extract_doc}

  Raises if native egglog rejects the input or extraction output is not
  available.
  """
  @spec extract!(t(), input(), String.t(), keyword() | map()) :: String.t()
  def extract!(%__MODULE__{} = program, input, expr, opts \\ []) do
    program
    |> extract(input, expr, opts)
    |> Common.bang("failed to extract egglog term")
  end

  @doc """
  Evaluates an egglog expression after applying query-local input.

  Primitive values are decoded into Elixir values. Non-primitive e-class values
  are returned with `type: :value`.

  #{@program_query_opts_doc}
  """
  @spec eval(t(), input(), String.t(), keyword() | map()) :: {:ok, value()} | {:error, term()}
  def eval(%__MODULE__{} = program, input, expr, opts \\ []) when is_binary(expr) do
    opts = Common.option_map(opts)

    program.ref
    |> Native.eval_program(run_source(program, input, opts), expr)
    |> Common.from_native_value()
  end

  @doc """
  Bang variant of `eval/4`.

  Evaluates an egglog expression after applying query-local input.

  #{@program_query_opts_doc}

  Raises if native egglog rejects the query-local input or expression.
  """
  @spec eval!(t(), input(), String.t(), keyword() | map()) :: value()
  def eval!(%__MODULE__{} = program, input, expr, opts \\ []) do
    program
    |> eval(input, expr, opts)
    |> Common.bang("failed to evaluate egglog expression")
  end

  @doc """
  Looks up a function row after evaluating the given argument expressions.

  Returns `{:ok, nil}` when no row exists for the evaluated key.

  #{@program_query_opts_doc}
  """
  @spec lookup(t(), input(), String.t(), [String.t()], keyword() | map()) ::
          {:ok, value() | nil} | {:error, term()}
  def lookup(%__MODULE__{} = program, input, name, arg_exprs, opts \\ [])
      when is_binary(name) and is_list(arg_exprs) do
    opts = Common.option_map(opts)

    program.ref
    |> Native.lookup_program(run_source(program, input, opts), name, arg_exprs)
    |> Common.from_native_lookup()
  end

  @doc """
  Bang variant of `lookup/5`.

  Looks up a function row after evaluating the given argument expressions.

  #{@program_query_opts_doc}

  Raises if native egglog rejects the query-local input or lookup expression.
  """
  @spec lookup!(t(), input(), String.t(), [String.t()], keyword() | map()) :: value() | nil
  def lookup!(%__MODULE__{} = program, input, name, arg_exprs, opts \\ []) do
    program
    |> lookup(input, name, arg_exprs, opts)
    |> Common.bang("failed to look up egglog function")
  end

  @doc @snapshot_doc
  @spec snapshot(t(), input(), keyword() | map()) :: {:ok, map()} | {:error, term()}
  def snapshot(%__MODULE__{} = program, input, opts \\ []) do
    opts = Common.option_map(opts)
    {render, run_opts} = Common.snapshot_run_opts(opts)

    with {:ok, result} <- run(program, input, run_opts),
         {:ok, snapshot} <- Common.snapshot_from_result(result, opts, render) do
      {:ok, snapshot}
    end
  end

  @doc """
  Bang variant of `snapshot/3`.

  #{@snapshot_doc}

  Raises if native egglog rejects the input, Graphviz rendering fails, or
  artifact writing fails.
  """
  @spec snapshot!(t(), input(), keyword() | map()) :: map()
  def snapshot!(%__MODULE__{} = program, input, opts \\ []) do
    program
    |> snapshot(input, opts)
    |> Common.bang("failed to snapshot egglog e-graph")
  end

  @doc """
  Returns native egglog's tuple count for the loaded base program.

  This wraps Rust egglog's `EGraph::num_tuples()` for the reusable base program.
  Query-local facts inserted by `run/3` do not mutate this base. For
  query-local node/class counts, visualization, or JSON inspection, use
  `snapshot/3` on the query you want to inspect.
  """
  @spec num_tuples(t()) :: {:ok, non_neg_integer()} | {:error, term()}
  def num_tuples(%__MODULE__{} = program) do
    program.ref |> Native.program_num_tuples() |> Common.from_native_count()
  end

  @doc """
  Closes the native loaded-program resource.
  """
  @spec close(t()) :: :ok | {:error, term()}
  def close(%__MODULE__{} = program) do
    program.ref |> Native.close_program() |> Common.from_native_close()
  end

  defp build_run_source(program, input, budget, mode) do
    [
      Common.get(input, :source) || Common.get(input, :program),
      Common.get(input, :commands, []),
      raw_terms(Common.get(input, :terms, %{})),
      Common.get(input, :facts, []),
      raw_sets(Common.get(input, :sets, [])),
      raw_unions(Common.get(input, :unions, [])),
      schedule_command(program, input, budget, mode),
      raw_requests(Common.get(input, :requests, []))
    ]
    |> Common.join_commands()
  end

  defp run_source(program, input, opts) do
    input = normalize_input(input)
    build_run_source(program, input, budget(program, input, opts), mode(input, opts))
  end

  defp budget(program, input, opts) do
    program.default_budget
    |> Map.merge(normalize_map(Common.get(input, :budget, %{})))
    |> Map.merge(normalize_map(Common.get(opts, :budget, %{})))
  end

  defp mode(input, opts), do: Common.get(opts, :mode, Common.get(input, :mode, :default))

  defp append_request(input, request) when is_binary(input),
    do: %{source: input, requests: [request]}

  defp append_request(input, request) when is_list(input),
    do: %{commands: input, requests: [request]}

  defp append_request(%Commands{source: source}, request),
    do: %{source: source || "", requests: [request]}

  defp append_request(input, request) when is_map(input) do
    requests = Common.get(input, :requests, [])
    Map.put(input, :requests, List.wrap(requests) ++ [request])
  end

  defp schedule_command(program, input, budget, mode) do
    case Common.get(input, :schedule) do
      nil -> mode_or_budget_schedule(program, budget, mode)
      schedule -> schedule_to_command(schedule)
    end
  end

  defp mode_or_budget_schedule(program, budget, mode) do
    mode_schedule = Map.get(program.modes, mode) || Map.get(program.modes, to_string(mode))

    cond do
      is_binary(mode_schedule) ->
        schedule_to_command(mode_schedule)

      rounds =
          Common.positive_integer(Common.get(budget, :rounds) || Common.get(budget, "rounds")) ->
        "(run #{rounds})"

      true ->
        nil
    end
  end

  defp schedule_to_command(schedule) when is_binary(schedule) do
    trimmed = String.trim(schedule)

    cond do
      Common.blank?(trimmed) -> nil
      String.starts_with?(trimmed, "(run") -> trimmed
      true -> "(run-schedule #{trimmed})"
    end
  end

  defp raw_terms(terms) when is_map(terms) do
    Enum.map(terms, fn {name, expr} -> "(let #{name} #{expr})" end)
  end

  defp raw_sets(sets) when is_list(sets) do
    Enum.map(sets, fn {call, value} -> "(set #{call} #{value})" end)
  end

  defp raw_unions(unions) when is_list(unions) do
    Enum.map(unions, fn {left, right} -> "(union #{left} #{right})" end)
  end

  defp raw_requests(requests) when is_list(requests), do: Enum.map(requests, &raw_request/1)

  defp raw_request(request) when is_binary(request), do: request

  defp raw_request(request) when is_map(request) do
    type = Common.get(request, :type)

    case normalize_type(type) do
      :check ->
        "(check #{Common.required!(request, :expr)})"

      :extract ->
        expr = Common.required!(request, :expr)

        case Common.get(request, :variants) || Common.get(request, :limit) do
          nil -> "(extract #{expr})"
          variants -> "(extract #{expr} #{variants})"
        end

      :print_size ->
        case Common.get(request, :name) do
          nil -> "(print-size)"
          name -> "(print-size #{name})"
        end

      :print_function ->
        name = Common.required!(request, :name)
        limit = Common.get(request, :limit, 20)
        "(print-function #{name} #{limit})"

      other ->
        raise ArgumentError, "unsupported egglog request type: #{inspect(other)}"
    end
  end

  defp source_from(source) when is_binary(source), do: source

  defp source_from(spec) when is_map(spec),
    do: Common.get(spec, :source) || Common.get(spec, :program) || ""

  defp normalize_input(%Commands{source: source}), do: %{source: source || ""}
  defp normalize_input(source) when is_binary(source), do: %{source: source}
  defp normalize_input(commands) when is_list(commands), do: %{commands: commands}
  defp normalize_input(input) when is_map(input), do: input

  defp normalize_map(nil), do: %{}
  defp normalize_map(map) when is_map(map), do: map
  defp normalize_map(list) when is_list(list), do: Map.new(list)

  defp normalize_type(type) when is_atom(type), do: type

  defp normalize_type(type) when is_binary(type) do
    case String.trim(type) do
      "check" -> :check
      "extract" -> :extract
      "print_size" -> :print_size
      "print-function" -> :print_function
      "print_function" -> :print_function
      other -> other
    end
  end
end