# SPDX-FileCopyrightText: 2026 ash_lua contributors # # SPDX-License-Identifier: MIT defmodule AshLua.EvalActions.Run.Eval do @moduledoc """ Implementation backing the synthesized `:eval` action. Evaluates the script through `AshLua.eval!/2` against a manifest scoped to the `eval_actions` configuration, with the caller's actor / tenant / context threaded through. The action returns a typed map `%{result, error}` mirroring the in-script `(result, err)` convention — successful runs populate `result`; failed runs populate `error`. """ use Ash.Resource.Actions.Implementation @impl true def run(input, _opts, context) do script = input.arguments.script resource = input.resource with {:ok, manifest} <- AshLua.Surface.for_eval_resource(resource) do do_run(script, manifest, AshLua.EvalActions.Info.forbidden_fields(resource), context) end end defp do_run(script, manifest, forbidden_fields, context) do eval_opts = [ manifest: manifest, actor: context.actor, tenant: context.tenant, context: Map.get(context, :source_context, %{}) || %{}, forbidden_fields: forbidden_fields ] try do {values, lua} = AshLua.eval!(script, eval_opts) {result, error} = split_lua_return(values) # Pass the script's return value through the encoder so any Luerl # reference records (function/userdata/table refs from e.g. `return # loop.item`) become opaque markers rather than crashing Jason in the # downstream MCP serializer. {:ok, %{ result: AshLua.Encoder.encode_result(result), error: error, print_output: AshLua.Runtime.print_output(lua) }} rescue e in [Lua.CompilerException, Lua.RuntimeException] -> # On Lua-side raise we lost the post-call state, so we can't surface # the prints from this particular run. Future improvement: thread # `lua` out via the exception so partial print output survives. {:ok, %{ result: nil, error: extract_structured_error(e) || format_lua_error(e), print_output: [] }} end end # When a Lua script does `assert(action_call())` and `action_call()` returns # `(nil, err_table)`, Lua raises with the err_table as the error object. The # default `Lua.RuntimeException` message is the opaque "error object is a # table!" — useless for diagnosing `invalid_fields` / `not_found` / etc. # Detect that case and decode the original error table back out so the # action's `error` slot carries the same structured shape the script would # have seen on the unwrapped path. defp extract_structured_error(%Lua.RuntimeException{original: original, state: state}) when not is_nil(state) do original |> raised_value() |> case do nil -> nil value -> value |> safe_decode(state) |> normalize_error_table() end end defp extract_structured_error(_), do: nil # Pull the actual raised Lua value out of Luerl's error tagging. `assert(x, # err)` raises `{:assert_error, err}`; `error(err)` raises `{:error_call, # [err]}`. Anything else (`{:illegal_index, ...}`, etc.) isn't an # ash_lua-shaped error and we let the default rescue formatter handle it. defp raised_value({:assert_error, value}), do: value defp raised_value({:error_call, [value | _]}), do: value defp raised_value(_), do: nil defp safe_decode(value, state) do :luerl.decode(value, state) rescue _ -> nil end defp normalize_error_table(list) when is_list(list) do case AshLua.Encoder.decode_input(list) do %{"class" => _, "errors" => _} = err -> err _ -> nil end end defp normalize_error_table(_), do: nil defp split_lua_return([]), do: {nil, nil} defp split_lua_return([value]), do: {value, nil} defp split_lua_return([value, nil | _]), do: {value, nil} defp split_lua_return([nil, err | _]), do: {nil, normalize_error(err)} defp split_lua_return([value, err | _]), do: {value, normalize_error(err)} defp normalize_error(err) when is_list(err), do: Map.new(err) defp normalize_error(err) when is_map(err), do: err defp normalize_error(err), do: %{"message" => inspect(err)} defp format_lua_error(e) do %{ "message" => Exception.message(e), "errors" => [ %{ "message" => Exception.message(e), "short_message" => "lua_error", "code" => "lua_error", "fields" => [], "vars" => %{} } ] } end end