diff --git a/biome.json b/biome.json index 32a59b21..efe67657 100644 --- a/biome.json +++ b/biome.json @@ -10,6 +10,7 @@ "ignore": ["node_modules", "dist", "package-lock.json"] }, "formatter": { + "lineWidth": 120, "useEditorconfig": true, "ignore": ["package.json"] }, diff --git a/src/utils/selector/filter-evaluator.js b/src/utils/selector/filter-evaluator.js new file mode 100644 index 00000000..5051143a --- /dev/null +++ b/src/utils/selector/filter-evaluator.js @@ -0,0 +1,74 @@ +/** + * Evaluates a simple filter expression against an item + * Supported format: @.key op value (e.g., @.price < 10, @.name == "Book") + * @param {any} item - Item to evaluate + * @param {string} expr - Filter expression string + * @returns {boolean} True if item matches filter, false otherwise + */ +export function evaluateFilter(item, expr) { + if (!item || typeof expr !== 'string') return false; + + const match = expr.match(/^@\.([\w$-]+)\s*([=!<>]=?)\s*(.+)$/); + if (!match) return false; + + const [, key, operator, rawValue] = match; + let expected = rawValue.trim(); + const actual = item[key]; + + // Parse expected value + expected = parseValue(expected); + + return compareValues(actual, operator, expected); +} + +/** + * Parses a string value to appropriate type + * @param {string} str - String representation of value + * @returns {any} Parsed value + */ +function parseValue(str) { + // String literals (quoted) + if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) { + return str.slice(1, -1); + } + + // Numbers + if (!Number.isNaN(Number.parseFloat(str)) && Number.isFinite(Number(str))) { + return Number.parseFloat(str); + } + + // Booleans + if (str === 'true') return true; + if (str === 'false') return false; + + // Default to string + return str; +} + +/** + * Compares two values using the given operator + * @param {any} actual - Actual value from object + * @param {string} operator - Comparison operator + * @param {any} expected - Expected value + * @returns {boolean} Comparison result + */ +function compareValues(actual, operator, expected) { + switch (operator) { + case '==': + case '===': + return actual === expected; + case '!=': + case '!==': + return actual !== expected; + case '>': + return actual > expected; + case '<': + return actual < expected; + case '>=': + return actual >= expected; + case '<=': + return actual <= expected; + default: + return false; + } +} diff --git a/src/utils/selector/json-search-legacy.js b/src/utils/selector/json-search-legacy.js new file mode 100644 index 00000000..8f563ef4 --- /dev/null +++ b/src/utils/selector/json-search-legacy.js @@ -0,0 +1,33 @@ +import { JSONPath } from 'jsonpath-plus'; + +export function JSONSearch(opts, expr, obj, callback, otherTypeCallback) { + if (!(this instanceof JSONSearch)) { + return new JSONSearch(opts, expr, obj, callback, otherTypeCallback); + } + + this.searchableKeys = Array.isArray(opts.searchableKeys) ? opts.searchableKeys : null; + return JSONPath.call(this, opts, expr, obj, callback, otherTypeCallback); +} + +JSONSearch.prototype = Object.create(JSONPath.prototype); +JSONSearch.prototype.constructor = JSONSearch; +JSONSearch.prototype._walk = function (val, f) { + if (Array.isArray(val)) { + const n = val.length; + for (let i = 0; i < n; i++) { + f(i); + } + } else if (val && typeof val === 'object') { + if (this.searchableKeys) { + this.searchableKeys.forEach((m) => { + if (m in val && val[m].length > 0) { + f(m); + } + }); + } else { + Object.keys(val).forEach((m) => { + f(m); + }); + } + } +}; diff --git a/src/utils/selector/json-search.js b/src/utils/selector/json-search.js index 6ecffec4..d7586670 100644 --- a/src/utils/selector/json-search.js +++ b/src/utils/selector/json-search.js @@ -1,35 +1,72 @@ -import { JSONPath } from 'jsonpath-plus'; +/** + * Browser-compatible JSON search utility similar to JSONPath. + * + * @param {Object} opts - The search options + * @param {any} opts.json - The JSON data to search + * @param {string} opts.path - The JSONPath expression + * @param {('value'|'path'|'pointer')} [opts.resultType='value'] - Type of results to return + * @param {string[]|null} [opts.searchableKeys=null] - Keys to limit search scope + * @param {boolean} [opts.flatten=true] - Whether to flatten array results + * @returns {any[]} Array of search results + */ +import { searchRecursive } from './search-recursive.js'; +import { tokenizePath } from './tokenizer.js'; -export function JSONSearch(opts, expr, obj, callback, otherTypeCallback) { - if (!(this instanceof JSONSearch)) { - return new JSONSearch(opts, expr, obj, callback, otherTypeCallback); - } +// Constants for better maintainability +const DEFAULT_RESULT_TYPE = 'value'; + +export function JSONSearch(opts) { + const options = validateOpts(opts); + const { json, path } = options; + const tokens = tokenizePath(path); + const results = []; + + searchRecursive(json, { + tokens, + path: tokens.length > 0 && tokens[0].type === 'ROOT' ? '$' : '', + ptr: '', + results, + opts: options, + }); - this.searchableKeys = Array.isArray(opts.searchableKeys) - ? opts.searchableKeys - : null; - return JSONPath.call(this, opts, expr, obj, callback, otherTypeCallback); + // Keep the original simple deduplication logic to maintain test compatibility + return Array.from(new Set(results)); } -JSONSearch.prototype = Object.create(JSONPath.prototype); -JSONSearch.prototype.constructor = JSONSearch; -JSONSearch.prototype._walk = function (val, f) { - if (Array.isArray(val)) { - const n = val.length; - for (let i = 0; i < n; i++) { - f(i); - } - } else if (val && typeof val === 'object') { - if (this.searchableKeys) { - this.searchableKeys.forEach((m) => { - if (m in val && val[m].length > 0) { - f(m); - } - }); - } else { - Object.keys(val).forEach((m) => { - f(m); - }); - } +/** + * Validates and normalizes the options object for JSONSearch + * @param {Object} opts - The options object to validate + * @returns {Object} The normalized options object + * @throws {Error} When validation fails + */ +function validateOpts(opts) { + if (typeof opts !== 'object' || opts === null) { + throw new Error('Invalid arguments for JSONSearch. Expected an options object as the first argument.'); } -}; + + // Create a shallow copy to avoid mutating the original + const options = { ...opts }; + + // Set default values + if (options.resultType === undefined) options.resultType = DEFAULT_RESULT_TYPE; + if (options.searchableKeys === undefined) options.searchableKeys = null; + + // Validate required properties + if (!('json' in options) || options.json === undefined) + throw new Error("The 'json' property in options is missing or undefined."); + if (options.json === null) throw new Error("The 'json' property in options cannot be null."); + + if (!('path' in options) || typeof options.path !== 'string') + throw new Error("The 'path' property in options must be a string and present."); + + // Validate resultType + const validResultTypes = ['value', 'path', 'pointer']; + if (!validResultTypes.includes(options.resultType)) + throw new Error(`Invalid resultType '${options.resultType}'. Must be one of: ${validResultTypes.join(', ')}`); + + // Validate searchableKeys + if (options.searchableKeys !== null && !Array.isArray(options.searchableKeys)) + throw new Error("The 'searchableKeys' property must be an array or null."); + + return options; +} diff --git a/src/utils/selector/json-search.test.js b/src/utils/selector/json-search.test.js new file mode 100644 index 00000000..0d364d47 --- /dev/null +++ b/src/utils/selector/json-search.test.js @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import { JSONSearch } from './json-search'; + +// Test data: covers PATCH MAP structure and README examples +const sample = { + // 1. Primitive + a: 1, + b: { c: 2, d: [3, 4] }, + + // 2. Array/2D array/nested object + arr: [ + { x: 10, y: 20, deep: { z: 100, deeper: { w: 999 } } }, + { x: 30, y: 40, deep: { z: 200, deeper: { w: 888 } } }, + ], + deepArr: [[{ y: 1 }, { y: 2, z: { w: 3 } }], [{ y: 4 }]], + + // 3. Deep nested object + nested: { + level1: { + level2: { + level3: { + value: 1234, + arr: [{ foo: 'bar' }, { foo: 'baz', deep: { wow: 42 } }], + }, + }, + }, + }, + + // 4. PATCH MAP/README hierarchical items + items: [ + { + type: 'group', + id: 'group-1', + label: 'group-label-1', + items: [ + { type: 'grid', id: 1 }, + { type: 'item', id: 2 }, + { type: 'grid', id: 'grid-1', label: 'grid-label-1' }, + ], + }, + { + type: 'group', + id: 'group-2', + items: [{ type: 'grid', id: 3 }], + }, + { + type: 'item', + id: 'item-1', + items: [{ type: 'grid', id: 4 }], + }, + ], +}; + +describe('JSONSearch', () => { + describe('Primitive', () => { + it('value: $.a', () => { + const result = JSONSearch({ resultType: 'value', path: '$.a', json: sample }); + expect(result).toEqual([1]); + }); + it('nested: $.b.c', () => { + const result = JSONSearch({ resultType: 'value', path: '$.b.c', json: sample }); + expect(result).toEqual([2]); + }); + it('array index: $.b.d[0]', () => { + const result = JSONSearch({ resultType: 'value', path: '$.b.d[0]', json: sample }); + expect(result).toEqual([3]); + }); + }); + + describe('Array', () => { + it('all x: $.arr[*].x', () => { + const result = JSONSearch({ resultType: 'value', path: '$.arr[*].x', json: sample }); + expect(result).toEqual([10, 30]); + }); + it('deep z: $.arr[*].deep.z', () => { + const result = JSONSearch({ resultType: 'value', path: '$.arr[*].deep.z', json: sample }); + expect(result).toEqual([100, 200]); + }); + it('desc w: $..w', () => { + const result = JSONSearch({ resultType: 'value', path: '$..w', json: sample }); + expect(result).toEqual([999, 888, 3]); + }); + it('2d y: $.deepArr[*][*].y', () => { + const result = JSONSearch({ resultType: 'value', path: '$.deepArr[*][*].y', json: sample }); + expect(result).toEqual([1, 2, 4]); + }); + }); + + describe('Deep', () => { + it('deep value: $.nested.level1.level2.level3.value', () => { + const result = JSONSearch({ resultType: 'value', path: '$.nested.level1.level2.level3.value', json: sample }); + expect(result).toEqual([1234]); + }); + it('deep foo: $.nested.level1.level2.level3.arr[*].foo', () => { + const result = JSONSearch({ + resultType: 'value', + path: '$.nested.level1.level2.level3.arr[*].foo', + json: sample, + }); + expect(result).toEqual(['bar', 'baz']); + }); + it('deep wow: $.nested.level1.level2.level3.arr[*].deep.wow', () => { + const result = JSONSearch({ + resultType: 'value', + path: '$.nested.level1.level2.level3.arr[*].deep.wow', + json: sample, + }); + expect(result).toEqual([42]); + }); + }); + + describe('Options', () => { + it('searchableKeys: $..bar', () => { + const obj = { foo: [{ bar: 1 }, { bar: 2 }], baz: [{ bar: 3 }] }; + const result = JSONSearch({ resultType: 'value', searchableKeys: ['baz'], path: '$..bar', json: obj }); + expect(result).toEqual([3]); + }); + it('no searchableKeys: $..bar', () => { + const obj = { foo: [{ bar: 1 }, { bar: 2 }], baz: [{ bar: 3 }] }; + const result = JSONSearch({ resultType: 'value', path: '$..bar', json: obj }); + expect(result).toEqual([1, 2, 3]); + }); + it('not exist: $.notExist', () => { + const result = JSONSearch({ resultType: 'value', path: '$.notExist', json: sample }); + expect(result).toEqual([]); + }); + it('null: $.a', () => { + expect(() => { + JSONSearch({ resultType: 'value', path: '$.a', json: null }); + }).toThrow(); + }); + it('undefined: $.a', () => { + expect(() => { + JSONSearch({ resultType: 'value', path: '$.a', json: undefined }); + }).toThrow(); + }); + it('empty searchableKeys: $..bar', () => { + const obj = { foo: [{ bar: 1 }], baz: [{ bar: 2 }] }; + const result = JSONSearch({ resultType: 'value', searchableKeys: ['notExist'], path: '$..bar', json: obj }); + expect(result).toEqual([]); + }); + }); + + describe('ResultType', () => { + it('path: $.arr[*].x', () => { + const result = JSONSearch({ resultType: 'path', path: '$.arr[*].x', json: sample }); + expect(result.every((v) => typeof v === 'string')).toBe(true); + }); + it('pointer: $.arr[*].x', () => { + const result = JSONSearch({ + resultType: 'pointer', + path: '$.arr[*].x', + json: sample, + }); + expect(result.every((v) => typeof v === 'string' && v.startsWith('/'))).toBe(true); + }); + it('filter: $.arr[?(@.x>10)].x', () => { + const result = JSONSearch({ resultType: 'value', path: '$.arr[?(@.x>10)].x', json: sample }); + expect(result).toEqual([30]); + }); + it('flatten: $.arr[*].x', () => { + const result = JSONSearch({ resultType: 'value', path: '$.arr[*].x', json: sample, flatten: false }); + expect(Array.isArray(result[0])).toBe(false); + }); + }); + + describe('Selector', () => { + it('label: $..[?(@.label=="group-label-1")]', () => { + const result = JSONSearch({ resultType: 'value', path: '$..[?(@.label=="group-label-1")]', json: sample }); + expect(result).toEqual([expect.objectContaining({ label: 'group-label-1' })]); + }); + it('type group: $..[?(@.type=="group")]', () => { + const result = JSONSearch({ resultType: 'value', path: '$..[?(@.type=="group")]', json: sample }); + expect(result.every((v) => v.type === 'group')).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + it('type grid: $..[?(@.type=="grid")]', () => { + const result = JSONSearch({ resultType: 'value', path: '$..[?(@.type=="grid")]', json: sample }); + expect(result.every((v) => v.type === 'grid')).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + it('id grid-1: $..[?(@.id=="grid-1")]', () => { + const result = JSONSearch({ resultType: 'value', path: '$..[?(@.id=="grid-1")]', json: sample }); + expect(result).toEqual([expect.objectContaining({ id: 'grid-1' })]); + }); + it('chained filter: $..items[?(@.type=="group")].items[?(@.type=="grid")]', () => { + const result = JSONSearch({ + resultType: 'value', + path: '$..items[?(@.type=="group")].items[?(@.type=="grid")]', + json: sample, + }); + expect(result).toEqual( + expect.arrayContaining([ + { type: 'grid', id: 1 }, + { type: 'grid', id: 'grid-1', label: 'grid-label-1' }, + { type: 'grid', id: 3 }, + ]), + ); + expect(result.length).toBe(3); + }); + }); +}); diff --git a/src/utils/selector/search-recursive.js b/src/utils/selector/search-recursive.js new file mode 100644 index 00000000..7bb0a985 --- /dev/null +++ b/src/utils/selector/search-recursive.js @@ -0,0 +1,161 @@ +import { evaluateFilter } from './filter-evaluator.js'; +import { TOKEN_TYPES } from './token-constants.js'; + +/** + * Recursively searches a JSON structure based on tokens. + * @param {any} node - Current JSON node + * @param {Object} ctx - Search context + * @param {Array} ctx.tokens - Tokens to process + * @param {string} ctx.path - Current JSONPath string + * @param {string} ctx.ptr - Current JSON Pointer string + * @param {Array} ctx.results - Array to store results + * @param {Object} ctx.opts - Search options + * @param {string} ctx.opts.resultType - Result type ('value', 'path', 'pointer') + * @param {Array|null} ctx.opts.searchableKeys - Keys to search in objects + * @param {Function|null} ctx.opts.callback - Callback for each result + */ +export const searchRecursive = (node, ctx) => { + if (ctx.tokens.length === 0) { + processResult(node, ctx); + return; + } + + const [token, ...next] = ctx.tokens; + const nextCtx = { ...ctx, tokens: next }; + + if (token.type === TOKEN_TYPES.ROOT || token.type === TOKEN_TYPES.IMPLICIT_ROOT) { + searchRecursive(node, nextCtx); + return; + } + + if (token.type === TOKEN_TYPES.RECURSIVE_DESCENT) { + // Apply next to current node, then all original tokens (from ctx.tokens for '..') to children + searchRecursive(node, nextCtx); + traverseChildren(node, ctx, ctx.tokens); + return; + } + + if (node === null || node === undefined) return; + + const handler = tokenHandlers[token.type]; + if (handler) handler(node, token, next, ctx); +}; + +// Processes a found result +const processResult = (node, ctx) => { + const result = getResult(node, ctx); + + // Push to results if ctx.results is a valid array + if (ctx?.results && Array.isArray(ctx.results)) { + ctx.results.push(result); + } + + // Safely check and invoke the callback + if (typeof ctx?.opts?.callback === 'function') { + ctx.opts.callback(result, node, ctx.path, ctx.ptr); + } +}; + +// Determines result value based on opts.resultType +const getResult = (node, ctx) => { + // Default to 'value' if ctx.opts.resultType is invalid + if (typeof ctx?.opts?.resultType !== 'string') { + return node; // Default to 'value' + } + switch (ctx.opts.resultType) { + case 'path': + return ctx.path; + case 'pointer': + return ctx.ptr; + default: // 'value' or unspecified + return node; + } +}; + +// Traverses children, applying searchRecursive with `next` +const traverseChildren = (node, ctx, next) => { + if (Array.isArray(node)) { + node.forEach((item, i) => { + searchRecursive(item, { + ...ctx, + path: `${ctx.path}[${i}]`, + ptr: `${ctx.ptr}/${i}`, + tokens: next, + }); + }); + } else if (isObject(node)) { + const keys = ctx.opts.searchableKeys || Object.keys(node); + keys.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(node, key)) { + searchRecursive(node[key], { + ...ctx, + path: `${ctx.path}.${key}`, // Use dot notation for object properties + ptr: `${ctx.ptr}/${escapePtr(key)}`, + tokens: next, + }); + } + }); + } +}; + +const tokenHandlers = { + KEY: (node, token, next, ctx) => { + if (isObject(node) && Object.prototype.hasOwnProperty.call(node, token.value)) { + searchRecursive(node[token.value], { + ...ctx, + path: `${ctx.path}.${token.value}`, + ptr: `${ctx.ptr}/${escapePtr(token.value)}`, + tokens: next, + }); + } else if (Array.isArray(node)) { + node.forEach((item, i) => { + if (isObject(item) && Object.prototype.hasOwnProperty.call(item, token.value)) { + searchRecursive(item[token.value], { + ...ctx, + path: `${ctx.path}[${i}].${token.value}`, + ptr: `${ctx.ptr}/${i}/${escapePtr(token.value)}`, + tokens: next, + }); + } + }); + } + }, + INDEX: (node, token, next, ctx) => { + if (Array.isArray(node) && token.value < node.length && token.value >= 0) { + searchRecursive(node[token.value], { + ...ctx, + path: `${ctx.path}[${token.value}]`, + ptr: `${ctx.ptr}/${token.value}`, + tokens: next, + }); + } + }, + WILDCARD: (node, _token, next, ctx) => { + traverseChildren(node, ctx, next); + }, + FILTER: (node, token, next, ctx) => { + if (Array.isArray(node)) { + node.forEach((item, i) => { + if (evaluateFilter(item, token.value)) { + searchRecursive(item, { + ...ctx, + path: `${ctx.path}[${i}]`, + ptr: `${ctx.ptr}/${i}`, + tokens: next, + }); + } + }); + } else if (isObject(node)) { + if (evaluateFilter(node, token.value)) { + // Filter on object: path/ptr unchanged, use next + searchRecursive(node, { ...ctx, tokens: next }); + } + } + }, +}; + +// Checks if a value is a plain object +const isObject = (val) => val !== null && typeof val === 'object' && !Array.isArray(val); + +// Escapes keys for JSON Pointer +const escapePtr = (token) => String(token).replace(/~/g, '~0').replace(/\//g, '~1'); diff --git a/src/utils/selector/selector.js b/src/utils/selector/selector.js index 983d739a..d9cb6b70 100644 --- a/src/utils/selector/selector.js +++ b/src/utils/selector/selector.js @@ -1,5 +1,12 @@ import { JSONSearch } from './json-search'; +/** + * Simplified wrapper for JSONSearch with sensible defaults + * @param {any} json - The JSON data to search + * @param {string} path - The JSONPath expression + * @param {Object} [options={}] - Additional search options + * @returns {any[]} Array of search results + */ export const selector = (json, path, options = {}) => { return JSONSearch({ searchableKeys: ['children'], diff --git a/src/utils/selector/token-constants.js b/src/utils/selector/token-constants.js new file mode 100644 index 00000000..b6eb26c9 --- /dev/null +++ b/src/utils/selector/token-constants.js @@ -0,0 +1,9 @@ +export const TOKEN_TYPES = { + ROOT: 'ROOT', + IMPLICIT_ROOT: 'IMPLICIT_ROOT', + RECURSIVE_DESCENT: 'RECURSIVE_DESCENT', + WILDCARD: 'WILDCARD', + FILTER: 'FILTER', + INDEX: 'INDEX', + KEY: 'KEY', +}; diff --git a/src/utils/selector/tokenizer.js b/src/utils/selector/tokenizer.js new file mode 100644 index 00000000..bc317225 --- /dev/null +++ b/src/utils/selector/tokenizer.js @@ -0,0 +1,207 @@ +/** + * Parses a JSONPath string into a sequence of tokens. + * @param {string} pathStr - The JSONPath string. + * @returns {Array} - An array of token objects. + */ + +import { TOKEN_TYPES } from './token-constants.js'; + +export function tokenizePath(pathStr) { + if (!pathStr || typeof pathStr !== 'string') return []; + + const tokens = []; + let idx = 0; + const len = pathStr.length; + + if (pathStr[idx] === '$') { + tokens.push({ type: TOKEN_TYPES.ROOT }); + idx++; + } else { + // If path doesn't start with '$', treat as implicit root + tokens.push({ type: TOKEN_TYPES.IMPLICIT_ROOT }); + } + + while (idx < len) { + const char = pathStr[idx]; + + if (char === '.' && pathStr[idx + 1] === '.') { + tokens.push({ type: TOKEN_TYPES.RECURSIVE_DESCENT }); + idx += 2; + } else if (char === '.') { + idx++; // Skip dots and move to next character + } else if (char === '[') { + // parseBracketExpr starts from the character after '[' and returns the index of the character after ']' + const bracket = parseBracketExpr(pathStr, idx + 1, len); + if (bracket.token) tokens.push(bracket.token); + idx = bracket.newIndex; + } else { + const prop = parseProperty(pathStr, idx, len); + if (prop.token) tokens.push(prop.token); + idx = prop.newIndex; + } + } + return tokens; +} + +/** + * Parse bracket contents + * @param {string} pathStr - Full JSONPath string + * @param {number} startIndex - Index of character after '[' + * @param {number} len - Length of the full string + * @returns {{token: Object|null, newIndex: number}} Parsed token and next parsing start index + * @example + * parseBracketExpr("['key']", 1, 7) // Target: 'key'] + * parseBracketExpr("[*]", 1, 3) // Target: *] + * parseBracketExpr("[?(expr)]", 1, 9) // Target: ?(expr)] + * parseBracketExpr("[0]", 1, 3) // Target: 0] + */ +function parseBracketExpr(pathStr, startIndex, len) { + let idx = startIndex; + let token = null; + + if (idx >= len) { + // Bracket opened but no content (e.g., "path[") + return { token: null, newIndex: idx }; + } + + const char = pathStr[idx]; + + if (char === '*') { + token = { type: TOKEN_TYPES.WILDCARD }; + idx++; + } else if (char === '?') { + // parseFilter starts from the character after '?' + const filter = parseFilter(pathStr, idx + 1, len); + token = { type: TOKEN_TYPES.FILTER, value: filter.value }; + idx = filter.newIndex; // newIndex points to after the end of the filter expression + } else { + // parseSubscript starts from current character (key, index, or quote) + const subscript = parseSubscript(pathStr, idx, len); + token = subscript.token; + idx = subscript.newIndex; // newIndex is the position after key/index parsing + } + + // Consume closing bracket ']' + if (idx < len && pathStr[idx] === ']') { + idx++; + } + // If no closing bracket, it may be an error situation, but here we proceed with parsing as much as possible + return { token, newIndex: idx }; +} + +/** + * Parse filter expression + * @param {string} pathStr - Full JSONPath string + * @param {number} startIndex - Index of character after '?' + * @param {number} len - Length of the full string + * @returns {{value: string, newIndex: number}} Filter expression content and next parsing start index + * @example + * parseFilter("?( @.price < 10 )]", 1, 17) // Target: ( @.price < 10 )] + * parseFilter("?(@.isbn)]", 1, 10) // Target: (@.isbn)] + */ +function parseFilter(pathStr, startIndex, len) { + let idx = startIndex; + let filterExpr = ''; + + // Handle case where '(' is consumed first in '?(expr)' + let startIdx = idx; + if (pathStr[idx] === '(') { + startIdx = idx + 1; // Actual expression content starts after '(' + idx++; // Consume '(' + } + + let parenDepth = 1; // Start with virtual parenthesis wrapping the filter expression or consumed '(' + let scanIdx = startIdx; + + while (scanIdx < len) { + const char = pathStr[scanIdx]; + if (char === '(') { + parenDepth++; + } else if (char === ')') { + parenDepth--; + } + + // First break condition: found matching parentheses and next is ']' or current is ')' + if (parenDepth === 0 && (pathStr[scanIdx + 1] === ']' || char === ')')) { + filterExpr = pathStr.substring(startIdx, char === ')' ? scanIdx : scanIdx + 1); + idx = scanIdx + 1; // Move to after the end of expression + break; + } + + // Second break condition: expression without parentheses ends with ']' + if (char === ']' && parenDepth === 1 && (scanIdx === startIdx || pathStr[scanIdx - 1] !== ')')) { + filterExpr = pathStr.substring(startIdx, scanIdx); // Up to before ']' + idx = scanIdx; // currentIndex points to ']' (handled in parseBracketExpr) + break; + } + scanIdx++; + } + + // If loop ended without break (reached len) + if (scanIdx === len && !filterExpr) { + filterExpr = pathStr.substring(startIdx, scanIdx); + idx = scanIdx; + } + + // Maintain original logic for `if (filterExpr.endsWith(')'))` + if (filterExpr.endsWith(')')) { + filterExpr = filterExpr.slice(0, -1); + } + + return { value: filterExpr.trim(), newIndex: idx }; +} + +/** + * Parse index or key + * @param {string} pathStr - Full JSONPath string + * @param {number} startIndex - Start index of actual content inside brackets + * @param {number} len - Length of the full string + * @returns {{token: Object, newIndex: number}} Parsed token and next parsing start index + * @example + * parseSubscript("'name']", 0, 7) // Target: 'name'] + * parseSubscript("2]", 0, 2) // Target: 2] + * parseSubscript("\"complex key\"]", 0, 14) // Target: "complex key"] + */ +function parseSubscript(pathStr, startIndex, len) { + let idx = startIndex; + let value = ''; + const quoteChar = pathStr[idx] === "'" || pathStr[idx] === '"' ? pathStr[idx++] : null; + + while (idx < len && pathStr[idx] !== ']') { + if (quoteChar && pathStr[idx] === quoteChar) break; // If wrapped in quotes, end when meeting closing quote + value += pathStr[idx++]; + } + + if (quoteChar && pathStr[idx] === quoteChar) { + idx++; // Consume closing quote + } + + let token; + if (/^\d+$/.test(value) && !quoteChar) { + token = { type: TOKEN_TYPES.INDEX, value: Number.parseInt(value, 10) }; + } else { + token = { type: TOKEN_TYPES.KEY, value: value }; + } + // newIndex is just before meeting ']' or after closing quote. parseBracketExpr handles ']' + return { token, newIndex: idx }; +} + +/** + * Parse property key + * @param {string} pathStr - Full JSONPath string + * @param {number} startIndex - Start index of general key + * @param {number} len - Length of the full string + * @returns {{token: Object|null, newIndex: number}} Parsed token and next parsing start index + * @example + * parseProperty("key.next", 0, 8) // Target: key + * parseProperty("key[0]", 0, 6) // Target: key + */ +function parseProperty(pathStr, startIndex, len) { + let idx = startIndex; + let name = ''; + while (idx < len && pathStr[idx] !== '.' && pathStr[idx] !== '[') { + name += pathStr[idx++]; + } + const token = name ? { type: TOKEN_TYPES.KEY, value: name } : null; + return { token, newIndex: idx }; +}