@loaders.gl/json
Version:
Framework-independent loader for JSON and streaming JSON formats
285 lines • 9.32 kB
JavaScript
// 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;
constructor(path = null) {
this.path = parseJsonPath(path);
}
clone() {
return new JSONPath(this);
}
toString() {
return formatJsonPath(this.path);
}
push(name) {
this.path.push(name);
}
pop() {
return this.path.pop();
}
set(name) {
this.path[this.path.length - 1] = name;
}
equals(other) {
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];
}
}
function parseJsonPath(path) {
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) {
const trimmedPath = pathString.trim();
if (!trimmedPath.startsWith('$')) {
throw new Error('JSONPath must start with $');
}
const segments = ['$'];
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, startIndex) {
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, startIndex) {
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) {
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, startIndex) {
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) {
return /[a-zA-Z0-9$_]/.test(character);
}
function isIdentifierStartCharacter(character) {
return /[a-zA-Z_$]/.test(character);
}
function isIdentifierSegment(segment) {
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(segment);
}
function formatJsonPath(path) {
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('');
}
//# sourceMappingURL=jsonpath.js.map