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