Orientation in JSON documents with Emacs and Tree-sitter
Adventures with the new Tree-sitter based Emacs mode for JSON
I often find myself opening a large JSON document, usually a sample from a larger dataset I’m analyzing, in order to figure out where in that JSON some interesting fields are buried. In most cases, I have some idea of what these fields should contain, so I can locate them quite easily by searching with Emacs’s Isearch for some string that I expect to find there.
If I end up somewhere in the middle of a large JSON document, the challenge then
becomes determining the path from the root of the document to the position of my
cursor. Emacs has a built-in function json-path-to-position in json.el that
parses the buffer as a JSON document and returns the path to a given position,
but since I’ve recently started using the new Tree-sitter based JSON major mode
json-ts-mode, I figured it’d be a nice exercise to implement a
json-path-at-point command that leverages Tree-sitter’s knowledge of the
buffer’s parse tree, and doesn’t require json.el.
There it is, I give you esy/json-path-at-point:
(defun esy/json-path-to-position (pos)
"Return the JSON path from the document's root to the element at POS.
The path is represented as a list of strings and integers,
corresponding to the object keys and array indices that lead from
the root to the element at POS."
(named-let loop ((node (treesit-node-at pos)) (acc nil))
(if-let ((parent (treesit-parent-until
node
(lambda (n)
(member (treesit-node-type n)
'("pair" "array"))))))
(loop parent
(cons
(pcase (treesit-node-type parent)
("pair"
(treesit-node-text
(treesit-node-child (treesit-node-child parent 0) 1) t))
("array"
(named-let check ((i 1))
(if (< pos (treesit-node-end (treesit-node-child parent i)))
(/ (1- i) 2)
(check (+ i 2))))))
acc))
acc)))
(defun esy/json-path-at-point (point &optional kill)
"Display the JSON path at POINT. When KILL is non-nil, kill it too.
Interactively, POINT is point and KILL is the prefix argument."
(interactive "d\nP" json-ts-mode)
(let ((path (mapconcat (lambda (o) (format "%s" o))
(esy/json-path-to-position point)
".")))
(if kill
(progn (kill-new path) (message "Copied: %s" path))
(message path))
path))
As an example of its usage, consider the following JSON:
{
"foo" : [
{
"bar" : "baz"
},
{
"one" : "two"
}
]
}
With point on “baz”, typing M-x esy/json-path-at-point displays foo.0.bar in
the echo area. With a prefix argument (C-u), it copies the path to the kill
ring as well so I can, for instance, later copy it into some script I’m writing.