(Posts)

Structural Editing of JSX in Emacs with Tree-sitter

Updated [ Emacs , Tree-sitter , JSX ]
show, don’t tell https://github.com/P233/jsx-jedi

Emacs 29 brought Tree-sitter into the editor, and with it a syntax tree of the buffer that stays up to date as I type. With that tree, an editing command can act on a JSX element, an attribute or a string instead of on characters and lines. I started with tsx-ts-mode, the mode I use most often, and wrote a set of such commands: one empties an element, another raises it out of its parent and another clears an attribute value. This post shares them, along with the keybindings I use.

To learn more about Tree-sitter, see the Emacs Tree-sitter documentation. Chinese readers can also refer to 《TreeSit API 详解》. To explore the Tree-sitter structure interactively, use M-x treesit-explore-mode and M-x treesit-inspect-mode.

Tree-sitter APIs

The functions below rely on these Tree-sitter APIs:

  • 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-node-parent: Get the node’s parent.
  • 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 the node’s child at a specific index.
  • treesit-node-prev-sibling: Get the node’s previous sibling.
  • treesit-node-start: Get the node’s starting buffer position.
  • treesit-node-end: Get the node’s ending buffer position.

The node types they match, such as jsx_element, come from the TSX grammar in tree-sitter-typescript, so the functions work only in tsx-ts-mode, which is built into Emacs 29.

Shared deletion helper

(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 helper kills the given region and moves the point to its start. Several functions below use it.

Emptying a JSX element

(defun jsx/empty-element ()
  "Empty the content of the JSX element containing the point."
  (interactive)
  (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 and removes its content, keeping the opening and closing tags.

Raising a JSX element

(defun jsx/raise-element ()
  "Raise the JSX element containing the point."
  (interactive)
  (when-let* ((node (treesit-node-at (point)))
              (element (treesit-parent-until node (lambda (n)
                                                    (member (treesit-node-type n)
                                                            '("jsx_element"
                                                              "jsx_self_closing_element")))))
              (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))))

This function borrows the idea of the Lispy raise function: it moves the current JSX tag one level up the tree and removes the original parent tag.

Deleting up to a closing delimiter

(defun jsx/delete-until ()
  "Delete up to the closing delimiter of the parent."
  (interactive)
  (when-let* ((node (treesit-node-at (point)))
              (parent (treesit-parent-until node (lambda (n)
                                                   (member (treesit-node-type n)
                                                           '("array"
                                                             "string"
                                                             "arguments"
                                                             "named_imports"
                                                             "object_pattern"
                                                             "formal_parameters"
                                                             "jsx_expression"
                                                             "jsx_opening_element")))))
              (end (1- (treesit-node-end parent))))
    (delete-region (point) end)))

Like the Vim ct command, this function deletes from the current point up to the closing delimiter, such as ", ), ], } or >. Unlike in Vim, you do not have to specify the matching delimiter yourself: the function finds it automatically.

Clearing an attribute value

(defun jsx/kill-attribute-value ()
  "Kill the value of the JSX attribute containing the point."
  (interactive)
  (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))))

With the point anywhere in the attribute, including its name, this function clears the value but keeps the quotes or braces around it.

Converting a declaration to a condition

(defun jsx/declaration-to-if-statement ()
  "Convert the variable declaration at point to an if statement."
  (interactive)
  (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)
    (indent-for-tab-command)))

I rarely need this one. It converts a variable declaration into an if statement and uses the call in its value as the condition: const user = getUser(id); becomes an if (getUser(id)) block with the point on the empty line inside it.

Deleting by node type

(defun jsx/kill-by-node-type ()
  "[Experimental] Kill the node or region based on the node type at point."
  (interactive)
  (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 deletes according to the node under the point. On a quote, it clears the string; on a comma, it deletes the comma and the item before it; on a bracket or other punctuation, it falls back to backward-kill-word. Anywhere else, it kills the whole node.

Keybindings

Finally, here are my keybindings. They also cover functions this post does not show, such as jsx/kill-block and jsx/move-to-next-attribute; the code for all of them, as of this post, is in my .emacs.d repository.

(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)))

None of the functions shown here runs past 20 lines, because the syntax tree already knows where an element, an attribute or a string begins and ends. The work is in choosing the node each one acts on.