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
85 changes: 85 additions & 0 deletions spec/compose_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
local errors = require("jsonata.errors")
local E = require("jsonata.evaluator")

describe("M5d: M.is_function + T2006", function()
it("M.is_function recognizes lambdas and builtins, rejects data", function()
assert.is_true(E.is_function({ _jsonata_lambda = true }))
assert.is_true(E.is_function({ _jsonata_function = true }))
assert.is_falsy(E.is_function(5))
assert.is_falsy(E.is_function("x"))
assert.is_falsy(E.is_function({ a = 1 }))
assert.is_falsy(E.is_function(nil))
end)

it("defines the T2006 template", function()
local ok, e = pcall(errors.raise, "T2006", { value = 3 })
assert.is_false(ok)
assert.are.equal("T2006", e.code)
assert.is_not_nil(e.message:find("function application", 1, true))
end)
end)

describe("M5d: ~> function composition", 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("composes two builtins, applied via ~>", function()
assert.are.equal(225, run("($square := function($x){$x*$x}; $i := $sum ~> $square; [1..5] ~> $i())"))
end)

it("composes and is directly callable, left-to-right order", function()
assert.are.equal("HELLO WORLD", run("($ut := $trim ~> $uppercase; $ut(' Hello World '))"))
end)

it("composes partially-applied functions (partial composition)", function()
assert.are.equal(55, run("($square := function($x){$x*$x}; $ss := $map(?, $square) ~> $sum; [1..5] ~> $ss())"))
end)

it("re-composes (three-way, left-associative)", function()
assert.are.equal(13, run("($inc := function($x){$x+1}; $dbl := function($x){$x*2}; $f := $inc ~> $dbl ~> $inc; $f(5))"))
end)

it("raises T2006 when the right side is not a function", function()
assert.are.equal("T2006", code("42 ~> 'hello'"))
assert.are.equal("T2006", code("($f := $sum; $f ~> 3)"))
end)

it("does not break the apply form (non-function lhs unchanged)", function()
assert.are.equal("5", run("5 ~> $string"))
assert.are.equal(6, run("[1,2,3] ~> $sum"))
assert.are.equal("HI", run("'hi' ~> $uppercase"))
end)
end)

