question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Complete rewrite of ESLint

See original GitHub issue

Introduction

ESLint was first released in 2013, meaning it will be ten years old next year. During that time, the way people write JavaScript has changed dramatically and we have been using the incremental approach to updating ESLint. This has served us well, as we’ve been able to keep up with changes fairly quickly while building off the same basic core as in 2013. However, I don’t believe continually to make incremental changes will get us to where ESLint needs to go if it wants to be around in another ten years.

Even though we are close to rolling out the new config system, which is the first significant rearchitecture we’ve done, that effort is what led me to believe that it’s time for a larger rewrite. We are still stuck on implementing things like async parsers and rules because it’s difficult to plot a path forward that doesn’t cause a lot of pain for a lot of users. This seems like the right time to stop and take stock of where we are and where we want to go.

Goals

I’ve been thinking about where I’d like ESLint to go next and have come up with several goals. These are pretty abstract at the moment, but here they are, in no particular order:

  1. Completely new codebase. Starting with a completely new repo will allow us to continue to maintain the current version of ESLint as long as necessary while ensuring we are making non-breaking changes on a new version.
  2. ESM with type checking. I don’t want to rewrite in TypeScript, because I believe the core of ESLint should be vanilla JS, but I do think rewriting from scratch allows us to write in ESM and also use tsc with JSDoc comments to type check the project. This includes publishing type definitions in the packages.
  3. Runtime agnostic. ESLint should be able to run in any runtime, whether Node.js, Deno, the browser, or other. I’d like to focus on creating a core package (@eslint/core) that is runtime agnostic and then runtime specific packages (@eslint/node, @eslint/browser, etc.) that have any additional functionality needed for any given runtime. Yes, that means an officially supported browser version!
  4. Language agnostic. There’s nothing about the core of ESLint that needs to be JavaScript specific. Calculating configurations, implementing rules, etc., are all pretty generic, so I’d like to pull the JavaScript-specific functionality out of the core and make it a plugin. Maybe @eslint/js? I envision a language implementation being distributed in a plugin that users can then assign to specific file patterns. (This would replace the parserForESLint() hack.) So ESLint could be used to lint any file format so long as someone as implemented an ESLint language API for it.
  5. New public APIs. Our public API right now is a pretty messy thanks to the incremental approach we’ve taken over the years. ESLint was never envisioned to have a public API beyond the Linter class (which started out as a linter object) and we’ve continued hacking on this. Right now we have both an ESLint class and a Linter class, which is confusing and they both do a lot more than just lint. I’d like to completely rethink the public API and provide both high-level APIs suitable for building things like StandardJS and the VSCode plugin and low-level APIs that adhere to the single-responsibility principal to make it possible to do more creative mixing and matching.
  6. Rust-based replacements. Once we have a more well-defined API, we may be able to swap out pieces into Rust-based alternatives for performance. This could look like creating NAPI modules written in Rust for Node.js, writing in Rust and compiling to WebAssembly, creating a standalone ESLint executable written in Rust that calls into the JavaScript portions, or other approaches.
  7. Async all the way down. Async parsing, rules…everything! We’ve had trouble making incremental progress with this, but building from scratch we can just make it work the way we want.
  8. Pluggable source code formatting. Stylistic rules are a pain, so I’d like to include source code formatting as a separate feature. And because it’s ESLint, this feature should be pluggable, so you can even just plug-in Prettier to fulfill that role if you want.
  9. Reporters for output. The current formatters paradigm is limited: we can only have one at a time, we can’t stream results as they complete, etc. I’d like to switch to a reporters model similar to what Mocha and Jest have.
  10. AST mutations for autofixing. This is something we’ve wanted for a long time. I see it as being in addition to the current text editing autofixes and not a direct replacement.

Maybes

These are some ideas that aren’t fully hatched in my mind and I’m not sure how we might go about implementing them or even if they are good ideas, but they are worth exploring.

  • Make ESLint type-aware. This seems to be something we keep banging our heads against – we just don’t have any way of knowing what type of value a variable contains. If we knew that, we’d be able to catch a lot more errors. Maybe we could find a way to consume TypeScript data for this?
  • Make ESLint project-aware. More and more we are seeing people wanting to have some insights into the surrounding project and not just individual files. typescript-eslint and eslint-plugin-import both work on more than one file to get a complete picture of the project. Figuring out how this might work in the core seems worthwhile to explore.
  • Standalone ESLint executable. With Rust’s ability to call into JavaScript, it might be worth exploring whether or not we could create a standalone ESLint executable that can be distributed without the need to install a separate runtime. Deno also has the ability to compile JavaScript into a standalone executable, so Rust isn’t even required to try this.

Approach

For whatever we decide, the overall approach would be to start small and not try to have 100% compatibility with the current ESLint right off the bat. I think we’d added a lot of features that maybe aren’t used as much, and so we would focus on getting the core experience right before adding, for example, every existing command line option.

