Skip to content

Project Matching for External Templates

Keen Yee Liau edited this page Nov 20, 2020 · 3 revisions

Project Matching for External Templates

Consider the following scenario:

A user opens the IDE with the Angular project as the sole workspace.
Initially there are no open files, then the user opens an external template.

What happens next?

The language server receives a textDocument/didOpen notification from the client.

The server needs to find the project that the file belongs to so that it can get hold of the language service instance. Each project has its own language service. Given just the file path, how does the server match the external template with its project?

In the Angular language server implementation, we call projectService.openClientFile(). ProjectService is a singleton that manages all projects and script infos.

openClientFile() will ascend up the directory where the external template is, and look for the nearest tsconfig.json. Once it finds one, it will create and load a Configured project that corresponds to the tsconfig.json.

If the newly opened file is a TypeScript file, the server will check if the file belongs to the Configured project (check whether it is one of the root files or one of their transitive dependencies). If it is, openClientFile() will return the config filename, and the work is done. If it is not, then the server will continue looking for ancestor tsconfig.json by walking up the directory tree.

Now, for external templates, since they are not TypeScript files, they do not strictly belong to any project. As a result, openClientFile() will not return any information.

How View Engine handles project matching

It is inevitable that Angular will need to tell the server the external templates that belong to a project. To do this, Angular LS asks View Engine to perform a global analysis, and returns the information via an API named getExternalFiles(project) that is exposed via the tsserver plugin. Note that the plugin is created on a per-project basis.

To explain what happens next, we need to take a step back and look at the lifecycle of a Configured project. The process is roughly as follows:

Create -> Load (updateGraph) -> Ready -> onChanges -> updateGraph -> Ready

The creation phase is just for setting up some data structures for bookkeeping. The only relevant detail here is that the language service is not yet ready in this phase.

The loading phase is when tsserver loads every root file and crawls their transitive dependencies via project.updateGraph(). updateGraph() is also the method that does incremental compilation and creates a new ts.Program every time the source code changes. It is after the program becomes available that ts.LanguageService becomes ready.

It is important to note that getExternalFiles(project) is called every time the graph is updated.

In View Engine, when this method gets called, we invoke ngLsHost.getAnalyzedModules() to perform a global analysis. These files are then attached to the project.

Why is this not ideal?

On every incremental change, View Engine has to repeatedly perform a global analysis to find all the external templates. Besides the inefficiency, there's also a risk of these files being parsed as TS files. This is because getExternalFiles() are called at various times, and depending on when it's called, the files could be added as root, and thus parsed as TS files. Although we take great care to make sure this does not happen, it is difficult to make sure it does not happen inadvertently in future TypeScript releases.

One example of us handling this very carefully: when a new project is being loaded, external files are added as root. Currently we work around this by checking a special case in getExternalFiles() to return an empty array when the project is empty (no root files in the project yet).

** Currently, externalFiles will also be added as root on a Partial reload. I haven't verified if this will break LS.

So ... how can we do this differently in Ivy LS?

Project Matching in Ivy

Basically, we have three options:

  1. Implement getExternalFiles() for Ivy LS
  2. Make the best guess using the nearest ancestor tsconfig.json
  3. Perform global analysis once, after the project has loaded (right after ngcc)

1. Implement getExternalFiles()

Pros: Guarantee correct behavior, because template information comes from Ivy compiler.

Cons: The downsides are, besides inefficiency, the complexity of implementing a method to retrieve all external templates. We will either have to expose such a method on the compiler, or do some bookkeeping in the compiler adapter. Open question: how to make sure this works well with incremental compilation? We know what files are added, but don't know which files are removed. There is also the risk of accidentally adding external templates as root files, as mentioned above.

My personal take is that we should avoid implementing getExternalFiles() unless absolutely necessary.

2. Make the best guess using the nearest ancestor tsconfig.json

Going back to the very first step, projectService.openClientFile(), we noticed that even though tsserver could not find a matching project for the external template, it would have created Configured projects for tsconfig.json files that it has discovered in the process. These projects are retained for the sake of efficiency.

Equipped with this knowledge, here's what we could do:

Look at all Configured projects discovered by the project service, and match the external template to the project whose tsconfig.json is the nearest ancestor of the template. In practice, this means looking at each project's tsconfig.json and finding the longest matching prefix.

For example:

  1. Opened file: /Users/kyliau/Documents/GitHub/vscode-ng-language-service/integration/project/app/foo.component.html
  2. Say, two Configured projects are created in this fictional setup:
    • /Users/kyliau/Documents/GitHub/vscode-ng-language-service/integration/project/tsconfig.json
    • /Users/kyliau/Documents/GitHub/vscode-ng-language-service/integration/project/app/tsconfig.json

Since /Users/kyliau/Documents/GitHub/vscode-ng-language-service/integration/project/app is the longest matching prefix for foo.component.html, we match the second project to the external template.

But ... this is technically incorrect! The nearest ancestor tsconfig.json is not necessarily the owning project for the external template!

Yes, admittedly, this is just a best guess, so we could be wrong. But let's see what happens if we proceed with this assumption:

  1. We found (guessed) a project for the external template.
  2. As part of handling textDocument/didOpen, we trigger diagnostics.
  3. We get hold of the language service instance and call ngLS.getSemanticDiagnostics().
  4. Angular compiler cannot find the template in this project, so nothing happens.

However, as soon as a TypeScript file is opened, the compiler will do a global analysis, and match the external template back to the right project. See FAQ below for detailed explanation.

In practice, I expect this approach to work well since the nearest tsconfig.json is very often the right (or the only) project.

3. Perform global analysis once project has loaded

The last point in option (2) got me thinking, why don't we just perform the global analysis once, right after the project is loaded? This means when ngcc is done, we enable the language service, and right after that, perform getSemanticDiagnostics() on one of the root (TS) files. This will trigger global analysis and match all the templates to the right project. It is efficient and correct, albeit a little bit hacky.

Given the consideration for correctness, efficiency, and simplicity, I think this approach is the best.

Decision

We will implement (3) since it is a straight-forward solution that gives us full control over the management of external templates. This is implemented in https://github.com/angular/vscode-ng-language-service/pull/988.

Follow-up questions

  1. How come as soon as a TS file is open, external templates automatically get matched to the right project? (in the case of Ivy LS)

When a TS file is open, we trigger diagnostics on that file (remember, ProjectService has no problem matching a TS file to its project). As part of doing so, the Ivy compiler will load all the external resources via the compiler adapter's readResource() method (I call this the global analysis phase - though it might have a more proper name).

In the readResource() method, we get the script snapshot and the script version from the ProjectService. Retrieving the script snapshot will automatically create a ScriptInfo for the external template and attach the script info to the project. We kill two birds with one stone. I think this is the best way to match external templates, and the good news is, we are already doing this, so no extra code necessary.