Better TSX support w/ Tree-sitter in Doom Emacs

  1. emacs
  2. doom
  3. typescript

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

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.