• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / loaders.gl / 24153816851

08 Apr 2026 07:17PM UTC coverage: 53.247% (-12.1%) from 65.319%
24153816851

push

github

web-flow
chore: Move from tape to vitest (#3351)

8651 of 17291 branches covered (50.03%)

Branch coverage included in aggregate %.

7 of 7 new or added lines in 1 file covered. (100.0%)

2031 existing lines in 296 files now uncovered.

17563 of 31940 relevant lines covered (54.99%)

5279.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.28
/modules/json/src/lib/jsonpath/jsonpath.ts
1
// loaders.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
/* eslint-disable no-continue */
6

7
/**
8
 * A parser for a minimal subset of the jsonpath standard
9
 * Full JSON path parsers for JS exist but are quite large (bundle size)
10
 *
11
 * Supports
12
 *
13
 *   `$.component.component.component`
14
 */
15
export default class JSONPath {
16
  path: string[];
17

18
  constructor(path: JSONPath | string[] | string | null = null) {
212✔
19
    this.path = parseJsonPath(path);
212✔
20
  }
21

22
  clone(): JSONPath {
23
    return new JSONPath(this);
17✔
24
  }
25

26
  toString(): string {
27
    return formatJsonPath(this.path);
51✔
28
  }
29

30
  push(name: string): void {
31
    this.path.push(name);
10,178✔
32
  }
33

34
  pop() {
35
    return this.path.pop();
10,178✔
36
  }
37

38
  set(name: string): void {
39
    this.path[this.path.length - 1] = name;
29,635✔
40
  }
41

42
  equals(other: JSONPath): boolean {
43
    if (!this || !other || this.path.length !== other.path.length) {
340✔
44
      return false;
308✔
45
    }
46

47
    for (let i = 0; i < this.path.length; ++i) {
32✔
48
      if (this.path[i] !== other.path[i]) {
60✔
49
        return false;
2✔
50
      }
51
    }
52

53
    return true;
30✔
54
  }
55

56
  /**
57
   * Sets the value pointed at by path
58
   * TODO - handle root path
59
   * @param object
60
   * @param value
61
   */
62
  setFieldAtPath(object, value) {
63
    const path = [...this.path];
1✔
64
    path.shift();
1✔
65
    const field = path.pop();
1✔
66
    for (const component of path) {
1✔
67
      object = object[component];
1✔
68
    }
69
    // @ts-ignore
70
    object[field] = value;
1✔
71
  }
72

73
  /**
74
   * Gets the value pointed at by path
75
   * TODO - handle root path
76
   * @param object
77
   */
78
  getFieldAtPath(object) {
79
    const path = [...this.path];
2✔
80
    path.shift();
2✔
81
    const field = path.pop();
2✔
82
    for (const component of path) {
2✔
83
      object = object[component];
2✔
84
    }
85
    // @ts-ignore
86
    return object[field];
2✔
87
  }
88
}
89

90
type BracketSegment =
91
  | {type: 'property'; value: string; nextIndex: number}
92
  | {type: 'array-selector'; nextIndex: number};
93

94
function parseJsonPath(path: JSONPath | string[] | string | null): string[] {
95
  if (path instanceof JSONPath) {
212✔
96
    return [...path.path];
26✔
97
  }
98

99
  if (Array.isArray(path)) {
186✔
100
    return ['$'].concat(path);
9✔
101
  }
102

103
  if (typeof path === 'string') {
177✔
104
    return parseJsonPathString(path);
24✔
105
  }
106

107
  return ['$'];
153✔
108
}
109

110
// eslint-disable-next-line complexity, max-statements
111
function parseJsonPathString(pathString: string): string[] {
112
  const trimmedPath = pathString.trim();
24✔
113
  if (!trimmedPath.startsWith('$')) {
24✔
114
    throw new Error('JSONPath must start with $');
1✔
115
  }
116

117
  const segments: string[] = ['$'];
23✔
118
  let index = 1;
23✔
119
  let arrayElementSelectorEncountered = false;
23✔
120

121
  while (index < trimmedPath.length) {
23✔
122
    const character = trimmedPath[index];
37✔
123
    if (character === '.') {
37✔
124
      if (arrayElementSelectorEncountered) {
24✔
125
        throw new Error('JSONPath cannot select fields after array element selectors');
1✔
126
      }
127
      index += 1;
23✔
128
      if (trimmedPath[index] === '.') {
23✔
129
        throw new Error('JSONPath descendant selectors (..) are not supported');
1✔
130
      }
131
      const {value, nextIndex, isWildcard} = parseDotSegment(trimmedPath, index);
22✔
132
      if (isWildcard) {
22✔
133
        if (nextIndex < trimmedPath.length) {
1!
134
          throw new Error('JSONPath wildcard selectors must terminate the path');
×
135
        }
136
        arrayElementSelectorEncountered = true;
1✔
137
        index = nextIndex;
1✔
138
        continue;
1✔
139
      }
140
      segments.push(value);
20✔
141
      index = nextIndex;
20✔
142
      continue;
20✔
143
    }
144

145
    if (character === '[') {
13!
146
      const parsedSegment = parseBracketSegment(trimmedPath, index);
13✔
147
      if (parsedSegment.type === 'property') {
13✔
148
        if (arrayElementSelectorEncountered) {
2!
UNCOV
149
          throw new Error('JSONPath cannot select fields after array element selectors');
×
150
        }
151
        segments.push(parsedSegment.value);
2✔
152
      } else {
153
        arrayElementSelectorEncountered = true;
5✔
154
      }
155
      index = parsedSegment.nextIndex;
7✔
156
      continue;
7✔
157
    }
158

UNCOV
159
    if (character === '@') {
×
UNCOV
160
      throw new Error('JSONPath current node selector (@) is not supported');
×
161
    }
162

UNCOV
163
    if (character.trim() === '') {
×
164
      index += 1;
×
165
      continue;
×
166
    }
167

UNCOV
168
    throw new Error(`Unexpected character "${character}" in JSONPath`);
×
169
  }
170

171
  return segments;
14✔
172
}
173

174
function parseDotSegment(
175
  pathString: string,
176
  startIndex: number
177
): {
178
  value: string;
179
  nextIndex: number;
180
  isWildcard: boolean;
181
} {
182
  if (startIndex >= pathString.length) {
22✔
183
    throw new Error('JSONPath cannot end with a period');
1✔
184
  }
185

186
  if (pathString[startIndex] === '*') {
21✔
187
    return {value: '*', nextIndex: startIndex + 1, isWildcard: true};
1✔
188
  }
189

190
  const firstCharacter = pathString[startIndex];
20✔
191
  if (firstCharacter === '@') {
20!
UNCOV
192
    throw new Error('JSONPath current node selector (@) is not supported');
×
193
  }
194
  if (!isIdentifierStartCharacter(firstCharacter)) {
20!
UNCOV
195
    throw new Error('JSONPath property names after period must start with a letter, $ or _');
×
196
  }
197

198
  let endIndex = startIndex + 1;
20✔
199
  while (endIndex < pathString.length && isIdentifierCharacter(pathString[endIndex])) {
20✔
200
    endIndex++;
91✔
201
  }
202

203
  if (endIndex === startIndex) {
20!
UNCOV
204
    throw new Error('JSONPath is missing a property name after period');
×
205
  }
206

207
  return {
20✔
208
    value: pathString.slice(startIndex, endIndex),
209
    nextIndex: endIndex,
210
    isWildcard: false
211
  };
212
}
213

214
function parseBracketSegment(pathString: string, startIndex: number): BracketSegment {
215
  const contentStartIndex = startIndex + 1;
13✔
216
  if (contentStartIndex >= pathString.length) {
13!
217
    throw new Error('JSONPath has unterminated bracket');
×
218
  }
219

220
  const firstCharacter = pathString[contentStartIndex];
13✔
221
  if (firstCharacter === "'" || firstCharacter === '"') {
13✔
222
    const {value, nextIndex} = parseBracketProperty(pathString, contentStartIndex);
3✔
223
    return {type: 'property', value, nextIndex};
3✔
224
  }
225

226
  const closingBracketIndex = pathString.indexOf(']', contentStartIndex);
10✔
227
  if (closingBracketIndex === -1) {
10!
UNCOV
228
    throw new Error('JSONPath has unterminated bracket');
×
229
  }
230

231
  const content = pathString.slice(contentStartIndex, closingBracketIndex).trim();
10✔
232
  const unsupportedSelectorMessage = getUnsupportedBracketSelectorMessage(content);
10✔
233
  if (unsupportedSelectorMessage) {
10✔
234
    throw new Error(unsupportedSelectorMessage);
4✔
235
  }
236
  if (content === '*') {
6✔
237
    return {type: 'array-selector', nextIndex: closingBracketIndex + 1};
2✔
238
  }
239

240
  if (/^\d+$/.test(content)) {
4✔
241
    throw new Error('JSONPath array index selectors are not supported');
1✔
242
  }
243

244
  if (/^\d*\s*:\s*\d*(\s*:\s*\d*)?$/.test(content)) {
3!
245
    return {type: 'array-selector', nextIndex: closingBracketIndex + 1};
3✔
246
  }
247

UNCOV
248
  throw new Error(`Unsupported bracket selector "[${content}]" in JSONPath`);
×
249
}
250

251
function getUnsupportedBracketSelectorMessage(content: string): string | null {
252
  if (!content.length) {
10!
253
    return 'JSONPath bracket selectors cannot be empty';
×
254
  }
255
  if (content.startsWith('(')) {
10✔
256
    return 'JSONPath script selectors are not supported';
1✔
257
  }
258
  if (content.startsWith('?')) {
9✔
259
    return 'JSONPath filter selectors are not supported';
1✔
260
  }
261
  if (content.includes(',')) {
8✔
262
    return 'JSONPath union selectors are not supported';
1✔
263
  }
264
  if (content.startsWith('@') || content.includes('@.')) {
7✔
265
    return 'JSONPath current node selector (@) is not supported';
1✔
266
  }
267
  return null;
6✔
268
}
269

270
// eslint-disable-next-line complexity, max-statements
271
function parseBracketProperty(
272
  pathString: string,
273
  startIndex: number
274
): {
275
  value: string;
276
  nextIndex: number;
277
} {
278
  const quoteCharacter = pathString[startIndex];
3✔
279
  let index = startIndex + 1;
3✔
280
  let value = '';
3✔
281
  let terminated = false;
3✔
282

283
  while (index < pathString.length) {
3✔
284
    const character = pathString[index];
30✔
285
    if (character === '\\') {
30!
UNCOV
286
      index += 1;
×
UNCOV
287
      if (index >= pathString.length) {
×
UNCOV
288
        break;
×
289
      }
UNCOV
290
      value += pathString[index];
×
UNCOV
291
      index += 1;
×
UNCOV
292
      continue;
×
293
    }
294

295
    if (character === quoteCharacter) {
30✔
296
      terminated = true;
2✔
297
      index += 1;
2✔
298
      break;
2✔
299
    }
300

301
    value += character;
28✔
302
    index += 1;
28✔
303
  }
304

305
  if (!terminated) {
3✔
306
    throw new Error('JSONPath string in bracket property selector is unterminated');
1✔
307
  }
308

309
  while (index < pathString.length && pathString[index].trim() === '') {
2✔
310
    index += 1;
×
311
  }
312

313
  if (pathString[index] !== ']') {
2!
314
    throw new Error('JSONPath property selectors must end with ]');
×
315
  }
316

317
  if (!value.length) {
2!
318
    throw new Error('JSONPath property selectors cannot be empty');
×
319
  }
320

321
  return {value, nextIndex: index + 1};
2✔
322
}
323

324
function isIdentifierCharacter(character: string): boolean {
325
  return /[a-zA-Z0-9$_]/.test(character);
105✔
326
}
327

328
function isIdentifierStartCharacter(character: string): boolean {
329
  return /[a-zA-Z_$]/.test(character);
20✔
330
}
331

332
function isIdentifierSegment(segment: string): boolean {
333
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(segment);
50✔
334
}
335

336
function formatJsonPath(path: string[]): string {
337
  return path
51✔
338
    .map((segment, index) => {
339
      if (index === 0) {
101✔
340
        return segment;
51✔
341
      }
342
      if (segment === '*') {
50!
343
        return '.*';
×
344
      }
345
      if (isIdentifierSegment(segment)) {
50✔
346
        return `.${segment}`;
47✔
347
      }
348
      const escapedSegment = segment.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
3✔
349
      return `['${escapedSegment}']`;
3✔
350
    })
351
    .join('');
352
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc