Jul 12, 2022 . 3 min

Navigating config files using tree-sitter

Yet another entry for tree-sitter. For those new here, I have blogged previously about using tree-sitter here and here.

Here is another installment of how I use tree-sitter to simplify/speed up things for me. In this installment we are going to see how we can use tree-sitter to easily navigate around config files.

When having to navigate through huge config files there are two problems that I usually run into.

Now let's see how we can fix these. In the below image you can see the path is shown in the header-line and that we are using imenu and consult-imenu to go to a node with completion.

Screenshot of it working

Figuring out where you are #

As you might know, tree-sitter has super sweet syntax tree which we can use. We can just query that to get the info on the file structure.

Just to give you a high level idea of what the code is doing, we query the entire tree to figure out where all the keys are. For example in case of json, you can use a query like (object (pair (string (string_content) @key) (_)) @item) to get the keys and the objects that they represent. Once we have that, we just go through them and find out all the item thingies for which start is before us and end is after us and get their keys.

The below snippet is pretty much what you can use for this. You can drop this into the statusline probably with something that will let you do deferred computation like mode-line-idle

(defun meain/tree-sitter-config-nesting ()
(if (or (eq major-mode 'json-mode) (eq major-mode 'yaml-mode) (eq major-mode 'nix-mode))
(let* ((cur-point (point))
(query (pcase major-mode
('json-mode "(object (pair (string (string_content) @key) (_)) @item)")
('yaml-mode "(block_mapping_pair (flow_node) @key (_)) @item")
('nix-mode "(bind (attrpath (attr_identifier) @key)) @item")))
(root-node (tsc-root-node tree-sitter-tree))
(query (tsc-make-query tree-sitter-language query))
(matches (tsc-query-matches query root-node #'tsc--buffer-substring-no-properties)))
(string-join (remove-if (lambda (x) (eq x nil))
(seq-map (lambda (x)
(let (
(item (seq-elt (cdr x) 0))
(key (seq-elt (cdr x) 1)))
(if (and
(> cur-point (byte-to-position (car (tsc-node-byte-range (cdr item)))))
(< cur-point (byte-to-position (cdr (tsc-node-byte-range (cdr item))))))
(format "%s" (tsc-node-text (cdr key)))

Btw, if you are lost in life, tree-sitter can't help you yet unfortunately.

Going to a node #

For this, I initially had a function leveraging completing-read, but since then I ended up just implementing logic to populate imenu list and let imenu do it along with the help of conult-imenu.

Here is the code which does it:

(defun meain/get-config-nesting-paths ()
"Get out all the nested paths in a config file."
(let* ((query (pcase major-mode
('json-mode "(object (pair (string (string_content) @key) (_)) @item)")
('yaml-mode "(block_mapping_pair (flow_node) @key (_)) @item")
('nix-mode "(bind (attrpath (attr_identifier) @key)) @item")))
(root-node (tsc-root-node tree-sitter-tree))
(query (tsc-make-query tree-sitter-language query))
(matches (tsc-query-matches query root-node #'tsc--buffer-substring-no-properties))
(prev-node-ends '(0)) ;; we can get away with just end as the list is sorted
(current-key-depth '())
(item-ranges (seq-map (lambda (x)
(let ((item (seq-elt (cdr x) 0))
(key (seq-elt (cdr x) 1)))
(list (tsc-node-text (cdr key))
(tsc-node-range (cdr key))
(tsc-node-range (cdr item)))))
(mapcar (lambda (x)
(let* ((current-end (seq-elt (cadr (cdr x)) 1))
(parent-end (car prev-node-ends))
(current-key (car x)))
(if (> current-end parent-end)
(mapcar (lambda (x)
(if (> current-end x)
(setq prev-node-ends (cdr prev-node-ends))
(setq current-key-depth (cdr current-key-depth)))))
(setq current-key-depth (cons current-key current-key-depth))
(setq prev-node-ends (cons current-end prev-node-ends))
(list (reverse current-key-depth) (seq-elt (cadr x) 0)))))
(defun meain/goto-config-nesting-path ()
"Interactively go to a nested path in a config file."
(let* ((paths (mapcar (lambda (x)
(cons (string-join (car x) ".") (cadr x)))
(goto-char (cdr (assoc
(completing-read "Choose path: " paths)
(defun meain/imenu-config-nesting-path ()
"Return config-nesting paths for use in imenu"
(mapcar (lambda (x)
(cons (string-join (car x) ".") (cadr x)))

The idea with this is more or less the same. We fetch the entire list of items, then go through it and create a list of items and their locations. Now we feed this to imenu by using something like below and we are good to go.

(add-hook 'nix-mode-hook (lambda ()
(setq imenu-create-index-function #'meain/imenu-config-nesting-path)))

And viola, there you go. I wanted to write a longer blog, but after I got this running I got really excited that I spent all the energy on celebrating. Even still, I hope folks find it useful. I am kinda thinking about creating another Emacs package around it, as in navigating and viewing for all kind of files like config / code / documentation (md) etc. But until then, you are welcome to copy paste the code from my dotfiles.

← Home