Orientation in JSON documents with Emacs and Tree-sitter

Adventures with the new Tree-sitter based Emacs mode for JSON

Created on [2023-05-17], last updated [2023-06-11]

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.