Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions spec/eval_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
local errors = require("jsonata.errors")

describe("M5c: D3120/D3121 error templates", function()
it("defines D3120 and D3121 with interpolatable {{value}}", function()
local ok1, e1 = pcall(errors.raise, "D3120", { value = "boom" })
assert.is_false(ok1)
assert.are.equal("D3120", e1.code)
assert.is_not_nil(e1.message:find("eval", 1, true))
assert.is_nil(e1.message:find("{{", 1, true)) -- {{value}} interpolated

local ok2, e2 = pcall(errors.raise, "D3121", { value = "boom" })
assert.is_false(ok2)
assert.are.equal("D3121", e2.code)
assert.is_nil(e2.message:find("{{", 1, true))
end)
end)

describe("M5c: M.apply wants_env hook", function()
local E = require("jsonata.evaluator")
local Environment = require("jsonata.environment")

it("passes (env, input, ...args) to a wants_env builtin", function()
local seen = {}
local proc = {
_jsonata_function = true,
wants_env = true,
impl = function(env, input, a)
seen.env, seen.input, seen.a = env, input, a
return a
end,
}
local env = Environment.new()
local r = E.apply(proc, { 42 }, "INPUT", env)
assert.are.equal(42, r)
assert.are.equal("INPUT", seen.input)
assert.are.equal(env, seen.env)
assert.are.equal(42, seen.a)
end)

it("a normal builtin gets only its args (no env/input prefix)", function()
local seen = {}
local proc = {
_jsonata_function = true,
impl = function(a, b)
seen.a, seen.b = a, b
return a
end,
}
E.apply(proc, { 7, 8 }, "INPUT", Environment.new())
assert.are.equal(7, seen.a)
assert.are.equal(8, seen.b) -- NOT shifted by an env/input prefix
end)
end)

describe("M5c: $eval", function()
local jsonata = require("jsonata")
local function run(src, input)
return jsonata.compile(src):evaluate(input)
end

it("parses and evaluates a literal expression", function()
assert.are.same({ 1, 2, 3 }, run("$eval('[1,2,3]')"))
end)

it("sees builtins in the evaluated string", function()
assert.are.same({ 1, "2", 3 }, run("$eval('[1,$string(2),3]')"))
end)

it("returns undefined for an undefined argument", function()
assert.is_nil(run("$eval(nope)", {}))
end)

it("evaluates against the current input by default", function()
assert.are.equal(6, run("$eval('a + b + c')", { a = 1, b = 2, c = 3 }))
end)

it("uses an explicit 2nd-arg context override", function()
assert.are.equal(6, run("$eval('x*y*z', sub)", { sub = { x = 1, y = 2, z = 3 } }))
end)
end)

describe("M5c: $eval outer-scope + error boundary", function()
local jsonata = require("jsonata")
local function run(src, input)
return jsonata.compile(src):evaluate(input)
end
local function code(src, input)
local ok, err = pcall(run, src, input)
assert.is_false(ok)
return err.code
end

it("sees an outer-scope bound variable (option i)", function()
assert.are.equal(5, run("($x := 5; $eval('$x'))"))
end)

it("raises D3120 on a syntax error in the expression", function()
assert.are.equal("D3120", code("$eval('[1,#string(2),3]')"))
end)

it("raises D3121 on a runtime error in the expression", function()
assert.are.equal("D3121", code("$eval('[1,string(2),3]')"))
end)

it("can recurse", function()
assert.are.equal(2, run("$eval('$eval(\"1+1\")')"))
end)
end)

