Source: helpers/jsonpath.js

/**
 * Simplified JSONPath implementation
 *
 * This is designed to make it easier to modify part of a LocusZoom layout, using a syntax based on intent
 *   ("modify association panels") rather than hard-coded assumptions ("modify the first button, and gosh I hope the order doesn't change")
 *
 * This DOES NOT support the full JSONPath specification. Notable limitations:
 * - Arrays can only be indexed by filter expression, not by number (can't ask for "array item 1")
 * - Filter expressions support only exact match, `field === value`. There is no support for "and" statements or
 *  arbitrary JS expressions beyond a single exact comparison. (the parser may be improved in the future if use cases emerge)
 *
 * @module
 * @private
 */

const ATTR_REGEX = /^(\*|[\w]+)/; // attribute names can be wildcard or valid variable names
const EXPR_REGEX = /^\[\?\(@((?:\.[\w]+)+) *===? *([0-9.eE-]+|"[^"]*"|'[^']*')\)\]/;  // Arrays can be indexed using filter expressions like `[?(@.id === value)]` where value is a number or a single-or-double quoted string

function get_next_token(q) {
    // This just grabs everything that looks good.
    // The caller should check that the remaining query is valid.
    if (q.substr(0, 2) === '..') {
        if (q[2] === '[') {
            return {
                text: '..',
                attr: '*',
                depth: '..',
            };
        }
        const m = ATTR_REGEX.exec(q.substr(2));
        if (!m) {
            throw `Cannot parse ${JSON.stringify(q)} as dotdot_attr.`;
        }
        return {
            text: `..${m[0]}`,
            attr: m[1],
            depth: '..',
        };
    } else if (q[0] === '.') {
        const m = ATTR_REGEX.exec(q.substr(1));
        if (!m) {
            throw `Cannot parse ${JSON.stringify(q)} as dot_attr.`;
        }
        return {
            text: `.${m[0]}`,
            attr: m[1],
            depth: '.',
        };
    } else if (q[0] === '[') {
        const m = EXPR_REGEX.exec(q);
        if (!m) {
            throw `Cannot parse ${JSON.stringify(q)} as expr.`;
        }
        let value;
        try {
            // Parse strings and numbers
            value = JSON.parse(m[2]);
        } catch (e) {
            // Handle single-quoted strings
            value = JSON.parse(m[2].replace(/^'|'$/g, '"'));
        }

        return {
            text: m[0],
            attrs: m[1].substr(1).split('.'),
            value,
        };
    } else {
        throw `The query ${JSON.stringify(q)} doesn't look valid.`;
    }
}

function normalize_query(q) {
    // Normalize the start of the query so that it's just a bunch of selectors one-after-another.
    // Otherwise the first selector is a little different than the others.
    if (!q) {
        return '';
    }
    if (!['$', '['].includes(q[0])) {
        q = `$.${  q}`;
    } // It starts with a dotless attr, so prepend the implied `$.`.
    if (q[0] === '$') {
        q = q.substr(1);
    }  // strip the leading $
    return q;
}

function tokenize (q) {
    q = normalize_query(q);
    let selectors = [];
    while (q.length) {
        const selector = get_next_token(q);
        q = q.substr(selector.text.length);
        selectors.push(selector);
    }
    return selectors;
}

/**
 * Fetch the attribute from a dotted path inside a nested object, eg `extract_path({k:['a','b']}, ['k', 1])` would retrieve `'b'`
 *
 * This function returns a three item array `[parent, key, object]`. This is done to support mutating the value, which requires access to the parent.
 *
 * @param obj
 * @param path
 * @returns {Array}
 */
function get_item_at_deep_path(obj, path) {
    let parent;
    for (let key of path) {
        parent = obj;
        obj = obj[key];
    }
    return [parent, path[path.length - 1], obj];
}

function tokens_to_keys(data, selectors) {
    // Resolve the jsonpath query into full path specifier keys in the object, eg
    //  `$..data_layers[?(@.tag === 'association)].color
    //  would become
    // ["panels", 0, "data_layers", 1, "color"]
    if (!selectors.length) {
        return [[]];
    }
    const sel = selectors[0];
    const remaining_selectors = selectors.slice(1);
    let paths = [];

    if (sel.attr && sel.depth === '.' && sel.attr !== '*') { // .attr
        const d = data[sel.attr];
        if (selectors.length === 1) {
            if (d !== undefined) {
                paths.push([sel.attr]);
            }
        } else {
            paths.push(...tokens_to_keys(d, remaining_selectors).map((p) => [sel.attr].concat(p)));
        }
    } else if (sel.attr && sel.depth === '.' && sel.attr === '*') { // .*
        for (let [k, d] of Object.entries(data)) {
            paths.push(...tokens_to_keys(d, remaining_selectors).map((p) => [k].concat(p)));
        }
    } else if (sel.attr && sel.depth === '..') { // ..
        // If `sel.attr` matches, recurse with that match.
        // And also recurse on every value using unchanged selectors.
        // I bet `..*..*` duplicates results, so don't do it please.
        if (typeof data === 'object' && data !== null) {
            if (sel.attr !== '*' && sel.attr in data) { // Exact match!
                paths.push(...tokens_to_keys(data[sel.attr], remaining_selectors).map((p) => [sel.attr].concat(p)));
            }
            for (let [k, d] of Object.entries(data)) {
                paths.push(...tokens_to_keys(d, selectors).map((p) => [k].concat(p))); // No match, just recurse
                if (sel.attr === '*') { // Wildcard match
                    paths.push(...tokens_to_keys(d, remaining_selectors).map((p) => [k].concat(p)));
                }
            }
        }
    } else if (sel.attrs) { // [?(@.attr===value)]
        for (let [k, d] of Object.entries(data)) {
            const [_, __, subject] = get_item_at_deep_path(d, sel.attrs);
            if (subject === sel.value) {
                paths.push(...tokens_to_keys(d, remaining_selectors).map((p) => [k].concat(p)));
            }
        }
    }

    const uniqPaths = uniqBy(paths, JSON.stringify); // dedup
    uniqPaths.sort((a, b) => b.length - a.length || JSON.stringify(a).localeCompare(JSON.stringify(b))); // sort longest-to-shortest, breaking ties lexicographically
    return uniqPaths;
}

function uniqBy(arr, key) {
    // Sometimes, the process of resolving paths to selectors returns duplicate results. This returns only the unique paths.
    return [...new Map(arr.map((elem) => [key(elem), elem])).values()];
}

function get_items_from_tokens(data, selectors) {
    let items = [];
    for (let path of tokens_to_keys(data, selectors)) {
        items.push(get_item_at_deep_path(data, path));
    }
    return items;
}

/**
 * Perform a query, and return the item + its parent context
 * @param data
 * @param query
 * @returns {Array}
 * @private
 */
function _query(data, query) {
    const tokens = tokenize(query);

    const matches = get_items_from_tokens(data, tokens);
    if (!matches.length) {
        console.warn(`No items matched the specified query: '${query}'`);
    }
    return matches;
}

/**
 * Fetch the value(s) for each possible match for a given query. Returns only the item values.
 * @param {object} data The data object to query
 * @param {string} query A JSONPath-compliant query string
 * @returns {Array}
 */
function query(data, query) {
    return _query(data, query).map((item) => item[2]);
}

/**
 * Modify the value(s) for each possible match for a given jsonpath query. Returns the new item values.
 * @param {object} data The data object to query
 * @param {string} query A JSONPath-compliant query string
 * @param {function|*} value_or_callback The new value for the specified field. Mutations will only be applied
 *  after the keys are resolved; this prevents infinite recursion, but could invalidate some matches
 *  (if the mutation removed the expected key).
 */
function mutate(data, query, value_or_callback) {
    const matches_in_context = _query(data, query);
    return matches_in_context.map(([parent, key, old_value]) => {
        const new_value = (typeof value_or_callback === 'function') ? value_or_callback(old_value) : value_or_callback;
        parent[key] = new_value;
        return new_value;
    });
}

export {mutate, query};