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