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] });
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))); }
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 };
}
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
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);
}
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)