summaryrefslogtreecommitdiff
path: root/data/extensions/uBlock0@raymondhill.net/js/jsonpath.js
diff options
context:
space:
mode:
Diffstat (limited to 'data/extensions/uBlock0@raymondhill.net/js/jsonpath.js')
-rw-r--r--data/extensions/uBlock0@raymondhill.net/js/jsonpath.js470
1 files changed, 470 insertions, 0 deletions
diff --git a/data/extensions/uBlock0@raymondhill.net/js/jsonpath.js b/data/extensions/uBlock0@raymondhill.net/js/jsonpath.js
new file mode 100644
index 0000000..4f4bf1a
--- /dev/null
+++ b/data/extensions/uBlock0@raymondhill.net/js/jsonpath.js
@@ -0,0 +1,470 @@
+/*******************************************************************************
+
+ uBlock Origin - a comprehensive, efficient content blocker
+ Copyright (C) 2025-present Raymond Hill
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see {http://www.gnu.org/licenses/}.
+
+ Home: https://github.com/gorhill/uBlock
+
+*/
+
+/**
+ * Implement the parsing of uBO-flavored JSON path queries.
+ *
+ * Reference to original JSON path syntax:
+ * https://goessner.net/articles/JsonPath/index.html
+ *
+ * uBO-flavored JSON path implementation differs as follow:
+ *
+ * - Both $ and @ are implicit. Though you can use them, you do not have to.
+ * Use $ only when the implicit context is not that of root. Example:
+ * - Official: $..book[?(@.isbn)]
+ * - uBO-flavored: ..book[?(.isbn)]
+ *
+ * - uBO-flavor syntax does not (yet) support:
+ * - Array slice operator
+ *
+ * - Regarding filter expressions, uBO-flavored JSON path supports a limited
+ * set of expressions since unlike the official implementation, uBO can't use
+ * JS eval() to evaluate arbitrary JS expressions. The operand MUST be valid
+ * JSON. The currently supported expressions are:
+ * - ==: strict equality
+ * - !=: strict inequality
+ * - <: less than
+ * - <=: less than or equal to
+ * - >: greater than
+ * - >=: greater than or equal to
+ * - ^=: stringified value starts with
+ * - $=: stringified value ends with
+ * - *=: stringified value includes
+ *
+ * - Examples (from "JSONPath examples" at reference link)
+ * - .store.book[*].author
+ * - ..author
+ * - .store.*
+ * - .store..price
+ * - ..book[2]
+ * - ..book[?(.isbn)]
+ * - ..book[?(.price<10)]
+ * - ..*
+ *
+ * uBO-flavored syntax supports assigning a value to a resolved JSON path by
+ * appending `=[value]` to the JSON path query. The assigned value MUST be
+ * valid JSON. Examples:
+ * - .store..price=0
+ * - .store.book[*].author="redacted"
+ *
+ * A JSONPath instance can be use to compile a JSON path query, and the result
+ * of the compilation can be applied to different objects. When a JSON path
+ * query does not assign a value, the resolved property will be removed.
+ *
+ * More capabilities can be added in the future as needed.
+ *
+ * */
+
+export class JSONPath {
+ static create(query) {
+ const jsonp = new JSONPath();
+ jsonp.compile(query);
+ return jsonp;
+ }
+ static toJSON(obj, stringifier, ...args) {
+ return (stringifier || JSON.stringify)(obj, ...args)
+ .replace(/\//g, '\\/');
+ }
+ get value() {
+ return this.#compiled && this.#compiled.rval;
+ }
+ set value(v) {
+ if ( this.#compiled === undefined ) { return; }
+ this.#compiled.rval = v;
+ }
+ get valid() {
+ return this.#compiled !== undefined;
+ }
+ compile(query) {
+ this.#compiled = undefined;
+ const r = this.#compile(query, 0);
+ if ( r === undefined ) { return; }
+ if ( r.i !== query.length ) {
+ if ( query.startsWith('+=', r.i) ) {
+ r.modify = '+';
+ r.i += 1;
+ }
+ if ( query.startsWith('=', r.i) === false ) { return; }
+ try { r.rval = JSON.parse(query.slice(r.i+1)); }
+ catch { return; }
+ }
+ this.#compiled = r;
+ }
+ evaluate(root) {
+ if ( this.valid === false ) { return []; }
+ this.#root = root;
+ const paths = this.#evaluate(this.#compiled.steps, []);
+ this.#root = null;
+ return paths;
+ }
+ apply(root) {
+ if ( this.valid === false ) { return 0; }
+ const { modify, rval } = this.#compiled;
+ this.#root = root;
+ const paths = this.#evaluate(this.#compiled.steps, []);
+ const n = paths.length;
+ let i = n;
+ while ( i-- ) {
+ const { obj, key } = this.#resolvePath(paths[i]);
+ if ( rval !== undefined ) {
+ if ( modify === '+' ) {
+ this.#modifyVal(obj, key, rval);
+ } else {
+ obj[key] = rval;
+ }
+ } else if ( Array.isArray(obj) && typeof key === 'number' ) {
+ obj.splice(key, 1);
+ } else {
+ delete obj[key];
+ }
+ }
+ this.#root = null;
+ return n;
+ }
+ dump() {
+ return JSON.stringify(this.#compiled);
+ }
+ toJSON(obj, ...args) {
+ return JSONPath.toJSON(obj, null, ...args)
+ }
+ get [Symbol.toStringTag]() {
+ return 'JSONPath';
+ }
+ #UNDEFINED = 0;
+ #ROOT = 1;
+ #CURRENT = 2;
+ #CHILDREN = 3;
+ #DESCENDANTS = 4;
+ #reUnquotedIdentifier = /^[A-Za-z_][\w]*|^\*/;
+ #reExpr = /^([!=^$*]=|[<>]=?)(.+?)\]/;
+ #reIndice = /^-?\d+/;
+ #root;
+ #compiled;
+ #compile(query, i) {
+ if ( query.length === 0 ) { return; }
+ const steps = [];
+ let c = query.charCodeAt(i);
+ steps.push({ mv: c === 0x24 /* $ */ ? this.#ROOT : this.#CURRENT });
+ if ( c === 0x24 /* $ */ || c === 0x40 /* @ */ ) { i += 1; }
+ let mv = this.#UNDEFINED;
+ for (;;) {
+ if ( i === query.length ) { break; }
+ c = query.charCodeAt(i);
+ if ( c === 0x20 /* whitespace */ ) {
+ i += 1;
+ continue;
+ }
+ // Dot accessor syntax
+ if ( c === 0x2E /* . */ ) {
+ if ( mv !== this.#UNDEFINED ) { return; }
+ if ( query.startsWith('..', i) ) {
+ mv = this.#DESCENDANTS;
+ i += 2;
+ } else {
+ mv = this.#CHILDREN;
+ i += 1;
+ }
+ continue;
+ }
+ if ( c !== 0x5B /* [ */ ) {
+ if ( mv === this.#UNDEFINED ) {
+ const step = steps.at(-1);
+ if ( step === undefined ) { return; }
+ i = this.#compileExpr(query, step, i);
+ break;
+ }
+ const s = this.#consumeUnquotedIdentifier(query, i);
+ if ( s === undefined ) { return; }
+ steps.push({ mv, k: s });
+ i += s.length;
+ mv = this.#UNDEFINED;
+ continue;
+ }
+ // Bracket accessor syntax
+ if ( query.startsWith('[?', i) ) {
+ const not = query.charCodeAt(i+2) === 0x21 /* ! */;
+ const j = i + 2 + (not ? 1 : 0);
+ const r = this.#compile(query, j);
+ if ( r === undefined ) { return; }
+ if ( query.startsWith(']', r.i) === false ) { return; }
+ if ( not ) { r.steps.at(-1).not = true; }
+ steps.push({ mv: mv || this.#CHILDREN, steps: r.steps });
+ i = r.i + 1;
+ mv = this.#UNDEFINED;
+ continue;
+ }
+ if ( query.startsWith('[*]', i) ) {
+ mv ||= this.#CHILDREN;
+ steps.push({ mv, k: '*' });
+ i += 3;
+ mv = this.#UNDEFINED;
+ continue;
+ }
+ const r = this.#consumeIdentifier(query, i+1);
+ if ( r === undefined ) { return; }
+ mv ||= this.#CHILDREN;
+ steps.push({ mv, k: r.s });
+ i = r.i + 1;
+ mv = this.#UNDEFINED;
+ }
+ if ( steps.length <= 1 ) { return; }
+ return { steps, i };
+ }
+ #evaluate(steps, pathin) {
+ let resultset = [];
+ if ( Array.isArray(steps) === false ) { return resultset; }
+ for ( const step of steps ) {
+ switch ( step.mv ) {
+ case this.#ROOT:
+ resultset = [ [] ];
+ break;
+ case this.#CURRENT:
+ resultset = [ pathin ];
+ break;
+ case this.#CHILDREN:
+ case this.#DESCENDANTS:
+ resultset = this.#getMatches(resultset, step);
+ break;
+ default:
+ break;
+ }
+ }
+ return resultset;
+ }
+ #getMatches(listin, step) {
+ const listout = [];
+ for ( const pathin of listin ) {
+ const { value: owner } = this.#resolvePath(pathin);
+ if ( step.k === '*' ) {
+ this.#getMatchesFromAll(pathin, step, owner, listout);
+ } else if ( step.k !== undefined ) {
+ this.#getMatchesFromKeys(pathin, step, owner, listout);
+ } else if ( step.steps ) {
+ this.#getMatchesFromExpr(pathin, step, owner, listout);
+ }
+ }
+ return listout;
+ }
+ #getMatchesFromAll(pathin, step, owner, out) {
+ const recursive = step.mv === this.#DESCENDANTS;
+ for ( const { path } of this.#getDescendants(owner, recursive) ) {
+ out.push([ ...pathin, ...path ]);
+ }
+ }
+ #getMatchesFromKeys(pathin, step, owner, out) {
+ const kk = Array.isArray(step.k) ? step.k : [ step.k ];
+ for ( const k of kk ) {
+ const normalized = this.#evaluateExpr(step, owner, k);
+ if ( normalized === undefined ) { continue; }
+ out.push([ ...pathin, normalized ]);
+ }
+ if ( step.mv !== this.#DESCENDANTS ) { return; }
+ for ( const { obj, key, path } of this.#getDescendants(owner, true) ) {
+ for ( const k of kk ) {
+ const normalized = this.#evaluateExpr(step, obj[key], k);
+ if ( normalized === undefined ) { continue; }
+ out.push([ ...pathin, ...path, normalized ]);
+ }
+ }
+ }
+ #getMatchesFromExpr(pathin, step, owner, out) {
+ const recursive = step.mv === this.#DESCENDANTS;
+ if ( Array.isArray(owner) === false ) {
+ const r = this.#evaluate(step.steps, pathin);
+ if ( r.length !== 0 ) { out.push(pathin); }
+ if ( recursive !== true ) { return; }
+ }
+ for ( const { obj, key, path } of this.#getDescendants(owner, recursive) ) {
+ if ( Array.isArray(obj[key]) ) { continue; }
+ const q = [ ...pathin, ...path ];
+ const r = this.#evaluate(step.steps, q);
+ if ( r.length === 0 ) { continue; }
+ out.push(q);
+ }
+ }
+ #normalizeKey(owner, key) {
+ if ( typeof key === 'number' ) {
+ if ( Array.isArray(owner) ) {
+ return key >= 0 ? key : owner.length + key;
+ }
+ }
+ return key;
+ }
+ #getDescendants(v, recursive) {
+ const iterator = {
+ next() {
+ const n = this.stack.length;
+ if ( n === 0 ) {
+ this.value = undefined;
+ this.done = true;
+ return this;
+ }
+ const details = this.stack[n-1];
+ const entry = details.keys.next();
+ if ( entry.done ) {
+ this.stack.pop();
+ this.path.pop();
+ return this.next();
+ }
+ this.path[n-1] = entry.value;
+ this.value = {
+ obj: details.obj,
+ key: entry.value,
+ path: this.path.slice(),
+ };
+ const v = this.value.obj[this.value.key];
+ if ( recursive ) {
+ if ( Array.isArray(v) ) {
+ this.stack.push({ obj: v, keys: v.keys() });
+ } else if ( typeof v === 'object' && v !== null ) {
+ this.stack.push({ obj: v, keys: Object.keys(v).values() });
+ }
+ }
+ return this;
+ },
+ path: [],
+ value: undefined,
+ done: false,
+ stack: [],
+ [Symbol.iterator]() { return this; },
+ };
+ if ( Array.isArray(v) ) {
+ iterator.stack.push({ obj: v, keys: v.keys() });
+ } else if ( typeof v === 'object' && v !== null ) {
+ iterator.stack.push({ obj: v, keys: Object.keys(v).values() });
+ }
+ return iterator;
+ }
+ #consumeIdentifier(query, i) {
+ const keys = [];
+ for (;;) {
+ const c0 = query.charCodeAt(i);
+ if ( c0 === 0x5D /* ] */ ) { break; }
+ if ( c0 === 0x2C /* , */ ) {
+ i += 1;
+ continue;
+ }
+ if ( c0 === 0x27 /* ' */ ) {
+ const r = this.#consumeQuotedIdentifier(query, i+1);
+ if ( r === undefined ) { return; }
+ keys.push(r.s);
+ i = r.i;
+ continue;
+ }
+ if ( c0 === 0x2D /* - */ || c0 >= 0x30 && c0 <= 0x39 ) {
+ const match = this.#reIndice.exec(query.slice(i));
+ if ( match === null ) { return; }
+ const indice = parseInt(query.slice(i), 10);
+ keys.push(indice);
+ i += match[0].length;
+ continue;
+ }
+ const s = this.#consumeUnquotedIdentifier(query, i);
+ if ( s === undefined ) { return; }
+ keys.push(s);
+ i += s.length;
+ }
+ return { s: keys.length === 1 ? keys[0] : keys, i };
+ }
+ #consumeQuotedIdentifier(query, i) {
+ const len = query.length;
+ const parts = [];
+ let beg = i, end = i;
+ for (;;) {
+ if ( end === len ) { return; }
+ const c = query.charCodeAt(end);
+ if ( c === 0x27 /* ' */ ) {
+ parts.push(query.slice(beg, end));
+ end += 1;
+ break;
+ }
+ if ( c === 0x5C /* \ */ && (end+1) < len ) {
+ parts.push(query.slice(beg, end));
+ const d = query.chatCodeAt(end+1);
+ if ( d === 0x27 || d === 0x5C ) {
+ end += 1;
+ beg = end;
+ }
+ }
+ end += 1;
+ }
+ return { s: parts.join(''), i: end };
+ }
+ #consumeUnquotedIdentifier(query, i) {
+ const match = this.#reUnquotedIdentifier.exec(query.slice(i));
+ if ( match === null ) { return; }
+ return match[0];
+ }
+ #compileExpr(query, step, i) {
+ const match = this.#reExpr.exec(query.slice(i));
+ if ( match === null ) { return i; }
+ try {
+ step.rval = JSON.parse(match[2]);
+ step.op = match[1];
+ } catch {
+ }
+ return i + match[1].length + match[2].length;
+ }
+ #resolvePath(path) {
+ if ( path.length === 0 ) { return { value: this.#root }; }
+ const key = path.at(-1);
+ let obj = this.#root
+ for ( let i = 0, n = path.length-1; i < n; i++ ) {
+ obj = obj[path[i]];
+ }
+ return { obj, key, value: obj[key] };
+ }
+ #evaluateExpr(step, owner, key) {
+ if ( owner === undefined || owner === null ) { return; }
+ if ( typeof key === 'number' ) {
+ if ( Array.isArray(owner) === false ) { return; }
+ }
+ const k = this.#normalizeKey(owner, key);
+ const hasOwn = Object.hasOwn(owner, k);
+ if ( step.op !== undefined && hasOwn === false ) { return; }
+ const target = step.not !== true;
+ const v = owner[k];
+ let outcome = false;
+ switch ( step.op ) {
+ case '==': outcome = (v === step.rval) === target; break;
+ case '!=': outcome = (v !== step.rval) === target; break;
+ case '<': outcome = (v < step.rval) === target; break;
+ case '<=': outcome = (v <= step.rval) === target; break;
+ case '>': outcome = (v > step.rval) === target; break;
+ case '>=': outcome = (v >= step.rval) === target; break;
+ case '^=': outcome = `${v}`.startsWith(step.rval) === target; break;
+ case '$=': outcome = `${v}`.endsWith(step.rval) === target; break;
+ case '*=': outcome = `${v}`.includes(step.rval) === target; break;
+ default: outcome = hasOwn === target; break;
+ }
+ if ( outcome ) { return k; }
+ }
+ #modifyVal(obj, key, rval) {
+ const lval = obj[key];
+ if ( rval instanceof Object === false ) { return; }
+ if ( lval instanceof Object === false ) { return; }
+ if ( Array.isArray(lval) ) { return; }
+ for ( const [ k, v ] of Object.entries(rval) ) {
+ lval[k] = v;
+ }
+ }
+}