Structural Editing of JSX in Emacs with Tree-sitter
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.