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.