describe("M5c: $eval via ~> and array focus (adversarial fixes)", function()
local jsonata = require("jsonata")
local function run(src, input)
return jsonata.compile(src):evaluate(input)
end

it("works when applied via ~> (env threaded, no crash)", function()
assert.are.equal(2, run("'1+1' ~> $eval"))
end)

it("sees outer-scope vars when applied via ~>", function()
assert.are.equal(8, run("($x := 7; '$x+1' ~> $eval)"))
end)

it("indexes a non-sequence array focus correctly", function()
assert.are.equal(10, run("$eval('$[0]', [10,20,30])"))
assert.are.equal(20, run("$eval('$[1]', [10,20,30])"))
end)

it("maps over a non-sequence array focus correctly", function()
assert.are.same({ 1, 2 }, run("$eval('$.a', [{'a':1},{'a':2}])"))
end)

it("still treats an input-independent expr the same (case008 intact)", function()
local r = run("$eval('{\"test\": 1}', [1,2,3])")
assert.are.equal(1, r.test)
end)
end)
10 changes: 10 additions & 0 deletions spec/jsonata-suite/baseline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,18 @@ return {
["function-error/case008"] = true,
["function-error/case009"] = true,
["function-error/case010"] = true,
["function-eval/case000"] = true,
["function-eval/case001"] = true,
["function-eval/case002"] = true,
["function-eval/case003"] = true,
["function-eval/case004"] = true,
["function-eval/case005"] = true,
["function-eval/case006"] = true,
["function-eval/case007"] = true,
["function-eval/case008/0"] = true,
["function-eval/case008/1"] = true,
["function-eval/case008/2"] = true,
["function-eval/case008/3"] = true,
["function-exists/case000"] = true,
["function-exists/case001"] = true,
["function-exists/case002"] = true,
Expand Down
2 changes: 2 additions & 0 deletions src/jsonata/errors.lua
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ local MESSAGES = {
D3138 = "The single() function expected exactly 1 matching result. Instead it matched more.",
D3139 = "The single() function expected exactly 1 matching result. Instead it matched 0.",
D3141 = "$assert() statement failed",
D3120 = "Syntax error in expression passed to function eval: {{value}}",
D3121 = "Dynamic error evaluating the expression passed to function eval: {{value}}",
}

function M.is_error(x)
Expand Down
13 changes: 8 additions & 5 deletions src/jsonata/evaluator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -781,9 +781,9 @@ local function _evaluate(node, input, env)
for _, a in ipairs(rhs.arguments) do
args[#args + 1] = evaluate(a, input, env)
end
return M.apply(proc, args, input)
return M.apply(proc, args, input, env)
end
return M.apply(evaluate(rhs, input, env), { lhs }, input)
return M.apply(evaluate(rhs, input, env), { lhs }, input, env)
elseif t == "transform" then
return {
_jsonata_function = true,
Expand Down Expand Up @@ -894,14 +894,17 @@ evaluate = function(node, input, env)
end

-- Apply a procedure (lambda closure or builtin) to a list of evaluated args.
function M.apply(proc, args, context)
function M.apply(proc, args, context, env)
if type(proc) == "table" and proc._jsonata_lambda then
return M.apply_lambda(proc, args, context)
end
if type(proc) == "table" and proc._jsonata_function then
if proc.signature then
args = proc.signature.validate(args, context)
end
if proc.wants_env then
return proc.impl(env, context, (table.unpack or unpack)(args, 1, #args))
end
return proc.impl((table.unpack or unpack)(args, 1, #args))
end
errors.raise("T1006", { value = proc })
Expand Down Expand Up @@ -933,7 +936,7 @@ function M.eval_function(node, input, env)
for i, a in ipairs(node.arguments) do
args[i] = evaluate(a, input, env)
end
local result = M.apply(proc, args, input)
local result = M.apply(proc, args, input, env)
if V.is_sequence(result) then
return finalize_sequence(result, false)
end
Expand Down Expand Up @@ -965,7 +968,7 @@ function M.partial(proc, argnodes, input, env)
for k, pos in ipairs(holes) do
args[pos] = fill[k]
end
return M.apply(proc, args)
return M.apply(proc, args, input, env)
end,
}
end
Expand Down
65 changes: 65 additions & 0 deletions src/jsonata/functions/eval.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
local V = require("jsonata.value")
local errors = require("jsonata.errors")

local R = {}

-- Lazy requires (break the evaluator -> functions -> evaluator load cycle; memoized).
local parser, evaluator
local function get_parser()
parser = parser or require("jsonata.parser")
return parser
end
local function get_evaluator()
evaluator = evaluator or require("jsonata.evaluator")
return evaluator
end

-- $eval(expr [, focus]) — parse `expr` as JSONata and evaluate it against the
-- current environment + the call-site input (or `focus` if supplied).
-- wants_env: M.apply prepends (env, input) ahead of the validated args.
local function eval_impl(env, input, expr, focus)
if V.is_nothing(expr) then
return V.NOTHING
end

-- Parse — non-recovering; any syntax error becomes D3120.
local ok, ast = pcall(get_parser().parse, expr)
if not ok then
errors.raise("D3120", { value = (type(ast) == "table" and ast.message) or tostring(ast) })
end

-- Pick the input: explicit focus overrides; else the threaded call-site input.
-- A non-sequence array focus carries the internal `cons` flag (from array
-- construction), which corrupts path steps; rebuild it clean (jsonata wraps a
-- non-sequence array focus so it's treated as a normal value).
local target = focus
if V.is_nothing(focus) then
target = input
elseif V.is_array(focus) and not V.is_sequence(focus) then
local clean = {}
for i = 1, #focus do
clean[i] = focus[i]
end
target = V.array(clean)
end

-- Evaluate against the CURRENT env — any runtime error becomes D3121.
local ok2, result = pcall(get_evaluator().evaluate, ast, target, env)
if not ok2 then
errors.raise("D3121", { value = (type(result) == "table" and result.message) or tostring(result) })
end
return result
end

-- Build the def manually: with wants_env, M.apply calls proc.impl(env, input, ...validated_args).
-- The H.def arity-checking wrapper would count env+input as extra args and fail.
-- The signature already validates the user-supplied args; we just need arity=1 for HOF hints.
R.eval = {
_jsonata_function = true,
impl = eval_impl,
arity = 1,
signature = require("jsonata.signature").parse("<sx?:x>"),
wants_env = true,
}

return R
1 change: 1 addition & 0 deletions src/jsonata/functions/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ local categories = {
require("jsonata.functions.array"),
require("jsonata.functions.object"),
require("jsonata.functions.higher_order"),
require("jsonata.functions.eval"),
}

M.registry = {}
Expand Down
Loading