From 1adea06d7f3972ccc1bb8007b8429861ea28ba9c Mon Sep 17 00:00:00 2001 From: fl Date: Wed, 24 Jun 2026 19:19:45 +0800 Subject: [PATCH 1/5] feat(M6b): a quoted-string path step selects the named field --- spec/keeparray_spec.lua | 20 ++++++++++++++++++++ src/jsonata/parser.lua | 5 +++++ 2 files changed, 25 insertions(+) create mode 100644 spec/keeparray_spec.lua diff --git a/spec/keeparray_spec.lua b/spec/keeparray_spec.lua new file mode 100644 index 0000000..28250f7 --- /dev/null +++ b/spec/keeparray_spec.lua @@ -0,0 +1,20 @@ +local jsonata = require("jsonata") +local function run(src, input) + return jsonata.compile(src):evaluate(input) +end + +describe("M6b A-2: quoted-string path step selects a field", function() + it('foo."bar" selects the field bar', function() + assert.are.equal(7, run('foo."bar"', { foo = { bar = 7 } })) + end) + it("a top-level string is still the literal", function() + assert.are.equal("bar", run('"bar"', { foo = { bar = 7 } })) + end) + it("foo.\"bar\" over a non-object foo is undefined (faithful)", function() + assert.is_nil(run('foo."bar"', { foo = 5 })) + end) + it("a standalone string with a predicate stays a literal", function() + assert.are.equal("Red", run('"Red"[$$ = "Bus"]', "Bus")) + assert.is_nil(run('"Red"[$$ = "Car"]', "Bus")) + end) +end) diff --git a/src/jsonata/parser.lua b/src/jsonata/parser.lua index 5555bde..3f3a24b 100644 --- a/src/jsonata/parser.lua +++ b/src/jsonata/parser.lua @@ -737,6 +737,11 @@ process_ast = function(ast, ctx) if ast.type == "binary" and ast.value == "." then local steps = {} flatten_path(ast, steps, ctx) + for i = 2, #steps do + if steps[i].type == "string" then + steps[i] = { type = "name", value = steps[i].value, position = steps[i].position } + end + end local path = { type = "path", steps = steps, position = ast.position } resolve_ancestry(path, ctx) return path From 8acda3e15e91f7016e442bd491699e2c0092be3b Mon Sep 17 00:00:00 2001 From: fl Date: Wed, 24 Jun 2026 19:20:24 +0800 Subject: [PATCH 2/5] feat(M6b): tolerate a trailing ; in a block expression --- spec/keeparray_spec.lua | 12 ++++++++++++ src/jsonata/parser.lua | 3 +++ 2 files changed, 15 insertions(+) diff --git a/spec/keeparray_spec.lua b/spec/keeparray_spec.lua index 28250f7..a1c80f3 100644 --- a/spec/keeparray_spec.lua +++ b/spec/keeparray_spec.lua @@ -18,3 +18,15 @@ describe("M6b A-2: quoted-string path step selects a field", function() assert.is_nil(run('"Red"[$$ = "Car"]', "Bus")) end) end) + +describe("M6b A-4: trailing ; in a block", function() + it("tolerates a trailing ; before )", function() + assert.are.equal(2, run("(1; 2;)")) + end) + it("( a; ) yields a", function() + assert.are.equal(1, run("(1;)")) + end) + it("empty () is unaffected", function() + assert.is_nil(run("()")) + end) +end) diff --git a/src/jsonata/parser.lua b/src/jsonata/parser.lua index 3f3a24b..b2055a9 100644 --- a/src/jsonata/parser.lua +++ b/src/jsonata/parser.lua @@ -203,6 +203,9 @@ do expressions[#expressions + 1] = p.expression(0) while p.node.id == ";" do p.advance() + if p.node.id == ")" then + break + end expressions[#expressions + 1] = p.expression(0) end end From e79fe8b6b5a608fc5950b30eb295329d160beb92 Mon Sep 17 00:00:00 2001 From: fl Date: Wed, 24 Jun 2026 19:27:33 +0800 Subject: [PATCH 3/5] feat(M6b): [] keepArray postfix (any step keeps the path's final array) Co-Authored-By: Claude Sonnet 4.6 --- spec/keeparray_spec.lua | 26 +++++++++++++++++++++++++- src/jsonata/evaluator.lua | 18 ++++++++++++++++-- src/jsonata/parser.lua | 11 ++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/spec/keeparray_spec.lua b/spec/keeparray_spec.lua index a1c80f3..154a798 100644 --- a/spec/keeparray_spec.lua +++ b/spec/keeparray_spec.lua @@ -10,7 +10,7 @@ describe("M6b A-2: quoted-string path step selects a field", function() it("a top-level string is still the literal", function() assert.are.equal("bar", run('"bar"', { foo = { bar = 7 } })) end) - it("foo.\"bar\" over a non-object foo is undefined (faithful)", function() + it('foo."bar" over a non-object foo is undefined (faithful)', function() assert.is_nil(run('foo."bar"', { foo = 5 })) end) it("a standalone string with a predicate stays a literal", function() @@ -30,3 +30,27 @@ describe("M6b A-4: trailing ; in a block", function() assert.is_nil(run("()")) end) end) + +describe("M6b []: keepArray (any step keeps the final array)", function() + it("a.b[] forces a single result into an array", function() + assert.are.same({ 1 }, run("a.b[]", { a = { b = 1 } })) + end) + it("a.b[] is a no-op on a multi-element result", function() + assert.are.same({ 1, 2 }, run("a.b[]", { a = { { b = 1 }, { b = 2 } } })) + end) + it("a mid-path [] keeps the final array (a[].b)", function() + assert.are.same({ 1 }, run("a[].b", { a = { b = 1 } })) + end) + it("a mid-path [] with a predicate keeps the final array (case037 shape)", function() + assert.are.same({ 1 }, run('x[k="a"][].v', { x = { { k = "a", v = 1 } } })) + end) + it("foo[] over a missing field is undefined, not []", function() + assert.is_nil(run("foo[]", {})) + end) + it("foo[][] is idempotent", function() + assert.are.same({ 5 }, run("foo[][]", { foo = 5 })) + end) + it("a normal path still unwraps a single result (regression)", function() + assert.are.equal(1, run("a.b", { a = { b = 1 } })) + end) +end) diff --git a/src/jsonata/evaluator.lua b/src/jsonata/evaluator.lua index 3eb81e6..128bee7 100644 --- a/src/jsonata/evaluator.lua +++ b/src/jsonata/evaluator.lua @@ -502,6 +502,19 @@ local function eval_path(node, input, env) return context end +-- True iff any step of this path (or of any nested path step) carries keepArray. +local function path_keeps_array(node) + for _, s in ipairs(node.steps) do + if s.keepArray then + return true + end + if s.steps and path_keeps_array(s) then + return true + end + end + return false +end + -- Singleton unwrapping applied at the boundary of path/array results. local function finalize_sequence(seq, keep_singleton) if #seq == 0 then @@ -750,6 +763,7 @@ local function _evaluate(node, input, env) end return V.obj_get(input, node.value) elseif t == "path" then + local keep = path_keeps_array(node) local tuple_mode = false for _, s in ipairs(node.steps) do if s.tuple then @@ -762,9 +776,9 @@ local function _evaluate(node, input, env) if node.tuple then return seq -- tuple stream for an enclosing tuple step; no unwrap end - return finalize_sequence(seq, false) + return finalize_sequence(seq, keep) end - return finalize_sequence(eval_path(node, input, env), false) + return finalize_sequence(eval_path(node, input, env), keep) elseif t == "array" then local arr = V.array({}) for _, e in ipairs(node.expressions) do diff --git a/src/jsonata/parser.lua b/src/jsonata/parser.lua index b2055a9..da6450a 100644 --- a/src/jsonata/parser.lua +++ b/src/jsonata/parser.lua @@ -260,6 +260,10 @@ do return { type = "array", expressions = expressions, position = t.position } end s.led = function(p, t, left) + if p.node.id == "]" then + p.advance() + return { type = "predicate", expr = left, keepArray = true, position = t.position } + end local filter = p.expression(0) if p.node.id ~= "]" then errors.raise("S0203", { position = p.node.position, token = "]" }) @@ -677,7 +681,6 @@ process_ast = function(ast, ctx) end if ast.type == "predicate" then local target = process_ast(ast.expr, ctx) - local filter = process_ast(ast.filter, ctx) local step, path if target.type == "path" then path = target @@ -686,6 +689,12 @@ process_ast = function(ast, ctx) step = target path = { type = "path", steps = { target }, position = ast.position } end + if ast.keepArray then + step.keepArray = true + push_ancestry(path, step) + return path + end + local filter = process_ast(ast.filter, ctx) if filter.seekingParent ~= nil then for _, slot in ipairs(filter.seekingParent) do if slot.level == 1 then From a7e50ba868aa663164925ffe5690b89afb033e69 Mon Sep 17 00:00:00 2001 From: fl Date: Wed, 24 Jun 2026 19:36:26 +0800 Subject: [PATCH 4/5] test(M6b): regenerate official-suite baseline ([] keepArray + parser fixes) Co-Authored-By: Claude Sonnet 4.6 --- spec/jsonata-suite/baseline.lua | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/spec/jsonata-suite/baseline.lua b/spec/jsonata-suite/baseline.lua index e473789..e3e6181 100644 --- a/spec/jsonata-suite/baseline.lua +++ b/spec/jsonata-suite/baseline.lua @@ -3,6 +3,7 @@ return { ["array-constructor/array-sequences/2"] = true, ["array-constructor/array-sequences/3"] = true, + ["array-constructor/array-sequences/4"] = true, ["array-constructor/case000"] = true, ["array-constructor/case001"] = true, ["array-constructor/case002"] = true, @@ -15,11 +16,13 @@ return { ["array-constructor/case009"] = true, ["array-constructor/case011"] = true, ["array-constructor/case012"] = true, + ["array-constructor/case015"] = true, ["array-constructor/case016"] = true, ["array-constructor/case017"] = true, ["array-constructor/case019"] = true, ["blocks/case000"] = true, ["blocks/case001"] = true, + ["blocks/case002"] = true, ["blocks/case003"] = true, ["blocks/case004"] = true, ["blocks/case005"] = true, @@ -36,6 +39,7 @@ return { ["boolean-expresssions/case009"] = true, ["boolean-expresssions/case010"] = true, ["boolean-expresssions/case011"] = true, + ["boolean-expresssions/case016"] = true, ["boolean-expresssions/case017"] = true, ["boolean-expresssions/case018"] = true, ["boolean-expresssions/case019"] = true, @@ -50,6 +54,7 @@ return { ["boolean-expresssions/case028"] = true, ["boolean-expresssions/case029"] = true, ["boolean-expresssions/case030"] = true, + ["closures/case000"] = true, ["closures/case001"] = true, ["coalescing-operator/case000"] = true, ["coalescing-operator/case001"] = true, @@ -124,6 +129,7 @@ return { ["context/case000"] = true, ["context/case001"] = true, ["context/case002"] = true, + ["context/case003"] = true, ["default-operator/case000"] = true, ["default-operator/case001"] = true, ["default-operator/case002"] = true, @@ -146,6 +152,7 @@ return { ["descendent-operator/case000"] = true, ["descendent-operator/case001"] = true, ["descendent-operator/case002"] = true, + ["descendent-operator/case004"] = true, ["descendent-operator/case006"] = true, ["descendent-operator/case007"] = true, ["descendent-operator/case008"] = true, @@ -189,6 +196,7 @@ return { ["fields/case007"] = true, ["flattening/array-inputs/0"] = true, ["flattening/array-inputs/1"] = true, + ["flattening/array-inputs/2"] = true, ["flattening/array-inputs/3"] = true, ["flattening/array-inputs/4"] = true, ["flattening/array-inputs/5"] = true, @@ -221,7 +229,15 @@ return { ["flattening/case034a"] = true, ["flattening/case035"] = true, ["flattening/case036"] = true, + ["flattening/case037"] = true, + ["flattening/case038"] = true, + ["flattening/case039"] = true, ["flattening/case040"] = true, + ["flattening/case041"] = true, + ["flattening/case042"] = true, + ["flattening/case043"] = true, + ["flattening/case044"] = true, + ["flattening/case045"] = true, ["flattening/sequence-of-arrays/0"] = true, ["flattening/sequence-of-arrays/1"] = true, ["function-abs/case000"] = true, @@ -251,6 +267,7 @@ return { ["function-applications/case015"] = true, ["function-applications/case016"] = true, ["function-applications/case017"] = true, + ["function-applications/case018"] = true, ["function-applications/case019"] = true, ["function-applications/case020"] = true, ["function-assert/case000"] = true, @@ -631,6 +648,7 @@ return { ["function-sort/case002"] = true, ["function-sort/case003"] = true, ["function-sort/case005"] = true, + ["function-sort/case006"] = true, ["function-sort/case007"] = true, ["function-sort/case008"] = true, ["function-sort/case009"] = true, @@ -759,7 +777,10 @@ return { ["hof-filter/case003"] = true, ["hof-map/case000"] = true, ["hof-map/case001"] = true, + ["hof-map/case0010"] = true, ["hof-map/case002"] = true, + ["hof-map/case003"] = true, + ["hof-map/case004"] = true, ["hof-map/case005"] = true, ["hof-map/case006"] = true, ["hof-map/case007"] = true, @@ -879,6 +900,8 @@ return { ["object-constructor/case005"] = true, ["object-constructor/case006"] = true, ["object-constructor/case007"] = true, + ["object-constructor/case008"] = true, + ["object-constructor/case009"] = true, ["object-constructor/case010"] = true, ["object-constructor/case011"] = true, ["object-constructor/case012"] = true, @@ -888,6 +911,8 @@ return { ["object-constructor/case016"] = true, ["object-constructor/case017"] = true, ["object-constructor/case018"] = true, + ["object-constructor/case019"] = true, + ["object-constructor/case020"] = true, ["object-constructor/case021"] = true, ["object-constructor/case022"] = true, ["object-constructor/case023"] = true, @@ -952,7 +977,11 @@ return { ["predicates/case001"] = true, ["predicates/case002"] = true, ["predicates/case003"] = true, + ["quoted-selectors/case000"] = true, + ["quoted-selectors/case001"] = true, + ["quoted-selectors/case003"] = true, ["quoted-selectors/case004"] = true, + ["quoted-selectors/case005"] = true, ["quoted-selectors/case006"] = true, ["quoted-selectors/case007"] = true, ["range-operator/case000"] = true, @@ -1037,6 +1066,8 @@ return { ["tail-recursion/case000"] = true, ["tail-recursion/case003"] = true, ["tail-recursion/case004"] = true, + ["token-conversion/case000"] = true, + ["token-conversion/case001"] = true, ["token-conversion/case002"] = true, ["transform/case000"] = true, ["transform/case001"] = true, @@ -1066,6 +1097,8 @@ return { ["transform/case025"] = true, ["transform/case026"] = true, ["transform/case027"] = true, + ["transform/case030"] = true, + ["transform/case031"] = true, ["transform/case032"] = true, ["transform/case033"] = true, ["transform/case034"] = true, @@ -1171,6 +1204,7 @@ return { ["wildcards/case004"] = true, ["wildcards/case005"] = true, ["wildcards/case006"] = true, + ["wildcards/case007"] = true, ["wildcards/case008"] = true, ["wildcards/case009"] = true, ["wildcards/case010/0"] = true, From ef953c30be78e478ae2076d4a3f039d178ef2b42 Mon Sep 17 00:00:00 2001 From: fl Date: Wed, 24 Jun 2026 19:51:54 +0800 Subject: [PATCH 5/5] fix(M6b): a first-position quoted-string path step is also a field selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i=1 in the path-handler string→name conversion; unblocks descendent-operator case003/005 and quoted-selectors case002 (+3 official). Obscure ("x").y paren-literal-head edge accepted (parser collapses the parens). --- spec/jsonata-suite/baseline.lua | 3 +++ spec/keeparray_spec.lua | 10 ++++++++++ src/jsonata/parser.lua | 5 ++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/spec/jsonata-suite/baseline.lua b/spec/jsonata-suite/baseline.lua index e3e6181..df552a1 100644 --- a/spec/jsonata-suite/baseline.lua +++ b/spec/jsonata-suite/baseline.lua @@ -152,7 +152,9 @@ return { ["descendent-operator/case000"] = true, ["descendent-operator/case001"] = true, ["descendent-operator/case002"] = true, + ["descendent-operator/case003"] = true, ["descendent-operator/case004"] = true, + ["descendent-operator/case005"] = true, ["descendent-operator/case006"] = true, ["descendent-operator/case007"] = true, ["descendent-operator/case008"] = true, @@ -979,6 +981,7 @@ return { ["predicates/case003"] = true, ["quoted-selectors/case000"] = true, ["quoted-selectors/case001"] = true, + ["quoted-selectors/case002"] = true, ["quoted-selectors/case003"] = true, ["quoted-selectors/case004"] = true, ["quoted-selectors/case005"] = true, diff --git a/spec/keeparray_spec.lua b/spec/keeparray_spec.lua index 154a798..f1c9d20 100644 --- a/spec/keeparray_spec.lua +++ b/spec/keeparray_spec.lua @@ -17,6 +17,16 @@ describe("M6b A-2: quoted-string path step selects a field", function() assert.are.equal("Red", run('"Red"[$$ = "Bus"]', "Bus")) assert.is_nil(run('"Red"[$$ = "Car"]', "Bus")) end) + it("a first-position quoted string in a path is also a field selector", function() + assert.are.equal(1, run('"x"."y"', { x = { y = 1 } })) + assert.are.equal(1, run('"x".y', { x = { y = 1 } })) + assert.are.equal(99, run('"a"."b"."c"', { a = { b = { c = 99 } } })) + end) + + it("a standalone string / parenthesized literal is unaffected for non-path uses", function() + assert.are.equal("bar", run('"bar"', {})) + assert.are.equal("Red", run('"Red"[$$ = "Bus"]', "Bus")) + end) end) describe("M6b A-4: trailing ; in a block", function() diff --git a/src/jsonata/parser.lua b/src/jsonata/parser.lua index da6450a..c6953e0 100644 --- a/src/jsonata/parser.lua +++ b/src/jsonata/parser.lua @@ -749,7 +749,10 @@ process_ast = function(ast, ctx) if ast.type == "binary" and ast.value == "." then local steps = {} flatten_path(ast, steps, ctx) - for i = 2, #steps do + -- A string step after the path head selects a field (jsonata: `"x".y` ≡ `x.y`). + -- i=1 also covers a first-position quoted string. (Edge: `("x").y` collapses to a + -- bare string in the parser and is wrongly treated as a field here — obscure, accepted.) + for i = 1, #steps do if steps[i].type == "string" then steps[i] = { type = "name", value = steps[i].value, position = steps[i].position } end