diff --git a/spec/compose_spec.lua b/spec/compose_spec.lua new file mode 100644 index 0000000..0eeb337 --- /dev/null +++ b/spec/compose_spec.lua @@ -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) diff --git a/spec/jsonata-suite/baseline.lua b/spec/jsonata-suite/baseline.lua index d99abbd..490934e 100644 --- a/spec/jsonata-suite/baseline.lua +++ b/spec/jsonata-suite/baseline.lua @@ -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, diff --git a/src/jsonata/errors.lua b/src/jsonata/errors.lua index e51ded1..27aecdc 100644 --- a/src/jsonata/errors.lua +++ b/src/jsonata/errors.lua @@ -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", diff --git a/src/jsonata/evaluator.lua b/src/jsonata/evaluator.lua index 6a19155..0aabf96 100644 --- a/src/jsonata/evaluator.lua +++ b/src/jsonata/evaluator.lua @@ -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) @@ -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, @@ -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 @@ -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 = {}