describe("M5d: ~> with a partial-application RHS", function()
local jsonata = require("jsonata")
local function run(src, input)
return jsonata.compile(src):evaluate(input)
end

it("composes two partials (case009 — both sides are functions)", function()
assert.are.equal("example", run('($domain := $substringAfter(?,"@") ~> $substringBefore(?,"."); $domain("john@example.com"))'))
end)

it("composes partials inline applied to a value", function()
assert.are.equal("example", run('"john@example.com" ~> $substringAfter(?, "@") ~> $substringBefore(?, ".")'))
end)

it("applies a partial RHS to a data lhs (fills the placeholder with lhs)", function()
assert.are.equal(25, run("5 ~> $power(?, 2)")) -- 5^2
assert.are.equal(32, run("5 ~> $power(2, ?)")) -- 2^5
end)

it("leaves the no-placeholder apply-with-args form unchanged", function()
assert.are.equal("hello", run('"hello world" ~> $substring(0, 5)'))
assert.are.equal("5", run("5 ~> $string"))
assert.are.equal(6, run("[1,2,3] ~> $sum"))
end)
end)
6 changes: 6 additions & 0 deletions spec/jsonata-suite/baseline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,19 @@ return {
["function-applications/case002"] = true,
["function-applications/case003"] = true,
["function-applications/case004"] = true,
["function-applications/case005"] = true,
["function-applications/case006"] = true,
["function-applications/case007"] = true,
["function-applications/case008"] = true,
["function-applications/case009"] = true,
["function-applications/case010"] = true,
["function-applications/case011"] = true,
["function-applications/case012"] = true,
["function-applications/case013"] = true,
["function-applications/case014"] = true,
["function-applications/case015"] = true,
["function-applications/case016"] = true,
["function-applications/case017"] = true,
["function-applications/case019"] = true,
["function-applications/case020"] = true,
["function-assert/case000"] = true,
Expand Down
1 change: 1 addition & 0 deletions src/jsonata/errors.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ local MESSAGES = {
T1003 = "Key in object structure must evaluate to a string; got: {{value}}",
T1006 = "Attempted to invoke a non-function",
T2001 = "The left side of an operator must evaluate to a number",
T2006 = "The right side of the function application operator ~> must be a function",
T2002 = "The right side of an operator must evaluate to a number",
T2003 = "The left side of the range operator (..) must evaluate to an integer",
T2004 = "The right side of the range operator (..) must evaluate to an integer",
Expand Down
50 changes: 44 additions & 6 deletions src/jsonata/evaluator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ local sort = require("jsonata.sort")

local M = {}

-- The function-composition meta-lambda (jsonata chainAST): parsed once, evaluated
-- per-compose so the inner lambda closes over the specific $f/$g. Lazy-required to
-- avoid an evaluator->parser load edge at module init.
local chain_ast
local function get_chain_ast()
if not chain_ast then
chain_ast = require("jsonata.parser").parse("function($f, $g){ function($x){ $g($f($x)) } }")
end
return chain_ast
end

local evaluate -- forward declaration

local function as_number(x, code)
Expand Down Expand Up @@ -776,14 +787,36 @@ local function _evaluate(node, input, env)
local lhs = evaluate(node.lhs, input, env)
local rhs = node.rhs
if rhs.type == "function" then
local proc = evaluate(rhs.procedure, input, env)
local args = { lhs }
local partial = false
for _, a in ipairs(rhs.arguments) do
args[#args + 1] = evaluate(a, input, env)
if a.type == "placeholder" then
partial = true
break
end
end
return M.apply(proc, args, input, env)
-- A bare invocation (no placeholders) is the apply-with-args form:
-- lhs ~> $f(a, b) => $f(lhs, a, b). When the rhs carries a `?`
-- placeholder it is a partial application; fall through and evaluate it
-- as a function value to compose with (or apply to) the lhs below.
if not partial then
local proc = evaluate(rhs.procedure, input, env)
local args = { lhs }
for _, a in ipairs(rhs.arguments) do
args[#args + 1] = evaluate(a, input, env)
end
return M.apply(proc, args, input, env)
end
end
local proc = evaluate(rhs, input, env)
if not M.is_function(proc) then
errors.raise("T2006", { value = proc, position = node.position })
end
if M.is_function(lhs) then
-- compose: $f ~> $g => λ($x){ $g($f($x)) } (f first, then g)
local chain = evaluate(get_chain_ast(), input, env)
return M.apply(chain, { lhs, proc }, input, env)
end
return M.apply(evaluate(rhs, input, env), { lhs }, input, env)
return M.apply(proc, { lhs }, input, env)
elseif t == "transform" then
return {
_jsonata_function = true,
Expand Down Expand Up @@ -893,6 +926,11 @@ evaluate = function(node, input, env)
return result
end

-- True iff `x` is a callable JSONata value (a lambda closure or a builtin).
function M.is_function(x)
return type(x) == "table" and (x._jsonata_lambda or x._jsonata_function)
end

-- Apply a procedure (lambda closure or builtin) to a list of evaluated args.
function M.apply(proc, args, context, env)
if type(proc) == "table" and proc._jsonata_lambda then
Expand Down Expand Up @@ -945,7 +983,7 @@ end

-- Partial application: $f(?, x) -> a new function that fills the holes when applied.
function M.partial(proc, argnodes, input, env)
if not (type(proc) == "table" and (proc._jsonata_lambda or proc._jsonata_function)) then
if not M.is_function(proc) then
errors.raise("T1006", { value = proc })
end
local bound = {}
Expand Down
Loading