Mastering JSX Editing in Emacs with Tree-sitter

May 15, 2024[ Emacs , TreeSitter , JSX ]

Emacs 29 introduced built-in support for Tree-sitter, a powerful tool that revolutionizes syntax highlighting and editing. Tree-sitter constructs a concrete syntax tree for source code and efficiently updates it as modifications are made, offering significant improvements in performance and accuracy compared to traditional regular expression parsing.

While exploring Tree-sitter, I discovered its vast potential for enhancing the editing experience. I started by focusing on the tsx-ts-mode, which I use most frequently, and created a collection of helpful functions to boost productivity. In this post, I’ll share these functions and invite you to contribute your own insights and improvements.

To better understand Tree-sitter, refer to the Emacs Tree-sitter documentation. Chinese readers can also check out 《TreeSit API 详解》. Additionally, you can explore the Tree-sitter structure interactively using M-x treesit-explore-mode and M-x treesit-inspect-mode.

To help you quickly grasp the essentials, here are some of the most commonly used Tree-sitter APIs covered in this post:

  • treesit-node-at: Get the syntax node at the current point.
  • treesit-node-type: Get the node’s type as a string.
  • treesit-node-text: Get the node’s text content as a string.
  • treesit-parent-until: Traverse up the tree to find a parent node matching a condition.
  • treesit-search-subtree: Search the node’s subtree for a descendant matching a condition.
  • treesit-node-child: Get a node’s child at a specific index.
  • treesit-node-prev-sibling: Get the node’s previous sibling.
  • treesit-node-next-sibling: Get the node’s next sibling.
  • treesit-node-start: Get the node’s starting buffer position.
  • treesit-node-end: Get the node’s ending buffer position.

Now, let’s dive into some practical editing functions implemented using Tree-sitter. Keep in mind that all the functions are specific to the tree-sitter-typescript parser and only work in the Emacs 29 built-in tsx-ts-mode.

(defun jsx/kill-region-and-goto-start (start end)
  "Kill the region between START and END, and move the point to START."
  (kill-region start end)
  (goto-char start))

This utility function deletes a specified region and moves the cursor to the start of that region. It serves as a helper function used in other examples.

(defun jsx/empty-element ()
  "Empty the content of the JSX element containing the point."
  (when-let* ((node (treesit-node-at (point)))
              (element (treesit-parent-until node (lambda (n)
                                                    (string= (treesit-node-type n) "jsx_element"))))
              (opening-node (treesit-node-child element 0))
              (closing-node (treesit-node-child element -1))
              (start (treesit-node-end opening-node))
              (end (treesit-node-start closing-node)))
    (jsx/kill-region-and-goto-start start end)))

Inspired by the Vim cit operation, this function finds the JSX element (tag) enclosing the current point, removes its content while preserving the opening and closing tags.

