Skip to content

Commit cfc3b05

Browse files
committed
Add regex-based selectors to jsonpath
Related discussion: uBlockOrigin/uAssets#23076
1 parent 5b0f4da commit cfc3b05

1 file changed

Lines changed: 123 additions & 94 deletions

File tree

src/js/jsonpath.js

Lines changed: 123 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,14 @@
2424
* Implement the parsing of uBO-flavored JSON path queries.
2525
*
2626
* Reference to original JSON path syntax:
27-
* https://goessner.net/articles/JsonPath/index.html
27+
* https://www.rfc-editor.org/info/rfc9535/
2828
*
2929
* uBO-flavored JSON path implementation differs as follow:
30+
* - Array slice operator not supported
31+
* - Selectors can be a `/`-delimited regex, i.e. `$./pattern/`
32+
* - Onle a single filter selector is allowed in filter selector syntax
3033
*
31-
* - Both $ and @ are implicit. Though you can use them, you do not have to.
32-
* Use $ only when the implicit context is not that of root. Example:
33-
* - Official: $..book[?(@.isbn)]
34-
* - uBO-flavored: ..book[?(.isbn)]
35-
*
36-
* - uBO-flavor syntax does not (yet) support:
37-
* - Array slice operator
38-
*
39-
* - Regarding filter expressions, uBO-flavored JSON path supports a limited
34+
* - uBO-flavored JSON path supports a limited set of filter selectors:
4035
* set of expressions since unlike the official implementation, uBO can't use
4136
* JS eval() to evaluate arbitrary JS expressions. The operand MUST be valid
4237
* JSON. The currently supported expressions are:
@@ -50,6 +45,9 @@
5045
* - $=: stringified value ends with
5146
* - *=: stringified value includes
5247
* - =/.../: true if the regular expression matches the stringified value
48+
*
49+
* - uBO-flavored JSON path supports assignement operator:
50+
* - =[any well-formed JSON value]
5351
* - =repl(...): [to be documented] i.e. {"regex":"...","flags":"i","replacement":"..."}
5452
* - =call(...): [to be documented] i.e. ["${obj}","setAttribute","${key}"]
5553
* *
@@ -87,6 +85,10 @@ export class JSONPath {
8785
return (stringifier || JSON.stringify)(obj, ...args)
8886
.replace(/\//g, '\\/');
8987
}
88+
static keys = Object.keys;
89+
static entries = Object.entries;
90+
static hasOwn = Object.hasOwn;
91+
static Regex = RegExp;
9092
get value() {
9193
return this.#compiled && this.#compiled.rval;
9294
}
@@ -163,7 +165,7 @@ export class JSONPath {
163165
#CHILDREN = 3;
164166
#DESCENDANTS = 4;
165167
#reUnquotedIdentifier = /^[A-Za-z_][\w]*|^\*/;
166-
#reExpr = /^([!=^$*]=|[<>]=?)(.+?)\]/;
168+
#reExpr = /^\s*([!=^$*]=|[<>]=?)\s*(.+?)\]/;
167169
#reIndice = /^-?\d+/;
168170
#reRval = /^=([a-z]+)\((.+)\)$/;
169171
#root;
@@ -205,20 +207,21 @@ export class JSONPath {
205207
if ( mv === this.#UNDEFINED ) {
206208
const step = steps.at(-1);
207209
if ( step === undefined ) { return; }
208-
i = this.#compileExpr(query, step, i);
210+
const j = this.#compileExpr(query, step, i);
211+
if ( j ) { i = j; }
209212
break;
210213
}
211-
const s = this.#consumeUnquotedIdentifier(query, i);
212-
if ( s === undefined ) { return; }
213-
steps.push({ mv, k: s });
214-
i += s.length;
214+
const r = this.#consumeUnquotedIdentifier(query, i);
215+
if ( r === undefined ) { return; }
216+
steps.push({ mv, k: r.s });
217+
i = r.i;
215218
mv = this.#UNDEFINED;
216219
continue;
217220
}
218221
// Bracket accessor syntax
219222
if ( query.startsWith('[?', i) ) {
220-
const not = query.charCodeAt(i+2) === 0x21 /* ! */;
221-
const j = i + 2 + (not ? 1 : 0);
223+
const not = query.charCodeAt(i+2) === 0x21 /* ! */ ? 1 : 0;
224+
const j = i + 2 + not;
222225
const r = this.#compile(query, j);
223226
if ( r === undefined ) { return; }
224227
if ( query.startsWith(']', r.i) === false ) { return; }
@@ -255,12 +258,19 @@ export class JSONPath {
255258
resultset = [ [ '$' ] ];
256259
break;
257260
case this.#CURRENT:
261+
if ( step.op ) {
262+
const { obj, key } = this.#resolvePath(pathin);
263+
const outcome = this.#evaluateExpr(step, obj, key);
264+
if ( outcome !== true ) { break; }
265+
}
258266
resultset = [ pathin ];
259267
break;
260268
case this.#CHILDREN:
261-
case this.#DESCENDANTS:
269+
case this.#DESCENDANTS: {
270+
if ( resultset.length === 0 ) { break; }
262271
resultset = this.#getMatches(resultset, step);
263272
break;
273+
}
264274
default:
265275
break;
266276
}
@@ -271,50 +281,66 @@ export class JSONPath {
271281
const listout = [];
272282
for ( const pathin of listin ) {
273283
const { value: owner } = this.#resolvePath(pathin);
274-
if ( step.k === '*' ) {
275-
this.#getMatchesFromAll(pathin, step, owner, listout);
276-
} else if ( step.k !== undefined ) {
277-
this.#getMatchesFromKeys(pathin, step, owner, listout);
278-
} else if ( step.steps ) {
284+
if ( step.steps ) {
279285
this.#getMatchesFromExpr(pathin, step, owner, listout);
286+
continue;
287+
}
288+
const iter = this.#expandKey(owner, step.k);
289+
if ( iter ) {
290+
for ( const k of iter ) {
291+
const outcome = this.#evaluateExpr(step, owner, k);
292+
if ( outcome !== true ) { continue; }
293+
listout.push([ ...pathin, k ]);
294+
}
295+
}
296+
if ( step.mv !== this.#DESCENDANTS ) { continue; }
297+
for ( const { obj, key, path } of this.#getDescendants(owner, true) ) {
298+
const iter = this.#expandKey(obj[key], step.k);
299+
if ( iter === undefined ) { continue; }
300+
for ( const k of iter ) {
301+
const outcome = this.#evaluateExpr(step, obj[key], k);
302+
if ( outcome !== true ) { continue; }
303+
listout.push([ ...pathin, ...path, k ]);
304+
}
280305
}
281306
}
282307
return listout;
283308
}
284-
#getMatchesFromAll(pathin, step, owner, out) {
285-
const recursive = step.mv === this.#DESCENDANTS;
286-
for ( const { path } of this.#getDescendants(owner, recursive) ) {
287-
out.push([ ...pathin, ...path ]);
309+
#expandKey(owner, k) {
310+
if ( typeof owner !== 'object' ) { return; }
311+
if ( Array.isArray(k) ) {
312+
const out = [];
313+
for ( const a of k ) {
314+
const iter = this.#expandKey(owner, a);
315+
if ( iter === undefined ) { continue; }
316+
out.push(...iter);
317+
}
318+
return out;
288319
}
289-
}
290-
#getMatchesFromKeys(pathin, step, owner, out) {
291-
const kk = Array.isArray(step.k) ? step.k : [ step.k ];
292-
for ( const k of kk ) {
293-
const normalized = this.#evaluateExpr(step, owner, k);
294-
if ( normalized === undefined ) { continue; }
295-
out.push([ ...pathin, normalized ]);
320+
if ( typeof k === 'number' ) {
321+
if ( Array.isArray(owner) === false ) { return; }
322+
return [ k >= 0 ? k : owner.length + k ];
296323
}
297-
if ( step.mv !== this.#DESCENDANTS ) { return; }
298-
for ( const { obj, key, path } of this.#getDescendants(owner, true) ) {
299-
for ( const k of kk ) {
300-
const normalized = this.#evaluateExpr(step, obj[key], k);
301-
if ( normalized === undefined ) { continue; }
302-
out.push([ ...pathin, ...path, normalized ]);
324+
if ( k === '*' ) {
325+
if ( Array.isArray(owner) ) { return owner.keys(); }
326+
return JSONPath.keys(owner);
327+
}
328+
if ( k instanceof JSONPath.Regex ) {
329+
const out = [];
330+
for ( const key of JSONPath.keys(owner) ) {
331+
if ( k.test(key) === false ) { continue; }
332+
out.push(key);
303333
}
334+
return out;
304335
}
336+
return [ k ];
305337
}
306338
#getMatchesFromExpr(pathin, step, owner, out) {
307339
const recursive = step.mv === this.#DESCENDANTS;
308-
if ( Array.isArray(owner) === false ) {
309-
const r = this.#evaluate(step.steps, pathin);
310-
if ( r.length !== 0 ) { out.push(pathin); }
311-
if ( recursive !== true ) { return; }
312-
}
313-
for ( const { obj, key, path } of this.#getDescendants(owner, recursive) ) {
314-
if ( Array.isArray(obj[key]) ) { continue; }
340+
for ( const { path } of this.#getDescendants(owner, recursive) ) {
315341
const q = [ ...pathin, ...path ];
316342
const r = this.#evaluate(step.steps, q);
317-
if ( r.length === 0 ) { continue; }
343+
if ( Boolean(r?.length) === false ) { continue; }
318344
out.push(q);
319345
}
320346
}
@@ -345,7 +371,7 @@ export class JSONPath {
345371
if ( Array.isArray(v) ) {
346372
this.stack.push({ obj: v, keys: v.keys() });
347373
} else if ( typeof v === 'object' && v !== null ) {
348-
this.stack.push({ obj: v, keys: Object.keys(v).values() });
374+
this.stack.push({ obj: v, keys: JSONPath.keys(v).values() });
349375
}
350376
}
351377
return this;
@@ -359,7 +385,7 @@ export class JSONPath {
359385
if ( Array.isArray(v) ) {
360386
iterator.stack.push({ obj: v, keys: v.keys() });
361387
} else if ( typeof v === 'object' && v !== null ) {
362-
iterator.stack.push({ obj: v, keys: Object.keys(v).values() });
388+
iterator.stack.push({ obj: v, keys: JSONPath.keys(v).values() });
363389
}
364390
return iterator;
365391
}
@@ -368,7 +394,7 @@ export class JSONPath {
368394
for (;;) {
369395
const c0 = query.charCodeAt(i);
370396
if ( c0 === 0x5D /* ] */ ) { break; }
371-
if ( c0 === 0x2C /* , */ ) {
397+
if ( c0 === 0x2C /* , */ || c0 === 0x20 /* SPACE */) {
372398
i += 1;
373399
continue;
374400
}
@@ -387,17 +413,24 @@ export class JSONPath {
387413
i += match[0].length;
388414
continue;
389415
}
390-
const s = this.#consumeUnquotedIdentifier(query, i);
391-
if ( s === undefined ) { return; }
392-
keys.push(s);
393-
i += s.length;
416+
const r = this.#consumeUnquotedIdentifier(query, i);
417+
if ( r === undefined ) { return; }
418+
keys.push(r.s);
419+
i = r.i;
394420
}
395421
return { s: keys.length === 1 ? keys[0] : keys, i };
396422
}
397423
#consumeUnquotedIdentifier(query, i) {
424+
if ( query.charCodeAt(i) === 0x2F /* / */ ) {
425+
const r = this.#untilChar(query, 0x2F, i+1);
426+
if ( r === undefined ) { return; }
427+
let re;
428+
try { re = new JSONPath.Regex(r.s); } catch { return; }
429+
return { s: re, i: r.i };
430+
}
398431
const match = this.#reUnquotedIdentifier.exec(query.slice(i));
399432
if ( match === null ) { return; }
400-
return match[0];
433+
return { s: match[0], i: i + match[0].length };
401434
}
402435
#untilChar(query, targetCharCode, i) {
403436
const len = query.length;
@@ -429,22 +462,27 @@ export class JSONPath {
429462
if ( r === undefined ) { return i; }
430463
const match = /^[i]/.exec(query.slice(r.i));
431464
try {
432-
step.rval = new RegExp(r.s, match && match[0] || undefined);
433-
} catch {
434-
return i;
435-
}
465+
step.rval = new JSONPath.Regex(r.s, match && match[0] || undefined);
466+
} catch { return; }
436467
step.op = 're';
437468
if ( match ) { r.i += match[0].length; }
438469
return r.i;
439470
}
440471
const match = this.#reExpr.exec(query.slice(i));
441-
if ( match === null ) { return i; }
442-
try {
443-
step.rval = JSON.parse(match[2]);
444-
step.op = match[1];
445-
} catch {
472+
if ( match === null ) { return; }
473+
const op = match[1], rval = match[2];
474+
if ( rval.charCodeAt(0) === 0x27 /* ' */ ) {
475+
const r = this.#untilChar(rval, 0x27, 1);
476+
if ( r === undefined ) { return; }
477+
step.rval = r.s;
478+
step.op = op;
479+
} else {
480+
try {
481+
step.rval = JSON.parse(rval);
482+
step.op = op;
483+
} catch { return; }
446484
}
447-
return i + match[1].length + match[2].length;
485+
return i + match[0].length - 1;
448486
}
449487
#resolvePath(path) {
450488
if ( path.length === 0 ) { return { value: this.#root }; }
@@ -455,34 +493,26 @@ export class JSONPath {
455493
}
456494
return { obj, key, value: obj[key] };
457495
}
458-
#evaluateExpr(step, owner, key) {
496+
#evaluateExpr(step, owner, k) {
459497
if ( owner === undefined || owner === null ) { return; }
460-
let k;
461-
if ( typeof key === 'number' ) {
462-
if ( Array.isArray(owner) === false ) { return; }
463-
k = key >= 0 ? key : owner.length + key;
464-
} else {
465-
k = key;
466-
}
467-
const hasOwn = owner[k] !== undefined || Object.hasOwn(owner, k);
498+
const hasOwn = owner[k] !== undefined || JSONPath.hasOwn(owner, k);
468499
if ( step.op !== undefined && hasOwn === false ) { return; }
469500
const target = step.not !== true;
470501
const v = owner[k];
471-
let outcome = false;
472502
switch ( step.op ) {
473-
case '==': outcome = (v === step.rval) === target; break;
474-
case '!=': outcome = (v !== step.rval) === target; break;
475-
case '<': outcome = (v < step.rval) === target; break;
476-
case '<=': outcome = (v <= step.rval) === target; break;
477-
case '>': outcome = (v > step.rval) === target; break;
478-
case '>=': outcome = (v >= step.rval) === target; break;
479-
case '^=': outcome = `${v}`.startsWith(step.rval) === target; break;
480-
case '$=': outcome = `${v}`.endsWith(step.rval) === target; break;
481-
case '*=': outcome = `${v}`.includes(step.rval) === target; break;
482-
case 're': outcome = step.rval.test(`${v}`); break;
483-
default: outcome = hasOwn === target; break;
503+
case '==': return (v === step.rval) === target;
504+
case '!=': return (v !== step.rval) === target;
505+
case '<': return (v < step.rval) === target;
506+
case '<=': return (v <= step.rval) === target;
507+
case '>': return (v > step.rval) === target;
508+
case '>=': return (v >= step.rval) === target;
509+
case '^=': return `${v}`.startsWith(step.rval) === target;
510+
case '$=': return `${v}`.endsWith(step.rval) === target;
511+
case '*=': return `${v}`.includes(step.rval) === target;
512+
case 're': return step.rval.test(`${v}`);
513+
default: break;
484514
}
485-
if ( outcome ) { return k; }
515+
return hasOwn === target;
486516
}
487517
#modifyVal(obj, key) {
488518
let { modify, rval } = this.#compiled;
@@ -498,7 +528,7 @@ export class JSONPath {
498528
const lval = obj[key];
499529
if ( lval instanceof Object === false ) { return; }
500530
if ( Array.isArray(lval) ) { return; }
501-
for ( const [ k, v ] of Object.entries(rval) ) {
531+
for ( const [ k, v ] of JSONPath.entries(rval) ) {
502532
lval[k] = v;
503533
}
504534
break;
@@ -522,10 +552,9 @@ export class JSONPath {
522552
this.#compiled.re = null;
523553
try {
524554
this.#compiled.re = rval.regex !== undefined
525-
? new RegExp(rval.regex, rval.flags)
526-
: new RegExp(rval.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
527-
} catch {
528-
}
555+
? new JSONPath.Regex(rval.regex, rval.flags)
556+
: new JSONPath.Regex(rval.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
557+
} catch { }
529558
}
530559
if ( this.#compiled.re === null ) { return; }
531560
obj[key] = lval.replace(this.#compiled.re, rval.replacement);

0 commit comments

Comments
 (0)