DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

A JSONPath engine is two moves — parse the path into a list of selectors, then pipe a node-set of {path, value} through each

JSONPath is to JSON what XPath is to XML: a tiny expression language for reaching into a nested document and pulling out the pieces you want. My first instinct was that an evaluator needs a heavyweight grammar and a parser generator. It doesn't. The whole thing is two moves — parse the path string into a flat list of selectors, then walk the tree feeding a node-set through each selector in turn. Under ~150 lines of real logic, no library. I built a live tester; here's the engine.

A node is a value plus the path that reached it

The single idea that makes everything else fall out: never carry a bare value around — carry {path, value}. Every selector reads a node's value and, for each match, appends one segment to path. When a value survives all selectors, its path is the exact normalized JSONPath that reached it — the thing real tools print next to each result.

let nodes = [{ path: ['$'], value: data }];   // start: the whole document
const childNode = (n, key) =>
  ({ path: n.path.concat(key), value: n.value[key] });
Enter fullscreen mode Exit fullscreen mode

Parse the path into a flat list of selectors

Scan the string left to right. A leading $ is the root; then repeatedly read .name (child), ..name (recursive descent), .* / [*] (wildcard), or a [ ... ] bracket whose contents are classified separately. The output is a plain array like [recursive, child'book', filter, child'title'].

if (c === '.' && s[i+1] === '.') sels.push({ type:'recursive' });   // ..
else if (c === '[') { const end = findClosingBracket(s, i);         // [ ... ]
  sels.push(parseBracket(s.slice(i+1, end))); }
Enter fullscreen mode Exit fullscreen mode

Classify what's inside the brackets

One bracket can mean many things, so branch on its shape: * is a wildcard, a leading ? is a filter, a quoted string is a named child, a colon means a slice, a bare integer is an index, and a top-level comma makes it a union. The comma split has to respect quotes and parentheses so a comma inside a filter isn't mistaken for a separator.

function classifyPart(p){
  if (/^['"].*['"]$/.test(p)) return { type:'child', name: unquote(p) };
  if (p.includes(':')) { const [a,b,c] = p.split(':').map(toNumOrNull);
    return { type:'slice', start:a, end:b, step:c }; }          // Python-style
  if (/^-?\d+$/.test(p)) return { type:'index', index: +p };    // negative = from end
  return { type:'child', name: p };
}
Enter fullscreen mode Exit fullscreen mode

The walk: pipe the node-set through each selector

Every selector is a small function mapping a set of nodes to a new set. Start with one node (the root) and feed the set through the selectors one at a time. A child steps into one key; an index picks one element; a wildcard fans out to all children; a slice takes a Python-style range; a union concatenates several. A query returns a set, never one value.

for (const sel of selectors)
  nodes = nodes.flatMap(n => applySelector(sel, n));   // set -> set, per step
Enter fullscreen mode Exit fullscreen mode

Recursive descent is "this node plus all descendants"

The one that trips everyone: .. is not "any two levels" — it's "search everywhere". It expands each node to itself and every descendant, then the next selector runs against them all, which is why $..price finds every price at any depth.

function descend(node, out){                 // node + all its descendants
  out.push(node);
  const v = node.value;
  if (v && typeof v === 'object')
    for (const k in v) descend(childNode(node, k), out);
}
Enter fullscreen mode Exit fullscreen mode

Filters are a tiny expression evaluator

[?(@.price < 10)] is the one recursive bit: a textbook precedence climber over @ (the current element), $ (the root), the comparisons and &&/||. @ rebinds to each array element in turn, and a missing key makes a comparison simply false — never an error, so existence (@.isbn) and value tests share one path.

The lesson underneath is the one I keep relearning: pick the right unit of data — here {path, value} — give every operation a pure function of it, and a scary-looking query language collapses into a parse and a fold.

Type a path, edit the JSON, and watch the matches and their exact paths update live:
https://dev48v.infy.uk/solve/day49-jsonpath-tester.html

Top comments (0)