defmodule Rx do @moduledoc """ Public API for driving R from Elixir. The external process backend is the default and production-preferred runtime. The embedded native backend is experimental and opt-in. Native Arrow dataframe exchange is available on validated native platforms, including macOS/arm64, when native is explicitly selected. """ @r_integer_min -2_147_483_647 @r_integer_max 2_147_483_647 @max_exact_double_integer 9_007_199_254_740_992 @type backend_selector :: :process | :port_arrow | :native | :embedded_nif | module() @type vector_value :: [boolean()] | [integer() | float()] | [String.t()] @type global_value :: nil | boolean() | integer() | float() | String.t() | Rx.NA.t() | Rx.Object.t() | Rx.Table.t() | vector_value() | %{optional(String.t()) => global_value()} @type globals :: %{optional(String.t()) => global_value()} @doc """ Initializes the configured R backend. Accepts the same options as `system_init/1`. """ @spec init(keyword()) :: :ok def init(opts \\ []) when is_list(opts), do: system_init(opts) @doc """ Initializes the selected system R backend. Options: * `:backend` - `:process`/`:port_arrow` for the external `Rscript` backend, or `:native`/`:embedded_nif` for the experimental NIF backend. * `:r_binary` - executable used by the process backend. Defaults to `"Rscript"`. * `:lib_paths` - R library paths prepended to `.libPaths()`. * `:r_home` - R home directory for the native backend. * `:lib_r_path` - path to `libR.so` or `libR.dylib` for the native backend. `system_init/1` is strict: after Rx is initialized, calling it with a different resolved backend configuration raises. """ @spec system_init(keyword()) :: :ok def system_init(opts \\ []) when is_list(opts) do config = opts |> validate_system_init_opts!() |> resolve_system_r!() Rx.Runtime.system_init(config) end @doc """ Selects and initializes the R backend for this BEAM. Calling `use_backend/2` after the process backend has already initialized Rx stops that external R process and starts the newly selected backend. Existing backend-owned object handles from the stopped process are invalid after the switch. The embedded native backend cannot currently be shut down inside the same BEAM. Once native has initialized, switching to another backend requires restarting the BEAM or Livebook runtime. Supported selectors: * `:process` - external long-running `Rscript` process backend * `:native` - embedded NIF backend * `:port_arrow` - compatibility alias for `:process` * `:embedded_nif` - compatibility alias for `:native` Options are the same as `system_init/1`, except the backend selector is passed as the first argument. """ @spec use_backend(backend_selector(), keyword()) :: :ok def use_backend(backend, opts \\ []) when is_list(opts) do if Keyword.has_key?(opts, :backend) do raise ArgumentError, "pass the backend as the first argument to Rx.use_backend/2" end backend |> backend_selector_opts(opts) |> validate_system_init_opts!() |> resolve_system_r!() |> Rx.Runtime.use_backend() end @doc """ Ensures the default R backend is initialized without replacing an explicit backend init. """ @spec ensure_init() :: :ok def ensure_init do default_system_init_configs() |> Rx.Runtime.ensure_system_init() end @doc """ Initializes the process backend with an explicit renv project environment. The first argument is either a project directory containing `renv.lock` or an explicit lockfile path. This function validates and loads the project environment only when called; ordinary `Rx.init/1`, `Rx.ensure_init/0`, and auto-init do not discover or activate renv projects. """ @spec renv_init(Path.t(), keyword()) :: :ok def renv_init(project_or_lockfile, opts \\ []) do project_or_lockfile |> Rx.Renv.resolve!(opts) |> Rx.Renv.ensure_runtime_can_use_process!() |> Rx.Renv.preflight!() |> Rx.Renv.to_runtime_config() |> Rx.Runtime.use_backend() end @doc """ Evaluates R source code and returns the result handle plus global handles. `globals` must be a map with string keys. Values may be raw scalar values (`nil`, booleans, finite numbers, and strings), `%Rx.Object{}` handles from prior calls, or string-key maps that are encoded as R named lists. Use `encode!/1` when you need to pass a reusable vector handle. By default this returns `{result, globals}` where `result` is a `%Rx.Object{}` or `nil` for empty/comment-only code, and `globals` contains `%Rx.Object{}` handles for variables left in the R evaluation environment. With `capture: true`, this returns `%Rx.EvalResult{}` and captures stdout, messages, and warnings. In default mode, stdout is written to `:stdout_device` and messages/warnings are written to `:stderr_device`. Options: * `:capture` - when `true`, return `%Rx.EvalResult{}`. * `:stdout_device` - IO device for stdout in default mode. * `:stderr_device` - IO device for messages and warnings in default mode. R parse and evaluation errors raise `Rx.Error`. """ @spec eval(String.t(), globals(), keyword()) :: {Rx.Object.t() | nil, %{optional(String.t()) => Rx.Object.t()}} | Rx.EvalResult.t() def eval(source, globals, opts \\ []) def eval(source, globals, opts) when is_binary(source) and is_map(globals) and is_list(opts) do opts = Keyword.validate!(opts, capture: false, stdout_device: Process.group_leader(), stderr_device: :standard_error ) validate_globals!(globals) :ok = ensure_init() Rx.Runtime.eval(source, globals, opts) end def eval(source, globals, opts) when is_binary(source) and is_list(opts) do raise ArgumentError, "globals must be a map with string keys, got: #{inspect(globals)}" end @doc """ Captures base R plots produced by evaluating R source code. The process backend opens a temporary PNG graphics device, evaluates the source, and returns every generated plot page in order. Low-level additions such as `points()` or `lines()` stay on the current page, matching normal R device behavior. By default this returns a list of `%Rx.Plot{}` structs and routes stdout, messages, and warnings to the configured IO devices. With `capture: true`, it returns `%Rx.PlotResult{}` containing plots plus captured output. Options: * `:capture` - when `true`, returns `%Rx.PlotResult{}` instead of plots * `:format` - plot format; only `:png` is supported initially * `:width` - PNG width in pixels, from 20 through 10000 * `:height` - PNG height in pixels, from 20 through 10000 * `:res` - PNG resolution, from 1 through 10000 * `:pointsize` - PNG point size, from 1 through 1000 * `:max_pages` - maximum captured plot pages, from 1 through 1000 * `:max_bytes` - maximum total PNG bytes, from 1 through 2147483647 * `:stdout_device` - IO device for stdout in default mode * `:stderr_device` - IO device for messages and warnings in default mode Plot errors raise `Rx.Error` with captured output. """ def plot(source, globals, opts \\ []) def plot(source, globals, opts) when is_binary(source) and is_map(globals) and is_list(opts) do opts = Keyword.validate!(opts, capture: false, stdout_device: Process.group_leader(), stderr_device: :standard_error, format: :png, width: 640, height: 480, res: 96, pointsize: 12, max_pages: 100, max_bytes: 64_000_000 ) validate_globals!(globals) validate_plot_opts!(opts) :ok = ensure_init() Rx.Runtime.plot(source, globals, opts) end def plot(source, globals, opts) when is_binary(source) and is_list(opts) do raise ArgumentError, "globals must be a map with string keys, got: #{inspect(globals)}" end @doc """ Encodes an Elixir value as a reusable R object handle. Supported values are `nil`, booleans, integers in R's non-`NA` integer range, integers outside that range when exactly representable as an R double, floats, strings, flat homogeneous lists of booleans/numbers/strings, and string-key maps. Maps are encoded inline as R named lists; `encode_list/1` exposes that map-only behavior explicitly. Integer values outside the exact IEEE-754 double integer range are rejected instead of silently losing precision. """ @spec encode!(global_value()) :: Rx.Object.t() def encode!(%Rx.Object{} = object), do: object def encode!(nil), do: ensure_init_and_encode(:null, nil) def encode!(value) when is_boolean(value), do: ensure_init_and_encode(:logical, value) def encode!(value) when is_integer(value) and value >= @r_integer_min and value <= @r_integer_max, do: ensure_init_and_encode(:integer, value) def encode!(value) when is_integer(value) and value >= -@max_exact_double_integer and value <= @max_exact_double_integer, do: ensure_init_and_encode(:double, value * 1.0) def encode!(value) when is_integer(value) do raise ArgumentError, "unsupported integer outside the exact double range: #{inspect(value)}" end def encode!(value) when is_float(value), do: ensure_init_and_encode(:double, value) def encode!(value) when is_binary(value), do: ensure_init_and_encode(:character, value) def encode!(values) when is_list(values), do: encode_list!(values) def encode!(%Rx.Table{} = table), do: inline_table_object(table) def encode!(%_{} = value), do: raise(ArgumentError, "structs are not supported by Rx.encode!/1: #{inspect(value)}") def encode!(value) when is_map(value) and not is_struct(value) do wire_map = encode_map_value!(value) inline_named_list_object(wire_map, value) end def encode!(value), do: raise(ArgumentError, "unsupported Rx.encode!/1 value: #{inspect(value)}") @doc """ Decodes a supported R object handle into Elixir data. Supported decoded values include `nil`, booleans, integers, floats, strings, flat atomic vectors, typed missing values as `%Rx.NA{}`, fully named plain R lists as maps, unnamed/partial/duplicate-name lists as `%Rx.RList{}`, and R `table` values as `%Rx.Table{}`. Opaque R objects, such as models and most classed objects, are returned as their original `%Rx.Object{}` handles. """ @spec decode(Rx.Object.t()) :: term() def decode(%Rx.Object{} = object), do: Rx.Runtime.decode(object) def decode(_other), do: raise(ArgumentError, "Rx.decode/1 expects a %Rx.Object{}") @doc """ Returns the text produced by R's `print()` method for an object. This is intended for opaque R objects such as fitted models, where the console-style display is useful but `decode/1` should keep the object handle intact. By default this returns stdout as a string. With `capture: true`, it returns a `%Rx.PrintResult{}` containing stdout, messages, and warnings. Options: * `:capture` - when `true`, returns `%Rx.PrintResult{}` instead of stdout * `:width` - temporary R print width, from 10 through 10000 * `:max_print` - temporary R `max.print`, from 1 through 2147483647 R print-method errors raise `Rx.Error` with captured output. """ def print(object, opts \\ []) def print(%Rx.Object{} = object, opts) when is_list(opts) do opts = Keyword.validate!(opts, capture: false, width: nil, max_print: nil ) validate_print_opts!(opts) :ok = ensure_init() Rx.Runtime.print(object, opts) end def print(%Rx.Object{}, opts) do raise ArgumentError, "Rx.print/2 options must be a keyword list, got: #{inspect(opts)}" end def print(_other, _opts), do: raise(ArgumentError, "Rx.print/2 expects a %Rx.Object{}") @doc """ Serializes an R data frame object as Apache Arrow IPC stream bytes. Returns `{:ok, bytes}` on success or `{:error, reason}` when the object is not an Arrow-compatible data frame or the R `arrow` package is unavailable. """ @spec decode_arrow(Rx.Object.t() | term()) :: {:ok, binary()} | {:error, term()} def decode_arrow(%Rx.Object{} = object), do: Rx.Runtime.decode_arrow(object) def decode_arrow(_other), do: {:error, "decode_arrow/1 expects a %Rx.Object{}, use Rx.eval/3 to get one"} @doc """ Returns the currently initialized backend, or `nil` when Rx has not been initialized. The value is `:port_arrow` for the external Rscript backend and `:native` for the embedded NIF backend. """ @spec backend() :: :port_arrow | :native | module() | nil def backend, do: Rx.Runtime.backend() @doc """ Encodes an Elixir map with string keys as an R named list. Returns a `%Rx.Object{}` that can be passed as an `eval/3` global or decoded back to a map with `decode/1`. No R call is made — the map is stored inline in the object id and transmitted to R on first use. Raises `ArgumentError` for non-string keys, unsupported value types, or `Explorer.DataFrame` values (use `Rx.Explorer.to_r/1` first). """ @spec encode_list(%{optional(String.t()) => global_value()}) :: Rx.Object.t() def encode_list(%{} = map) when not is_struct(map) do wire_map = encode_map_value!(map) inline_named_list_object(wire_map, map) end def encode_list(other) do raise ArgumentError, "Rx.encode_list/1 expects a map with string keys, got: #{inspect(other)}" end defp ensure_init_and_encode(type, value) do :ok = ensure_init() Rx.Runtime.encode(type, value) end defp validate_print_opts!(opts) do unless is_boolean(opts[:capture]) do raise ArgumentError, "Rx.print/2 option :capture must be a boolean" end validate_width_or_nil!(opts[:width]) validate_max_print_or_nil!(opts[:max_print]) end defp validate_plot_opts!(opts) do unless is_boolean(opts[:capture]) do raise ArgumentError, "Rx.plot/3 option :capture must be a boolean" end unless opts[:format] == :png do raise ArgumentError, "Rx.plot/3 option :format must be :png, got: #{inspect(opts[:format])}" end validate_plot_integer!(:width, opts[:width], 20, 10_000) validate_plot_integer!(:height, opts[:height], 20, 10_000) validate_plot_integer!(:res, opts[:res], 1, 10_000) validate_plot_integer!(:pointsize, opts[:pointsize], 1, 1_000) validate_plot_integer!(:max_pages, opts[:max_pages], 1, 1_000) validate_plot_integer!(:max_bytes, opts[:max_bytes], 1, @r_integer_max) end defp validate_plot_integer!(_name, value, min, max) when is_integer(value) and value >= min and value <= max, do: :ok defp validate_plot_integer!(name, value, min, max) do raise ArgumentError, "Rx.plot/3 option :#{name} must be an integer from #{min} through #{max}, got: #{inspect(value)}" end defp validate_width_or_nil!(nil), do: :ok defp validate_width_or_nil!(value) when is_integer(value) and value in 10..10_000, do: :ok defp validate_width_or_nil!(value) do raise ArgumentError, "Rx.print/2 option :width must be an integer from 10 through 10000, got: #{inspect(value)}" end defp validate_max_print_or_nil!(nil), do: :ok defp validate_max_print_or_nil!(value) when is_integer(value) and value in 1..@r_integer_max, do: :ok defp validate_max_print_or_nil!(value) do raise ArgumentError, "Rx.print/2 option :max_print must be an integer from 1 through 2147483647, got: #{inspect(value)}" end defp validate_system_init_opts!(opts) do Keyword.validate!(opts, backend: :process, r_home: nil, lib_r_path: nil, lib_paths: [], r_binary: "Rscript" ) end defp resolve_system_r!(opts) do # NOTE: Linux is the primary tested platform, but the port backend works # anywhere Rscript is available. r_binary = opts[:r_binary] || "Rscript" backend = backend_module!(opts[:backend]) [ backend: backend, r_binary: r_binary, r_home: opts[:r_home], lib_r_path: opts[:lib_r_path], lib_paths: opts[:lib_paths] ] end defp default_system_init_configs do case default_backend_mode() do :port_arrow -> [resolve_system_r!(validate_system_init_opts!([]))] :native -> [resolve_system_r!(validate_system_init_opts!(native_default_opts()))] :native_fallback -> [ resolve_system_r!(validate_system_init_opts!(native_default_opts())), resolve_system_r!(validate_system_init_opts!([])) ] end end defp native_default_opts do r_home = System.get_env("R_HOME") || discover_r_home() [backend: :native, r_home: r_home] end defp backend_selector_opts(:process, opts), do: Keyword.put(opts, :backend, :process) defp backend_selector_opts(:port_arrow, opts), do: Keyword.put(opts, :backend, :port_arrow) defp backend_selector_opts(:native, opts) do native_default_opts() |> Keyword.merge(opts) |> Keyword.put(:backend, :native) end defp backend_selector_opts(:embedded_nif, opts) do native_default_opts() |> Keyword.merge(opts) |> Keyword.put(:backend, :embedded_nif) end defp backend_selector_opts(backend, _opts) do raise ArgumentError, "unknown Rx backend selector: #{inspect(backend)}" end defp discover_r_home do case System.cmd("R", ["RHOME"], stderr_to_stdout: true) do {r_home, 0} -> String.trim(r_home) _other -> nil end rescue ErlangError -> nil end defp default_backend_mode do case System.get_env("RX_BACKEND") do nil -> :port_arrow "" -> :port_arrow "process" -> :port_arrow "port_arrow" -> :port_arrow "port" -> :port_arrow "native" -> :native "embedded_nif" -> :native "native_fallback" -> :native_fallback "native_or_port" -> :native_fallback other -> raise ArgumentError, "unknown RX_BACKEND value: #{inspect(other)}" end end defp validate_globals!(globals) do Enum.each(globals, fn {key, %Rx.Object{}} when is_binary(key) -> :ok {key, %Rx.Table{} = table} when is_binary(key) -> Rx.Table.__to_wire__(table) :ok {key, value} when is_binary(key) and (is_nil(value) or is_boolean(value) or is_float(value) or is_binary(value) or (is_integer(value) and value >= -@max_exact_double_integer and value <= @max_exact_double_integer)) -> :ok {key, _value} when not is_binary(key) -> raise ArgumentError, "expected globals keys to be strings, got: #{inspect(key)}" {key, value} when is_map(value) and not is_struct(value) -> {key, encode_map_value!(value)} {key, value} -> raise ArgumentError, "unsupported global for key #{inspect(key)} with value #{inspect(value)}. " <> "Pass nil, booleans, numbers, strings, string-key maps, %Rx.Table{}, or %Rx.Object{} handles." end) end defp backend_module!(:process), do: Rx.Backends.PortArrow defp backend_module!(:port_arrow), do: Rx.Backends.PortArrow defp backend_module!(Rx.Backends.PortArrow), do: Rx.Backends.PortArrow defp backend_module!(:native), do: Rx.Backends.Native defp backend_module!(:embedded_nif), do: Rx.Backends.Native defp backend_module!(Rx.Backends.Native), do: Rx.Backends.Native defp backend_module!(backend) when is_atom(backend) do if Code.ensure_loaded?(backend) and function_exported?(backend, :system_init, 1) do backend else raise ArgumentError, "unknown Rx backend: #{inspect(backend)}" end end defp backend_module!(backend) do raise ArgumentError, "unknown Rx backend: #{inspect(backend)}" end defp encode_list!([]), do: ensure_init_and_encode(:logical_vector, []) defp encode_list!(values) do if Enum.any?(values, &is_list/1), do: raise(ArgumentError, "nested lists are not supported by Rx.encode!/1") type = list_type!(values) ensure_init_and_encode(type, encode_list_values(type, values)) end defp encode_list_values(:double_vector, values), do: Enum.map(values, &double_value!/1) defp encode_list_values(_type, values), do: values defp list_type!(values) do cond do Enum.all?(values, &is_boolean/1) -> :logical_vector Enum.all?(values, &integer32?/1) -> :integer_vector Enum.all?(values, &(is_integer(&1) or is_float(&1))) -> :double_vector Enum.all?(values, &is_binary/1) -> :character_vector true -> raise ArgumentError, "lists must be flat and homogeneous booleans, numbers, or strings" end end defp integer32?(value) when is_integer(value) and value >= @r_integer_min and value <= @r_integer_max, do: true defp integer32?(_value), do: false defp encode_map_value!(%{} = map) when not is_struct(map) do entries = Enum.map(map, fn {k, v} -> unless is_binary(k) do raise ArgumentError, "Rx.encode_list/1 requires string keys, got: #{inspect(k)}" end %{"key" => k, "value" => encode_map_entry_value!(v)} end) %{"kind" => "named_list", "entries" => entries} end defp inline_named_list_object(wire_map, source_map) do json = Jason.encode!(wire_map) %Rx.Object{id: "__inline__:" <> json, remote_info: {:inline_named_list, source_map}} end defp inline_table_object(%Rx.Table{} = table) do json = Jason.encode!(Rx.Table.__to_wire__(table)) %Rx.Object{id: "__inline__:" <> json, remote_info: {:inline_table, table}} end defp encode_map_entry_value!(nil), do: %{"kind" => "scalar", "type" => "null", "value" => nil} defp encode_map_entry_value!(v) when is_boolean(v), do: %{"kind" => "scalar", "type" => "logical", "value" => v} defp encode_map_entry_value!(v) when is_integer(v) and v >= @r_integer_min and v <= @r_integer_max, do: %{"kind" => "scalar", "type" => "integer", "value" => v} defp encode_map_entry_value!(v) when is_integer(v) and v >= -@max_exact_double_integer and v <= @max_exact_double_integer, do: %{"kind" => "scalar", "type" => "double", "value" => v * 1.0} defp encode_map_entry_value!(v) when is_integer(v), do: raise( ArgumentError, "unsupported map integer outside the exact double range: #{inspect(v)}" ) defp encode_map_entry_value!(v) when is_float(v), do: %{"kind" => "scalar", "type" => "double", "value" => v} defp encode_map_entry_value!(v) when is_binary(v), do: %{"kind" => "scalar", "type" => "character", "value" => v} defp encode_map_entry_value!(%Rx.Object{id: "__null__"}), do: %{"kind" => "scalar", "type" => "null", "value" => nil} defp encode_map_entry_value!(%Rx.Object{id: <<"__inline__:", json::binary>>}) do case Jason.decode(json) do {:ok, value} -> value {:error, %Jason.DecodeError{} = error} -> raise ArgumentError, "invalid inline Rx object: #{Exception.message(error)}" end end defp encode_map_entry_value!(%Rx.Object{id: id}), do: %{"kind" => "object", "id" => id} defp encode_map_entry_value!(%Rx.NA{type: t}) when t in [:logical, :integer, :double, :character], do: %{"kind" => "na", "type" => Atom.to_string(t)} defp encode_map_entry_value!(%Rx.NA{type: t}), do: raise( ArgumentError, "unsupported NA type: #{inspect(t)}, must be :logical, :integer, :double, or :character" ) defp encode_map_entry_value!(%Rx.Table{} = table), do: Rx.Table.__to_wire__(table) defp encode_map_entry_value!(%{__struct__: Explorer.DataFrame}), do: raise( ArgumentError, "encode a map value of type Explorer.DataFrame by calling Rx.Explorer.to_r/1 first, " <> "then place the resulting %Rx.Object{} in the map" ) defp encode_map_entry_value!(%_{} = v), do: raise(ArgumentError, "unsupported map value: #{inspect(v)}") defp encode_map_entry_value!(v) when is_list(v) do if Enum.any?(v, &is_list/1) do raise ArgumentError, "nested lists are not supported as map values in Rx.encode_list/1" end type = list_type!(v) {type_str, values} = case type do :logical_vector -> {"logical", v} :integer_vector -> {"integer", v} :double_vector -> {"double", Enum.map(v, &double_value!/1)} :character_vector -> {"character", v} end %{"kind" => "vector", "type" => type_str, "values" => values} end defp encode_map_entry_value!(%{} = v) when not is_struct(v), do: encode_map_value!(v) defp encode_map_entry_value!(v), do: raise(ArgumentError, "unsupported map value: #{inspect(v)}") defp double_value!(value) when is_float(value), do: value defp double_value!(value) when is_integer(value) and value >= -@max_exact_double_integer and value <= @max_exact_double_integer, do: value * 1.0 defp double_value!(value) when is_integer(value), do: raise( ArgumentError, "unsupported integer outside the exact double range: #{inspect(value)}" ) end