Better TSX support w/ Tree-sitter in Doom Emacs
I don’t like TypeScript, but for science/torturing myself, I decided to try it in a small side project. Naturally, I need to set up the development environment for it in Emacs. Luckily it’s pretty easy to do in Doom Emacs. All I need to do is to enable the javascript and web layer in init.el, but there is a catch (or this article won’t exist).
Update 2023-04-09
If you are using Emacs 29, you can use the built-in Tree-sitter support for TSX/TS. See Typescript/TSX in (Doom) Emacs 29+.
Update 2022-11-23
If you’re using emacs 29, feature/tree-sitter just got merged, and you can use the built in
TLDR;
View The Final Result or use emacs-mac (if you are using macOS).
The catch
I have a desktop with Ryzen 3900X and a MacBook Pro 2019(I know, I’m sorry). So this problem didn’t occur to me until I opened the project on the Macbook Pro. Emacs lags so much when navigating in the .tsx files. And I remembered that a former colleague was complaining about this a couple of days ago, and I was like: “It works for me.”. It’s not now, so I started digging.
typescript-mode & web-mode
Doom Emacs uses a major mode derived from web-mode to handle .tsx files, because typescript-mode does not support TSX. I agree with their decision after I read part of the web-mode code, like this function handles the indentation. A 700+ lines code function is called just for I press enter in a web-mode buffer. web-mode is a great package and I understand that it has to supports soooo many template languages, but it’s just too complicated.
In the typescript-mode thread, someone mentioned using typescript-mode + Tree-sitter to handle .tsx files caught my attention. Since I was running Tree-sitter in my configuration for weeks, and it works pretty well. So I tried it.
typescript-mode + Tree-sitter
I already configured Tree-sitter in my configuration, copied from a pending PR from Doom Emacs.
- Update: there are another PR working for better Tree-sitter integration to Doom.
packages.el
(package! tree-sitter
:ignore (null (bound-and-true-p module-file-suffix)))
(package! tree-sitter-langs
:ignore (null (bound-and-true-p module-file-suffix)))
config.el
(use-package! tree-sitter
:when (bound-and-true-p module-file-suffix)
:hook (prog-mode . tree-sitter-mode)
:hook (tree-sitter-after-on . tree-sitter-hl-mode)
:config
(require 'tree-sitter-langs)
(global-tree-sitter-mode)
(add-hook 'tree-sitter-after-on-hook #'tree-sitter-hl-mode)
(defadvice! doom-tree-sitter-fail-gracefully-a (orig-fn &rest args)
"Don't break with errors when current major mode lacks tree-sitter support."
:around #'tree-sitter-mode
(condition-case e
(apply orig-fn args)
(error
(unless (string-match-p (concat "^Cannot find shared library\\|"
"^No language registered\\|"
"cannot open shared object file")
(error-message-string e))
(signal (car e) (cadr e)))))))
So I added the suggestion from the issue thread in typescript-mode.
(use-package! typescript-mode
:mode ("\\.tsx\\'" . typescript-tsx-tree-sitter-mode)
:config
(setq typescript-indent-level 2)
(define-derived-mode typescript-tsx-tree-sitter-mode typescript-mode "TypeScript TSX"))
(after! tree-sitter
(add-to-list 'tree-sitter-major-mode-language-alist '(typescript-tsx-tree-sitter-mode . tsx)))
And it works! JSX syntax highlighted, lsp-mode works, but(this is the last “but”; I promise.), since typescript-mode does not support JSX, it doesn’t know how to indent the code, of course, you can still manually press tab, but that’s not what we want from a “smart” editor, right?
Indentation: a failed try
I started to figure out the indentation. At first, I found this package tree-sitter-indent.el, it seems in a fairly early stage, only supports rust right now, it does have some instruction to adding new language support. But it requires some deep knowledge in Tree-sitter and the language parser that I don’t have, and I also only want to handle the JSX part, leave the TypeScript part to typescript-mode, so I spent 4h to come up with this…
(defun typescript-tsx-tree-sitter-indent-line ()
(if-let* ((col (typescript-tsx-tree-sitter--proper-indentation))
(offset (- (current-column) (current-indentation))))
(progn
(indent-line-to col)
(move-to-column (+ offset col)))
(typescript-indent-line)))
(defun typescript-tsx-tree-sitter--in-tsx (node-type parent-type)
(--some? (s-starts-with? "jsx" it)
(--map (format "%s" it) (list node-type parent-type))))
(defun typescript-tsx-tree-sitter--proper-indentation ()
(save-excursion
(back-to-indentation)
(-let* ((node (tree-sitter-node-at-point))
(parent (tsc-get-parent node))
(node-type (tsc-node-type node))
(parent-type (tsc-node-type parent)))
;; only handles jsx related indention
(when (typescript-tsx-tree-sitter--in-tsx node-type parent-type)
;; (message "current: %s" node-type)
;; (message "parent: %s" parent-type)
(cond
((and (member node-type '("{" "}"))
(eq 'jsx_expression parent-type))
(+ (cdr (tsc-node-start-point (tsc-get-parent parent))) typescript-indent-level))
((eq 'jsx_expression parent-type)
nil)
((eq 'jsx_attribute parent-type)
(+ (cdr (tsc-node-start-point (tsc-get-parent parent))) typescript-indent-level))
((eq 'jsx_expression node-type)
(+ typescript-indent-level (cdr (tsc-node-start-point node))))
((member node-type '(">" "/"))
(cdr (tsc-node-start-point parent)))
((eq 'jsx_closing_element parent-type)
(cdr (tsc-node-start-point (tsc-get-parent parent))))
((eq 'jsx_self_closing_element parent-type)
nil)
((eq 'jsx_self_closing_element node-type)
(+ (cdr (tsc-node-start-point node)) typescript-indent-level))
(t
(+ (cdr (tsc-node-start-point parent)) typescript-indent-level)))))))
It’s kind of working, but so much worse than the web-mode version. And I believe I missed lots of use cases.
Indentation with rjsx-mode
Before I got frustrated, I started wondering: “Why .jsx files’ indentation works so well?”.
The answer is rjsx-mode, it’s a lot simpler than web-mode, and of course, does not support TypeScript, but I only want it to handle the indentation. It should be easy, right?
The answer is YES! rjsx-mode provides a rjsx-minor-mode which parses JSX syntax into AST, it can then be used in other rjsx-mode function, in this case, rjsx-indent-line. It will probably comes with a performance cost, but I feel it’s worth.
The Final Result
Here is the working configuration I ended up with, even some bonus “electric” action from rjsx-mode.
(use-package! typescript-mode
:mode ("\\.tsx\\'" . typescript-tsx-tree-sitter-mode)
:config
(setq typescript-indent-level 2)
(define-derived-mode typescript-tsx-tree-sitter-mode typescript-mode "TypeScript TSX"
(setq-local indent-line-function 'rjsx-indent-line))
(add-hook! 'typescript-tsx-tree-sitter-mode-local-vars-hook
#'+javascript-init-lsp-or-tide-maybe-h
#'rjsx-minor-mode)
(map! :map typescript-tsx-tree-sitter-mode-map
"<" 'rjsx-electric-lt
">" 'rjsx-electric-gt))
(after! tree-sitter
(add-to-list 'tree-sitter-major-mode-language-alist '(typescript-tsx-tree-sitter-mode . tsx)))
emacs-mac
All the above is I trying to improve TSX support in Doom Emacs with emacs 28 w/ native-compile on a macbook.
A day after that, I’m trying out the emacs-mac port, turns out it can massively improve the Emacs performance on macOS, way much than emacs 28 w/ native-compile. I guess the biggest problem is not web-mode or lisp after all, but I feel it still helps. The rjsx electric magic is fantastic.
More on Tree-sitter
There is another take on integrating tree-sitter into emacs core, I hope this can make tsx or in general web development better in Emacs.