web-mode + eglot + Vetur + Vue.js = happy

Recently, I’ve been writing code for a work project that involves a Vue.js front end, written in a mix of JavaScript and TypeScript, along with a backend API that’s completely TypeScript. My emacs config gave me great tooling when I was working on the API, but support for the front end code felt really lacking in comparison. Yesterday, thanks to a lucky discovery, I solved this. I’m really happy about it!

(If you just want the config change TL;DR bit without all the narrative, it’s at the bottom of the post.)

The lucky discovery came when I contemplated switching over to VS Code for working on the front end code. When I opened one of the Vue single file components in VS Code for the first time, it prompted me to install an extension called Vetur. That’s how I learned that Vetur is an extension that provides a Vue-specific LSP server called vls, which provides all the tooling support for working with Vue files in VS Code. (If you’re not familiar with LSP, or Language Server Protocol, there’s a good overview on Wikipedia.)

In my Emacs config, I use eglot for LSP integration. It’s great and doesn’t require much setup. The way eglot decides which LSP engine to use for a particular file depends on the major mode of that file and a variable called eglot-server-programs, which provides a mapping between major modes and engines. Since I use web-mode to edit Vue code, my initial attempt looked like this:

(add-to-list 'eglot-server-programs '(web-mode "vls"))

After I installed vls (by running npm install -g vls), I opened up a Vue file and ran M-x eglot, which starts up the configured server in the background and starts using it manage stuff in the buffer. It worked! This was great!

Since I didn’t want to have to remember to manually start up Eglot every time I started editing a Vue file, I next added this bit of configuration:

(add-hook web-mode-hook #'eglot-ensure)

This makes sure that whenever a file using web-mode is opened, there’s an Eglot process either already running. If not, one gets started up. (Eglot uses one server instance per project to cut down on resource usage.)

So I added that bit of config, restarted Emacs, opened a Vue file, and everything worked! Awesome! But then I tried doing something else with a normal HTML file in web-mode, and …dear readers, things got unpleasant. The Eglot server started up, it expected a Vue file, found an HTML one, and then it threw all the errors and warnings.

I was so bummed at this point! It felt like I was sooooo close to solving this problem, only to get derailed at the last instant because I use web-mode for more than just Vue files. I started thinking about how I could have a major mode, like web-mode, but dedicated just to Vue …and then I remembered define-derived-mode!

If you haven’t run across it before, define-derived-mode lets you make a new major mode that’s basically a copy of an existing one. All the configuration — variables, hooks, etc — that are associated with the existing mode apply to the new one, and you can add additional configuration that’s specific to the new mode too. (In OOP terms, the new mode is essentially a child of the parent mode.)

I added some code to make a new mode, called genehack-vue-mode. Then I configured things so that Vue files open in that mode and that mode is associted with the vls server on the Eglot level. Finally, I added the eglot-ensure call to genehack-vue-mode-hook. After that, problem solved!

tl;dr

If you just want something to copy and paste, here you go:

(require 'eglot)
(require 'web-mode)
(define-derived-mode genehack-vue-mode web-mode "ghVue"
"A major mode derived from web-mode, for editing .vue files with LSP support.")
(add-to-list 'auto-mode-alist '("\\.vue\\'" . genehack-vue-mode))
(add-hook 'genehack-vue-mode-hook #'eglot-ensure)
(add-to-list 'eglot-server-programs '(genehack-vue-mode "vls"))

Make sure you’ve got eglot and web-mode installed, naturally …and don’t forget to run install the vls command via NPM. Make sure the location it is installed to is in exec-path so Emacs can find it, or use the full path when adding it to the eglot-server-programs alist.

Hopefully, writing this up saves somebody else the time of figuring out how to do it…