I’ve been using VSCode as my defacto Typescript IDE for the last few months. I’m a heavy Emacs user, however, and it was only a matter of time until I attempted to get a similar experience via Emacs configuration, thereby continuing my quest to use Emacs as the sole interface to my computer.

I took the plunge last weekend, thinking I’d start with something “simple” like getting linting with eslint to work. Little did I know…

Batteries (Not) Included

When you think of linting in Emacs, you immediately think of Flycheck. Luckily for me (I thought), Emacs flycheck has eslint support built in. So theoretically, all I have to do is add

(flycheck-mode +1)

to my Emacs config and I’m good to go. As I’m editing files, this package will call eslint asynchronously as a shell command and report back the results.

As I’m editing, however, I notice results are coming back with a lot of lag. Like, 5-10 seconds of lag. Huh. Is this an Emacs thing or a eslint thing? So I pop open a terminal and do

$ cd my_proj/
$ time eslint my_file.ts
real    0m6.684s
user    0m9.321s
sys     0m0.821s

And sure enough, the linter takes 9 seconds to complete. But I was just in VSCode editing this same exact file, and linting was happening almost instantaneously… what gives??!!

Linting => Type Checking => Compiling

Turns out, most people won’t run into this slowness unless they enable certain eslint options in .eslintrc.js. Ours happens to look something like this

module.exports = {
  parser: '@typescript-eslint/parser',
  plugins: ['import', '@typescript-eslint'],
  parserOptions: {
    ecmaVersion: 2018,
    sourceType: 'module',
    project: './tsconfig.json',
  }
  ...
}

You’ll notice we have parser set, which tells eslint to use a plug-in to read the typescript syntax. We also have parserOptions.project set to ./tsconfig.json, which tells our parser to include type information in the parse, so we can use it to make type-aware eslint rules.

Unfortunately, enabling this option is known to be very slow since it requires compiling the entire typescript project to get the type information. This lines up with my experience, since doing

$ cd my_proj
$ time tsc 
real    0m4.968s
user    0m8.181s
sys     0m0.689s

takes around 8 seconds. So that’s 8 seconds to compile the project, and < 1 second to run the actual linter rules. And as expected, removing parserOptions.project speeds up eslint to under 1 second.

The Difference That Makes the Difference

So why is VSCode so fast?

Linting in VSCode is done by the ESLint Extension, which claims to just call eslint in the background. Thinking that couldn’t possibly be true, I dug into the source code.

Turns out they do call eslint, just not from the shell. Instead, they import eslint into a node process and call it repeatedly as the file changes. I suspected this meant that the process was able to cache the AST coming back from tsc, and only compile the parts that changed. I put together a little proof-of-concept and what do you know…

eslint = require('my_proj/node_modules/eslint/lib/api.js')
cli = new eslint.CLIEngine({ cwd: 'my_proj' })

// This is slow, takes ~9 seconds
cli.executeOnFiles(['my_proj/my_file.ts']).results[0].messages

// This is fast, one second at most
cli.executeOnFiles(['my_proj/my_file.ts']).results[0].messages

// This is still fast, even if we change the underlying file to introduce a previously unseen linter error
introduceLintError('my_proj/my_file.ts')
cli.executeOnFiles(['my_proj/my_file.ts']).results[0].messages

// And it's fast on other files we haven't loaded before
cli.executeOnFiles(['my_proj/other_file.ts']).results[0].messages

The Fix?

My first intuition was to just stop calling eslint from the shell. Instead, I imagined I could do something like this:

  1. Write a short node server that receives HTTP POST requests containing filenames to lint. It would then call out to eslint.CLIEngine and return the lint errors as JSON.
  2. Write a flycheck checker that would just curl the endpoint.

As soon as I wrote down that plan, however, I realized I was basically describing an eslint language server. So I looked up if Emacs lsp-mode had a defacto eslint langauge server and surprise surprise, they pointed me to the VSCode ESlint extension.

So what I was looking for was with me the whole time. All I needed to do was add lsp-mode to my config

(use-package lsp-mode
  :ensure t)

and then install the eslint server with M-x lsp-install-server. And voilà, we have lightning fast linting, just like VSCode.

Epilogue

The language server fix is perfectly acceptable. In fact, a standardized LSP syntax checker seems like a logical successor to the Flycheck framework for linting.

It seems to me, though, that we could have made the flycheck version work. Whatever caching the parser is doing could reasonably be serialized to disk between calls to the eslint command line tool. I imagine we could add a setting like parserOptions.cacheASTFname that would tell eslint where to store that information. This would be in line with the behavior other caching options like the built-in --cache.

Do I Hate Emacs?

No, I still like Emacs. And despite this little saga taking me the better part of three days, I’m happy to have the tools to get to the root of the problem and fix it myself.