UNPKG

@loaders.gl/json

Version:

Framework-independent loader for JSON and streaming JSON formats

353 lines (299 loc) 9.03 kB
// loaders.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors /* eslint-disable no-continue */ /** * A parser for a minimal subset of the jsonpath standard * Full JSON path parsers for JS exist but are quite large (bundle size) * * Supports * * `$.component.component.component` */ export default class JSONPath { path: string[]; constructor(path: JSONPath | string[] | string | null = null) { this.path = parseJsonPath(path); } clone(): JSONPath { return new JSONPath(this); } toString(): string { return formatJsonPath(this.path); } push(name: string): void { this.path.push(name); } pop() { return this.path.pop(); } set(name: string): void { this.path[this.path.length - 1] = name; } equals(other: JSONPath): boolean { if (!this || !other || this.path.length !== other.path.length) { return false; } for (let i = 0; i < this.path.length; ++i) { if (this.path[i] !== other.path[i]) { return false; } } return true; } /** * Sets the value pointed at by path * TODO - handle root path * @param object * @param value */ setFieldAtPath(object, value) { const path = [...this.path]; path.shift(); const field = path.pop(); for (const component of path) { object = object[component]; } // @ts-ignore object[field] = value; } /** * Gets the value pointed at by path * TODO - handle root path * @param object */ getFieldAtPath(object) { const path = [...this.path]; path.shift(); const field = path.pop(); for (const component of path) { object = object[component]; } // @ts-ignore return object[field]; } } type BracketSegment = | {type: 'property'; value: string; nextIndex: number} | {type: 'array-selector'; nextIndex: number}; function parseJsonPath(path: JSONPath | string[] | string | null): string[] { if (path instanceof JSONPath) { return [...path.path]; } if (Array.isArray(path)) { return ['$'].concat(path); } if (typeof path === 'string') { return parseJsonPathString(path); } return ['$']; } // eslint-disable-next-line complexity, max-statements function parseJsonPathString(pathString: string): string[] { const trimmedPath = pathString.trim(); if (!trimmedPath.startsWith('$')) { throw new Error('JSONPath must start with $'); } const segments: string[] = ['$']; let index = 1; let arrayElementSelectorEncountered = false; while (index < trimmedPath.length) { const character = trimmedPath[index]; if (character === '.') { if (arrayElementSelectorEncountered) { throw new Error('JSONPath cannot select fields after array element selectors'); } index += 1; if (trimmedPath[index] === '.') { throw new Error('JSONPath descendant selectors (..) are not supported'); } const {value, nextIndex, isWildcard} = parseDotSegment(trimmedPath, index); if (isWildcard) { if (nextIndex < trimmedPath.length) { throw new Error('JSONPath wildcard selectors must terminate the path'); } arrayElementSelectorEncountered = true; index = nextIndex; continue; } segments.push(value); index = nextIndex; continue; } if (character === '[') { const parsedSegment = parseBracketSegment(trimmedPath, index); if (parsedSegment.type === 'property') { if (arrayElementSelectorEncountered) { throw new Error('JSONPath cannot select fields after array element selectors'); } segments.push(parsedSegment.value); } else { arrayElementSelectorEncountered = true; } index = parsedSegment.nextIndex; continue; } if (character === '@') { throw new Error('JSONPath current node selector (@) is not supported'); } if (character.trim() === '') { index += 1; continue; } throw new Error(`Unexpected character "${character}" in JSONPath`); } return segments; } function parseDotSegment( pathString: string, startIndex: number ): { value: string; nextIndex: number; isWildcard: boolean; } { if (startIndex >= pathString.length) { throw new Error('JSONPath cannot end with a period'); } if (pathString[startIndex] === '*') { return {value: '*', nextIndex: startIndex + 1, isWildcard: true}; } const firstCharacter = pathString[startIndex]; if (firstCharacter === '@') { throw new Error('JSONPath current node selector (@) is not supported'); } if (!isIdentifierStartCharacter(firstCharacter)) { throw new Error('JSONPath property names after period must start with a letter, $ or _'); } let endIndex = startIndex + 1; while (endIndex < pathString.length && isIdentifierCharacter(pathString[endIndex])) { endIndex++; } if (endIndex === startIndex) { throw new Error('JSONPath is missing a property name after period'); } return { value: pathString.slice(startIndex, endIndex), nextIndex: endIndex, isWildcard: false }; } function parseBracketSegment(pathString: string, startIndex: number): BracketSegment { const contentStartIndex = startIndex + 1; if (contentStartIndex >= pathString.length) { throw new Error('JSONPath has unterminated bracket'); } const firstCharacter = pathString[contentStartIndex]; if (firstCharacter === "'" || firstCharacter === '"') { const {value, nextIndex} = parseBracketProperty(pathString, contentStartIndex); return {type: 'property', value, nextIndex}; } const closingBracketIndex = pathString.indexOf(']', contentStartIndex); if (closingBracketIndex === -1) { throw new Error('JSONPath has unterminated bracket'); } const content = pathString.slice(contentStartIndex, closingBracketIndex).trim(); const unsupportedSelectorMessage = getUnsupportedBracketSelectorMessage(content); if (unsupportedSelectorMessage) { throw new Error(unsupportedSelectorMessage); } if (content === '*') { return {type: 'array-selector', nextIndex: closingBracketIndex + 1}; } if (/^\d+$/.test(content)) { throw new Error('JSONPath array index selectors are not supported'); } if (/^\d*\s*:\s*\d*(\s*:\s*\d*)?$/.test(content)) { return {type: 'array-selector', nextIndex: closingBracketIndex + 1}; } throw new Error(`Unsupported bracket selector "[${content}]" in JSONPath`); } function getUnsupportedBracketSelectorMessage(content: string): string | null { if (!content.length) { return 'JSONPath bracket selectors cannot be empty'; } if (content.startsWith('(')) { return 'JSONPath script selectors are not supported'; } if (content.startsWith('?')) { return 'JSONPath filter selectors are not supported'; } if (content.includes(',')) { return 'JSONPath union selectors are not supported'; } if (content.startsWith('@') || content.includes('@.')) { return 'JSONPath current node selector (@) is not supported'; } return null; } // eslint-disable-next-line complexity, max-statements function parseBracketProperty( pathString: string, startIndex: number ): { value: string; nextIndex: number; } { const quoteCharacter = pathString[startIndex]; let index = startIndex + 1; let value = ''; let terminated = false; while (index < pathString.length) { const character = pathString[index]; if (character === '\\') { index += 1; if (index >= pathString.length) { break; } value += pathString[index]; index += 1; continue; } if (character === quoteCharacter) { terminated = true; index += 1; break; } value += character; index += 1; } if (!terminated) { throw new Error('JSONPath string in bracket property selector is unterminated'); } while (index < pathString.length && pathString[index].trim() === '') { index += 1; } if (pathString[index] !== ']') { throw new Error('JSONPath property selectors must end with ]'); } if (!value.length) { throw new Error('JSONPath property selectors cannot be empty'); } return {value, nextIndex: index + 1}; } function isIdentifierCharacter(character: string): boolean { return /[a-zA-Z0-9$_]/.test(character); } function isIdentifierStartCharacter(character: string): boolean { return /[a-zA-Z_$]/.test(character); } function isIdentifierSegment(segment: string): boolean { return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(segment); } function formatJsonPath(path: string[]): string { return path .map((segment, index) => { if (index === 0) { return segment; } if (segment === '*') { return '.*'; } if (isIdentifierSegment(segment)) { return `.${segment}`; } const escapedSegment = segment.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); return `['${escapedSegment}']`; }) .join(''); }