Next steps

This obviously isn’t a complete proposal. There would need to be a (massive) RFC taking into account all of the goals and ideas people have for the next generation of ESLint. My intent here is just to start the conversation rolling, solicit feedback from the team and community about what they’d like to see, and then figure out how to move forward from there.

This list is by no means exhaustive. This is the place to add all of your crazy wishlist items for ESLint’s future because doing a complete rewrite means taking into account things we can’t even consider now.

Issue Analytics

  • State:closed
  • Created a year ago
  • Reactions:22
  • Comments:14 (11 by maintainers)

github_iconTop GitHub Comments

2reactions
kecrilycommented, Nov 12, 2022

It is recommended to convert this issue to discussion. We need structured comments.

2reactions
bradzachercommented, Nov 11, 2022

HOOOOO BOY. There’s a lot to talk about here.

I’ve got a version of this written up already (https://github.com/typescript-eslint/typescript-eslint/issues/5845#issuecomment-1283248238) but I’ve copied it here so that I can add more context

It’s worth noting that a lot of the problems we run into with type-aware linting also apply in some degree to eslint-plugin-import which does its own out-of-band parsing and caching.


ESLint is currently designed to be a stateless, single-file linter. It and the ecosystem of “API consumers” (tools that build on top of their API - IDEs, CLI tools, etc) assume this to be true and optimise based on the assumption. For most parsers (@babel/eslint-parser, vue-eslint-parser, etc) this holds true - they parse a file and forget about it, and for our parser (@typescript-eslint/parser) in non-type aware mode this also holds true. However when instructed to use type information, our parser now breaks both assumptions - it now stores stateful, cross-file information.

Type-aware linting, unfortunately, doesn’t fit too well into the ESLint model as it’s currently designed - so we’ve had to implement a number of workarounds to make it fit - we’ve fit a square peg into a round hole by cutting the edges of the hole. This, as you can imagine, means there are a number of edge-cases where things can get funky.

ESLint Usecases

ESLint is used by end users in one of three ways:

  1. “One and done” lint runs - primarily done by using eslint folder or similar on your CLI. In this style of run each file is parsed and linted exactly once.
  2. “One and done, with fixers” lint runs - primarily done using eslint folder --fix. In this style of run most files are parsed and linted exactly once, except those that have fixable lint errors that are parsed and linted up to 11 times.
  3. “Continuous” runs - primarily done via IDEs. In this style of run each file can be parsed and linted 0..n times.

For a stateless, single-file system - all 3 cases can be treated the same! In that style of system when linting File A you don’t ever care if File B changes because the contents of File B have zero impact on the lint results for File A. However for a stateful, cross-file system each case needs its own, unique handling. For performance reasons we cache the “TypeScript Program” (ts.Program) once we’ve created it for a specific tsconfig because it’s super expensive to create - so we are storing a cache that needs to correctly react to the state of the project.

Caching

These are the caching strategies that we can use for each usecase. Note that each usecase affords a different caching strategy!

  1. “One and done” runs have a fixed cache - we can assume that file contents are constant and thus that the type information is constant throughout the run.
  2. “One and done, with fixers” runs mostly have a fixed cache, except for those files that get fixed, but as fixers “should not break the build”, we assume that the fixed file contents won’t change the types of other files.
    • This is a slightly unsafe assumption, but the alternative is to treat this case exactly the same as the “continuous” case, which means we hugely impact performance.
    • This assumption allows us to re-check a subset of the project (just the fixed file and its dependencies) with the slower builder API, rather than switching the entire run to the slower builder API - which obviously allows us to remain fast.
  3. “Continuous” runs are the wild wild west. The cache has to be truly reactive as anything can change at any time and any change can impact any and all types in other files.
    • Note that by “anything can change at any time”, I really do mean anything. Files and folders can be created, deleted, moved, renamed, changed at the whim of the user, and most of those changes occur outside of the lint run (mentioned in more detail below)

APIs

TypeScript

TypeScript’s consumer API is built around the concept of “Programs”. A program is esentially a set of files, their ASTs, and their types. For us a program is derived from a tsconfig (eg the user tells us the configs and we ask TS to create a program from the config).

A Program is designed to be immutable - there’s no direct way to update it. To perform updates to a Program, TS exposes another API called a “Builder Program” which allows you to inform TS of changes to files so that it can internally make the appropriate updates to the Program. The builder Program API is much slower for all operations than the immutable Program API - so where possible we want to use the immutable API for performance reasons and only rely on the builder API when absolutely required.

So to line it up with the aforementioned usecases - we want use the immutable API for (1) and most of (2), and fall back to the builder API when a file is fixed in (2), then (3) always uses the builder API.

ESLint’s API

ESLint implements one unified API for a consumer to perform a lint run on 1..n files - the ESLint class.

There are no flags or config options that control how this class must be used by consumers. This means that ESLint cannot distinguish between the above usecases. This makes sense from ESLint’s POV - why would it care when it’s a stateless and single-file system; all the cases are the same to them!

This poses a problem for us though because if ESLint can’t distinguish the cases, then we can’t distinguish the cases and so we’re left with the complex problem of “how can we implement different cache strategies without being able to tell which strategy to use?”

Problems

Cache Strategy and Codepath Selection

As mentioned above, we want to use the immutable Program API where possible as it’s so much faster. We do this automatically by inferring whether or not you’ve run ESLint from the CLI by inspecting the environment. It’s a hack, but it does work for usecase (1). Unfortunately there’s no way for us to differentiate usecase (1) from (2), so we have to have a fallback to switch to the builder Program for usecase (2) so that we can update the Program after a fix is applied. If our detection code doesn’t fire, we just assume we’re in usecase (3), and use the slow but safe codepaths.

Slow lint runs often occur due to incorrect usecase detection due to the user running things in ways we didn’t expect / can’t detect (such as custom scripts), or due to cases we haven’t handled.

Disk Watchers

Ideally we’d attach filewatchers to the disk to detect when the relevant files/folders are changed (would solve the “out-of-editor file updates” problem below). Unfortunately there’s no good way to attach a watcher without creating an “open file handle”. In case you don’t know - open file handles are a huge problem because NodeJS will not exit whilst there are open file handles. Simply put - if we attach watchers and don’t detach them then CLI lint runs will just never exit - it’ll look like the process has stalled and you have to ctrl+c to quit them.

There is no lifecycle API built into ESLint so we can’t tell when would be a good time to clean up watchers. And because we can’t tell the difference between an IDE and a CLI run, we can’t make assumptions and attach watchers either. So ultimately we just can’t use watchers! Thus our only option is to rely on the information ESLint tells us - which is just going to be information about what file is currently being linted - and hope that is enough information.

Live File Updates

This is only a problem for usecase (3). When you make a change to file A in the IDE, the IDE extension schedules a new lint run with the contents from the editor which we use to update the Program. If you have file B that depends on the types from file A, this means that we’ve also implicitly recalculated the type for file B. However, the extension controls lint runs - so we cannot trigger a new lint run on file B. This means that file B will show stale lint type-aware errors until the IDE schedules a new lint run on it.

Single-threaded vs Multi-threaded linting

The implicit update of file B’s types based on changes to file A assume that both file A and B are linted in the same thread. If the aren’t linted in the same thread, then updates to file A will not be updated in file B’s thread, and thus file B will never have the correctly updated types for file A - which leads to incorrect lints!! The only way to fix this would be by restarting the IDE extension (or the IDE itself!)

Out-of-editor File Updates

In all IDEs it’s possible that you can use the “file explorer” to move files around to different folders, or even rename files. This disk change happens outside of the editor window, and thus no IDE extension can or will tell ESLint that such a change occurred. This is a big problem for us because the Program state explicitly depends on the filesystem state!

We have some very slow fallback codepaths for this case that attempts to determine if out-of-editor changes occurred on disk, but it’s not perfect code and can miss cases.


So with all that being said… what would I want to see from a rewritten version of ESLint?

Well the biggest problem we have is that we cannot tell what state ESLint is running in, so we have to rely on fuzzy and unsound logic in order to determine cache strategies.

So I’d really want ESLint to be able to tell parsers and plugins about the state ESLint is running in so that they can make decisions about how to invalidate or update their data-stores. I suspect this means that ESLint will need to have more than one API for consumers instead of the single ESLint API it exposes - but that’s something that can be nutted out later? Hard to say.

Worth mentioning this is something I’ve been thinking about for a long while (eg https://github.com/eslint/eslint/issues/13525), but obviously haven’t had the time to do any formal design or RFCs.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Complete rewrite of ESLint · Discussion #16557 - GitHub
This is the place to add all of your crazy wishlist items for ESLint's future because doing a complete rewrite means taking into...
Read more >
Complete rewrite of ESLint : r/programming - Reddit
A complete greenfield rewrite will mean that most developer resources won't be available for maintaining the existing ESLint code base. The ...
Read more >
Complete Rewrite of ESLint | Hacker News
This statement makes no sense in two ways and shows you're not as accustomed to the problems at hand as you'd perhaps like...
Read more >
Complete Rewrite of ESLint - Brian Lovin
This statement makes no sense in two ways and shows you're not as accustomed to the problems at hand as you'd perhaps like...
Read more >
Configuration Files - ESLint - Pluggable JavaScript Linter
A pluggable and configurable linter tool for identifying and reporting on patterns in JavaScript. Maintain your code quality with ease.
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found