Deno + Tree Sitter + Emacs

Posted: emacs

I've been spending a bunch of time fiddling with Deno lately, mostly in the form of small scripts and Fresh projects. One thing that hasn't impressed me doesn't have anything to do with Deno itself but rather the tooling around it: setting up Deno with Emacs is a little cumbersome. The problem roots to Deno and TypeScript sharing a file extension (.ts). While this doesn't cause issue for syntax highlighting (since it's the same for both languages), it does cause issue when picking an appropriate language server. Since Emacs generally uses file extensions to associate languages and functionality it's a bit awkward to have Emacs pick the right LSP program.

For individual projects you can use Directory Variables to override Eglot settings just for that directory, telling Eglot that it belongs to a Deno project and not a regular TypeScript project. This approach gets old fast, I don't want to have to create a dir-locals.el file just to get my Deno tooling working.

This lead me to hacking together some Deno project detection into my Emacs configuration. Basically, if Emacs is in a project (that is, a version-controlled folder) and finds a deno.json file, Eglot will select the Deno language server instead of the TypeScript language server. In all other cases, it defaults to TypeScript.

(defun deno-ts-project-p ()
  (when-let* ((project (project-current))
              (p-root (project-root project)))
    (file-exists-p (concat p-root "deno.json"))))

(defun ts-server-program (&rest _)
  (cond ((deno-project-p) '("deno" "lsp" :initializationOptions
                                         (:enable t :lint t)))
         (t '("typescript-language-server" "--stdio"))))

(add-to-list 'eglot-server-programs '(web-mode . ts-server-program))

This technique works great, provided you always version control your Deno projects. Emacs 29 introduces a new variable that we can use to improve project detection for Deno, relying solely on the presence of a deno.json file:

(add-to-list 'project-vc-extra-root-markers "deno.json")

These changes got me thinking, why not create a major mode for Deno? That way all of these configuration options could be shipped with the major mode and Emacs would inform the user in their mode-line whether they're working in a TypeScript project or a Deno project. Moreover, since we're already using Emacs 29 features, what if that major mode was built on tree-sitter? It's a great excuse for finally trying that library out. And so I set out to build deno-ts-mode.

Setting up tree-sitter requires a few extra steps. Firstly, you need Emacs 29.1 (the most recent release) installed. Secondly, you must compile parsers for each language you want to use. For Deno, that means a TypeScript parser and a TSX parser.

Assuming cc is available on your system, you can install both parsers with this script:

(setq treesit-language-source-alist
      '((typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src")
        (tsx "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src")))

(mapc #'treesit-install-language-grammar (mapcar #'car treesit-language-source-alist))

The parsers are installed to your Emacs user directory as DLLs. You can test that everything installed correctly by trying out typescript-ts-mode.

With tree-sitter good to go, let's create a new major mode for Deno that's based on the TypeScript tree-sitter mode:

(define-derived-mode deno-ts-mode
  typescript-ts-mode "Deno"
  "Major mode for Deno."
  :group 'deno-ts-mode)

At this stage, deno-ts-mode is near identical to typescript-ts-mode. The only difference is that Deno shows up in your mode-line when deno-ts-mode is activated, all syntax highlighting is inherited from typescript-ts-mode.

The next step is to figure out whether a visited .ts file should use our new Deno mode or the TypeScript mode. For this we can use our earlier function, deno-project-p, and apply it to auto-mode-alist, the association list mapping file extensions to major modes. Since auto-mode-alist accepts a function, we can create a couple of wrappers around the two major modes in question to resolve based on the result of deno-project-p:

(defun deno-ts--ts-auto-mode ()
  (cond ((deno-ts-project-p) (deno-ts-mode))
        (t (typescript-ts-mode))))

(defun deno-ts--tsx-auto-mode ()
  (cond ((deno-ts-project-p) (deno-ts-mode))
        (t (tsx-ts-mode))))

(add-to-list 'auto-mode-alist '("\\.ts\\'" . deno-ts--ts-auto-mode))
(add-to-list 'auto-mode-alist '("\\.tsx\\'" . deno-ts--tsx-auto-mode))

When visiting a .ts or .tsx file, Emacs will smartly select either deno-ts-mode or typescript-ts-mode (or TSX) based on the presence of a deno.json file. Pretty great!

This simplifies our Eglot setup considerably, since there's no need to check deno-project-p. The major mode has solved that already!

(use-package eglot
  :ensure t
  :hook ((deno-ts-mode . eglot-ensure))
  :config
  (add-to-list 'eglot-server-programs
               '(deno-ts-mode . ("deno" "lsp" :initializationOptions
                                              (:enable t :lint t)))))

These features plus a few more (task automation, accepting deno.json and deno.jsonc) are all available in my package deno-ts-mode. Give it a try and let me know what you think!


Thanks for reading! Send your comments to [email protected].