Description
Background
We've heard from many TypeScript and JavaScript developers that large codebases suffer from massive startup delays in the editor. This is often due to program construction time. Before any semantic operation can be triggered, TypeScript has to effectively do the following:
- Find out which project a file belongs to
- Load that project
- loop: Read each of the given files
- Parse the file
- Look through the imports/exports and resolve each import path
- If new files were found, add them to the program's file list and go to
loop
.
All this is unfortunately synchronous today, and even if it wasn't, this might still end up being a lot of work for larger projects. For users with many files and non-trivial resolution schemes, we've witnessed program construction times that have taken minutes.
This can be frustrating for lots of users whose first tasks might just include navigation and some basic editing.
Basic Code Navigation
Let's take the following code and assume a user requests to go-to-definition on the import
statement:
import * as foo from "./foo";
If TypeScript is still loading the program, it can't answer where ./foo
resolves to.
But it's a relative path, and for most users this feels like something so obvious and cheap to compute. Most users could guess which file it points to be right. If there was a lightweight way to say "it should probably take you here" until a program is fully loaded up, it might get 99% of the job done for 99% of people. If there was a way to guess 2-3 locations and give the editor a list of locations to look up, that might just solve the issue.
Basic Completions
Let's take the following and assume the user requests completions in each of the comments:
import { SomeClass } from "some-module";
function foo() {
function bar() {
b/*inBar*/
}
function baz() {
SomeClass./*afterDot*/
}
new /*afterNew*/
}
class CurrentClass {
/*inClass*/
}
Completions at /*inBar*/
and /*afterNew*/
technically need a full program to tell us what globals are available, but a lot of the time users might be able to get by with locals until the program is fully loaded - after all, this was the basic experience in lots of editors before they had TypeScript support. We could still build up the list of local symbols and list those as the highest-priority completions, followed by a list of local identifiers.
When it comes to /*afterDot*/
something like what's proposed in #5334 might be good enough - just provide a list of local identifiers.
A completion at /*inClass*/
could limit itself to contextual keywords (e.g. public
, private
, protected
, readonly
, abstract
, whatever) and that would still be useful.
Proposal
Some editors spin up two TS Server instances: one which looks for full semantic information, and one which only answers file-local syntactic questions. So one idea we've had is: could we give the syntactic server some basic heuristics to provide answers to editor questions that appear obvious, or where incomplete answers are good enough?
The proposal is to enable syntax-only instances of TypeScript's language service to answer a set of semantic-like questions that includes
- Go-to-Definition on
- relative paths
- locally declared functions and variables
- Completions based on
- Locally scoped identifiers
- Keywords based on different syntactic contexts
Go to Definition
On Imports to Relative Paths
When it comes to relative paths, there's at least the following resolution scheme:
- If it has an extension
- remove it, try to resolve by appending
.ts
,.tsx
,.d.ts
,.js
,.jsx
- try loading the file by that path (e.g.
.json
files)
- remove it, try to resolve by appending
- Try appending
.ts
,.tsx
,.d.ts
,.js
,.jsx
to the current path
TypeScript can potentially provide an ordered list of lookup locations back to the editor, or just guess at one. The editor can try all of these, wait, and return the first "preferred" definition, but we'd need to sync with editor teams to see how feasible this would be.
Alternatively, the syntax server can perform the work, but there are some complications. See the open questions below.
On Imported Bindings
In
import { foo } from "./foo";
/**/foo();
It might be okay for go-to-definition to be the same as go-to-definition
for ./foo
itself if it's prohibitively expensive to load/parse/bind a file on the fly (though we're going to do that once it's opened in the editor anyway).
On Local Bindings
TypeScript can potentially bind each source file on demand, and walk up the AST to resolve the requested symbol, and jump to the definition. This means that go-to-definition definitely wouldn't work on globals across files, and it might be able to jump to the correct file of certain imports, but it would at least definitely work within the current file.
Completions
Completions could work by providing the list of contextual keywords based on the current AST (which we already have at least in part), along with a list of either file-local identifier completions or whatever's in the current chain of file-local symbol tables.
Editor Usage
Editors could use this new functionality by changing certain commands to be multi-step commands: see if the semantic language service is in the middle of loading a project first, use the syntactic version of the command, and then try to fire semantic a syntax command if that fails (e.g. for go-to-definition, none of the operations worked).
Alternatively, it can have both operations race against each other. Try both concurrently, see which completes first, and then either cancel the operation the semantic server if it loses or throw away the results of the syntax server if it loses.
Prior Art
Current Architecture
The syntax server existing in the first place gives some legitimacy to this approach - a server for answering questions that doesn't know the entire state of the world.
Roslyn
Roslyn has some of the same concepts but with a different approach. Roslyn makes a lot of their architecture asynchronous and lazy, and if results aren't currently available, they'll provide an incomplete experience - for example, completion lists aren't always available for the entire program. The approach is drastically different from what's being proposed here, but it's fundamentally the same idea: work with what you've got until you've got it all.
Open Questions
Can we return locations that don't exist in navigation?
We mention above the idea of guessing file locations - but those files might not exist on disk! Some editors might not expect an invalid location to be returned. We'll have to chat with editor teams to understand whether this is actually a problem or not. Without an opt-in flag, it might be.
Does go-to-definition need to return a ranked list of files to try?
Today, go-to-definition just returns a list of locations. In this version of go-to-definition, we might want to provide a list of potential lookup locations for the editor - but they're not all equally weighted. You don't want to provide the .js
file in the same list of definitions as the .ts
file. It seems like this version of go-to-definition needs to be slightly altered from the existing version.
Can the server just resolve the files itself instead returning a ranked list of files?
Technically it can but TSServer is synchronous. Maybe for a max of 5-10 file existence checks this wouldn't be too bad, but we should figure out if it's better for editors to decide.
Do we need new TSServer commands?
Maybe! Technically, we can have existing TS Server commands respond differently between the syntax server and the semantic server, but to be honest, I think we'd be better off not doing this. Some of this new functionality might need opt-in flags, so I think it would be better to make this entire experience opt-in via a separate language server command.
Is TypeScript providing its own list of completions actually better than most editors' naive implementation of completions?
It's not entirely clear whether TypeScript's "look at every identifier in the file" approach is actually going to be better than what an editor like VS Code does in a plaintext file. We should understand what they do for plaintext and see if there's actually anything we can provide that's better.
Should editors ever fall back to sytnax operations once the semantic server is done?
Operations like go-to-definition can often trigger new project loads. This will block the semantic server again. It feels like using the syntax server is the right call, but this is another place where "not everything works perfectly" might be confusing.
Would this experience too different/incomplete?
It might be very jarring if a user got drastically different results from a normal editing session. Hopefully visual indicators from the editor can give a hint that results will be slightly incomplete, but a big goal here is to capture as much of the current experience in a lightweight way.