From 9ba2bad0d7e2742bb256e413849e864b8ce3f4ee Mon Sep 17 00:00:00 2001 From: simonkundrik <117163790+simonkundrik@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:03:35 +0000 Subject: [PATCH] feat(slice): support negative steps like [::-1] The slice selector only handled a positive step: the check that routes [start:end:step] to _slice allowed only digits after the second colon, so [::-1] never reached it, and a step of 0 was silently turned into 1. Slices now follow RFC 9535 section 2.3.4.2.2. A negative step walks backwards, with the last item as the default start and the first item as the default end, and a step of 0 selects nothing. Positive steps behave as before. Fixes #176 --- src/jsonpath.js | 37 ++++++++++++++++++++++++++++++------- test/test.slice.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/jsonpath.js b/src/jsonpath.js index d7b16a5d..4d036ee7 100644 --- a/src/jsonpath.js +++ b/src/jsonpath.js @@ -707,7 +707,7 @@ class JSONPathClass { return retObj; } else if (loc === '$') { // root only addRet(this._trace(x, val, path, null, null, callback, hasArrExpr)); - } else if ((/^(-?\d*):(-?\d*):?(\d*)$/v).test(loc)) { // [start:end:step] Python slice syntax + } else if ((/^(-?\d*):(-?\d*):?(-?\d*)$/v).test(loc)) { // [start:end:step] Python slice syntax const sliceResult = this._slice( loc, x, val, path, parent, parentPropName, callback ); @@ -984,14 +984,37 @@ class JSONPathClass { return undefined; } const len = val.length, parts = loc.split(':'), - step = (parts[2] && Number(parts[2])) || 1; - let start = (parts[0] && Number(parts[0])) || 0, - end = parts[1] ? Number(parts[1]) : len; - start = (start < 0) ? Math.max(0, start + len) : Math.min(len, start); - end = (end < 0) ? Math.max(0, end + len) : Math.min(len, end); + step = parts[2] ? Number(parts[2]) : 1; /** @type {ReturnObject[]} */ const ret = []; - for (let i = start; i < end; i += step) { + // A step of zero selects nothing (RFC 9535 section 2.3.4.2.2) + if (step === 0) { + return ret; + } + /** + * @param {number} index + * @returns {number} + */ + const normalize = (index) => (index < 0 ? index + len : index); + // With a negative step the defaults run backwards: from the last + // item to (and including) the first + const start = normalize( + parts[0] ? Number(parts[0]) : (step > 0 ? 0 : len - 1) + ), + end = normalize( + parts[1] ? Number(parts[1]) : (step > 0 ? len : -len - 1) + ); + // The first and last positions to visit, whichever way we are going + const [from, to] = step > 0 + ? [ + Math.min(Math.max(start, 0), len), + Math.min(Math.max(end, 0), len) + ] + : [ + Math.min(Math.max(start, -1), len - 1), + Math.min(Math.max(end, -1), len - 1) + ]; + for (let i = from; step > 0 ? i < to : i > to; i += step) { const tmp = this._trace( unshift(i, expr), val, diff --git a/test/test.slice.js b/test/test.slice.js index 5034642c..637668b4 100644 --- a/test/test.slice.js +++ b/test/test.slice.js @@ -64,3 +64,49 @@ describe('JSONPath - slice', function () { }), []); }); }); + +describe('JSONPath - slice with a negative step', function () { + const small = [0, 1, 2, 3]; + const digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + + // From RFC 9535 section 2.3.4 and the JSONPath compliance test suite + /** @type {[string, number[], number[]][]} */ + const cases = [ + ['$[::-1]', small, [3, 2, 1, 0]], + ['$[:0:-1]', small, [3, 2, 1]], + ['$[2::-1]', small, [2, 1, 0]], + ['$[::-2]', small, [3, 1]], + ['$[-1:-3:-1]', digits, [9, 8]], + ['$[-1:-6:-2]', digits, [9, 7, 5]], + ['$[-1:-7:-2]', digits, [9, 7, 5]], + ['$[-1:1:-1]', digits, [9, 8, 7, 6, 5, 4, 3, 2]], + ['$[7:-5:-1]', digits, [7, 6]], + ['$[9:0:-1]', digits, [9, 8, 7, 6, 5, 4, 3, 2, 1]], + ['$[113667776004:0:-1]', digits, [9, 8, 7, 6, 5, 4, 3, 2, 1]], + ['$[3:-113667776004:-1]', digits, [3, 2, 1, 0]], + ['$[-1:-10:-113667776004]', digits, [9]], + ['$[::-1]', [], []] + ]; + + for (const [path, json, expected] of cases) { + it(`should evaluate ${path} on ${JSON.stringify(json)}`, function () { + assert.deepEqual(jsonpath({json, path}), expected); + }); + } + + it('should reverse objects and report their paths', function () { + const json = {children: [{a: 1}, {a: 2}, {a: 3}]}; + assert.deepEqual( + jsonpath({json, path: '$.children[::-1].a'}), [3, 2, 1] + ); + assert.deepEqual( + jsonpath({json, path: '$.children[::-2]', resultType: 'path'}), + ["$['children'][2]", "$['children'][0]"] + ); + }); + + it('should select nothing for a step of zero', function () { + assert.deepEqual(jsonpath({json: digits, path: '$[1:2:0]'}), []); + assert.deepEqual(jsonpath({json: digits, path: '$[::0]'}), []); + }); +});