Skip to content

Commit 9ba2bad

Browse files
committed
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
1 parent b8cbcbe commit 9ba2bad

2 files changed

Lines changed: 76 additions & 7 deletions

File tree

‎src/jsonpath.js‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,7 @@ class JSONPathClass {
707707
return retObj;
708708
} else if (loc === '$') { // root only
709709
addRet(this._trace(x, val, path, null, null, callback, hasArrExpr));
710-
} else if ((/^(-?\d*):(-?\d*):?(\d*)$/v).test(loc)) { // [start:end:step] Python slice syntax
710+
} else if ((/^(-?\d*):(-?\d*):?(-?\d*)$/v).test(loc)) { // [start:end:step] Python slice syntax
711711
const sliceResult = this._slice(
712712
loc, x, val, path, parent, parentPropName, callback
713713
);
@@ -984,14 +984,37 @@ class JSONPathClass {
984984
return undefined;
985985
}
986986
const len = val.length, parts = loc.split(':'),
987-
step = (parts[2] && Number(parts[2])) || 1;
988-
let start = (parts[0] && Number(parts[0])) || 0,
989-
end = parts[1] ? Number(parts[1]) : len;
990-
start = (start < 0) ? Math.max(0, start + len) : Math.min(len, start);
991-
end = (end < 0) ? Math.max(0, end + len) : Math.min(len, end);
987+
step = parts[2] ? Number(parts[2]) : 1;
992988
/** @type {ReturnObject[]} */
993989
const ret = [];
994-
for (let i = start; i < end; i += step) {
990+
// A step of zero selects nothing (RFC 9535 section 2.3.4.2.2)
991+
if (step === 0) {
992+
return ret;
993+
}
994+
/**
995+
* @param {number} index
996+
* @returns {number}
997+
*/
998+
const normalize = (index) => (index < 0 ? index + len : index);
999+
// With a negative step the defaults run backwards: from the last
1000+
// item to (and including) the first
1001+
const start = normalize(
1002+
parts[0] ? Number(parts[0]) : (step > 0 ? 0 : len - 1)
1003+
),
1004+
end = normalize(
1005+
parts[1] ? Number(parts[1]) : (step > 0 ? len : -len - 1)
1006+
);
1007+
// The first and last positions to visit, whichever way we are going
1008+
const [from, to] = step > 0
1009+
? [
1010+
Math.min(Math.max(start, 0), len),
1011+
Math.min(Math.max(end, 0), len)
1012+
]
1013+
: [
1014+
Math.min(Math.max(start, -1), len - 1),
1015+
Math.min(Math.max(end, -1), len - 1)
1016+
];
1017+
for (let i = from; step > 0 ? i < to : i > to; i += step) {
9951018
const tmp = this._trace(
9961019
unshift(i, expr),
9971020
val,

‎test/test.slice.js‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,49 @@ describe('JSONPath - slice', function () {
6464
}), []);
6565
});
6666
});
67+
68+
describe('JSONPath - slice with a negative step', function () {
69+
const small = [0, 1, 2, 3];
70+
const digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
71+
72+
// From RFC 9535 section 2.3.4 and the JSONPath compliance test suite
73+
/** @type {[string, number[], number[]][]} */
74+
const cases = [
75+
['$[::-1]', small, [3, 2, 1, 0]],
76+
['$[:0:-1]', small, [3, 2, 1]],
77+
['$[2::-1]', small, [2, 1, 0]],
78+
['$[::-2]', small, [3, 1]],
79+
['$[-1:-3:-1]', digits, [9, 8]],
80+
['$[-1:-6:-2]', digits, [9, 7, 5]],
81+
['$[-1:-7:-2]', digits, [9, 7, 5]],
82+
['$[-1:1:-1]', digits, [9, 8, 7, 6, 5, 4, 3, 2]],
83+
['$[7:-5:-1]', digits, [7, 6]],
84+
['$[9:0:-1]', digits, [9, 8, 7, 6, 5, 4, 3, 2, 1]],
85+
['$[113667776004:0:-1]', digits, [9, 8, 7, 6, 5, 4, 3, 2, 1]],
86+
['$[3:-113667776004:-1]', digits, [3, 2, 1, 0]],
87+
['$[-1:-10:-113667776004]', digits, [9]],
88+
['$[::-1]', [], []]
89+
];
90+
91+
for (const [path, json, expected] of cases) {
92+
it(`should evaluate ${path} on ${JSON.stringify(json)}`, function () {
93+
assert.deepEqual(jsonpath({json, path}), expected);
94+
});
95+
}
96+
97+
it('should reverse objects and report their paths', function () {
98+
const json = {children: [{a: 1}, {a: 2}, {a: 3}]};
99+
assert.deepEqual(
100+
jsonpath({json, path: '$.children[::-1].a'}), [3, 2, 1]
101+
);
102+
assert.deepEqual(
103+
jsonpath({json, path: '$.children[::-2]', resultType: 'path'}),
104+
["$['children'][2]", "$['children'][0]"]
105+
);
106+
});
107+
108+
it('should select nothing for a step of zero', function () {
109+
assert.deepEqual(jsonpath({json: digits, path: '$[1:2:0]'}), []);
110+
assert.deepEqual(jsonpath({json: digits, path: '$[::0]'}), []);
111+
});
112+
});

0 commit comments

Comments
 (0)