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!