(defun jsx/raise-element ()
  "Raise the JSX element containing the point."
  (when-let* ((node (treesit-node-at (point)))
              (element (treesit-parent-until node (lambda (n)
                                                    (member (treesit-node-type n)
              (element-text (treesit-node-text element t))
              (element-parent (treesit-parent-until element (lambda (n)
                                                              (string= (treesit-node-type n) "jsx_element"))))
              (start (treesit-node-start element-parent))
              (end (treesit-node-end element-parent)))
    (delete-region start end)
    (insert element-text)
    (indent-region start (point))))

Drawing inspiration from the Lispy raises function, this function moves the current JSX tag one level up in the tree hierarchy and removes the original parent tag.

(defun jsx/delete-until ()
  "Delete up to the end of the parent closing."
  (when-let* ((node (treesit-node-at (point)))
              (parent (treesit-parent-until node (lambda (n)
                                                   (member (treesit-node-type n)
              (end (1- (treesit-node-end parent))))
    (delete-region (point) end)))

Akin to the Vim ct command, this function automatically deletes from the current point up to the ending character, such as ", ), ], }, or >. Unlike Vim, you don’t need to manually specify the matching end character.

(defun jsx/kill-attribute-value ()
  "Kill the value of the JSX attribute containing the point."
  (when-let* ((node (treesit-node-at (point)))
              (attribute (treesit-parent-until node (lambda (n)
                                                      (string= (treesit-node-type n) "jsx_attribute"))))
              (value (treesit-node-child attribute -1)))
    (let ((start (1+ (treesit-node-start value)))
          (end (1- (treesit-node-end value))))
      (jsx/kill-region-and-goto-start start end))))

This convenient function clears the value of the JSX attribute containing the point.

(defun jsx/declaration-to-if-statement ()
  "Convert the variable declaration at point to an if statement."
  (when-let* ((node (treesit-node-at (point)))
              (parent (treesit-parent-until node (lambda (n)
                                                   (string= (treesit-node-type n) "lexical_declaration"))))
              (value (treesit-search-subtree parent (lambda (n)
                                                      (string= (treesit-node-type n) "call_expression"))))
              (value-text (treesit-node-text value t))
              (start (treesit-node-start parent))
              (end (treesit-node-end parent)))
    (delete-region start end)
    (insert (format "if (%s) {\n\n}" value-text))
    (indent-region start (point))
    (forward-line -1)

Although rarely used, this function can be very handy in specific scenarios. It converts a variable declaration into an if statement, using the variable’s value as the condition.

(defun jsx/kill-by-node-type ()
  "[Experimental] Kill the node or region based on the node type at point."
  (let* ((node (treesit-node-at (point)))
         (node-text (treesit-node-text node t)))
    (pcase node-text
      ((or "." ":" ";" "<" "</" ">" "(" ")" "[" "]" "{" "}")
       (call-interactively 'backward-kill-word))
      ((or "'" "\"" "`")
       (let* ((parent-node (treesit-node-parent node))
              (start (1+ (treesit-node-start parent-node)))
              (end (1- (treesit-node-end parent-node))))
         (jsx/kill-region-and-goto-start start end)))
       (when-let* ((prev-node (treesit-node-prev-sibling node))
                   (start (treesit-node-start prev-node))
                   (end (treesit-node-end node))
                   (space-prefix (string= (buffer-substring-no-properties (1- start) start) " ")))
         (jsx/kill-region-and-goto-start (if space-prefix (1- start) start) end)))
      (_ (kill-region (treesit-node-start node) (treesit-node-end node))))))

This experimental function aggressively deletes the current Tree-sitter node under the point. However, for punctuation characters, it employs backward-kill-word to maintain consistent deletion behavior.

Finally, let’s assign these functions to some handy keybindings. The example functions presented here are just a fraction of the available functions. Please note that all functions are still under active development. You can find the most up-to-date code in my .emacs.d repository. If you have any ideas for improvement or suggestions, I’d be delighted to hear from you!

(add-hook 'tsx-ts-mode-hook (lambda ()
                              (define-key tsx-ts-mode-map (kbd "C-<backspace>") 'jsx/kill-by-node-type)
                              (define-key tsx-ts-mode-map (kbd "C-c C-k") 'jsx/kill-block)
                              (define-key tsx-ts-mode-map (kbd "C-c C-w") 'jsx/copy-block)
                              (define-key tsx-ts-mode-map (kbd "C-c C-x") 'jsx/duplicate-block)
                              (define-key tsx-ts-mode-map (kbd "C-c C-SPC") 'jsx/select-block)
                              (define-key tsx-ts-mode-map (kbd "C-c C-u") 'jsx/delete-until)
                              (define-key tsx-ts-mode-map (kbd "C-c C-;") 'jsx/comment-uncomment-block)
                              (define-key tsx-ts-mode-map (kbd "C-c C-t C-e") 'jsx/empty-element)
                              (define-key tsx-ts-mode-map (kbd "C-c C-t C-r") 'jsx/raise-element)
                              (define-key tsx-ts-mode-map (kbd "C-c C-t C-p") 'jsx/move-to-opening-tag)
                              (define-key tsx-ts-mode-map (kbd "C-c C-t C-n") 'jsx/move-to-closing-tag)
                              (define-key tsx-ts-mode-map (kbd "C-c C-a C-k") 'jsx/kill-attribute)
                              (define-key tsx-ts-mode-map (kbd "C-c C-a C-w") 'jsx/copy-attribute)
                              (define-key tsx-ts-mode-map (kbd "C-c C-a C-v") 'jsx/kill-attribute-value)
                              (define-key tsx-ts-mode-map (kbd "C-c C-a C-p") 'jsx/move-to-prev-attribute)
                              (define-key tsx-ts-mode-map (kbd "C-c C-a C-n") 'jsx/move-to-next-attribute)
                              (define-key tsx-ts-mode-map (kbd "C-c C-s") 'my/open-or-create-associated-scss-file)))

By leveraging the power of Tree-sitter and these custom functions, you can significantly enhance your TypeScript and JSX editing experience in Emacs. Experiment with these functions, adapt them to your workflow, and share your own innovations with the community. Happy coding!

Did you enjoy reading this post? I'd be thrilled to have you join me on my journey of exploration and creation. Let's keep discussing this post on Twitter. Keep in touch!