Content mappers - #4712
Conversation
|
I cloned vuejs/language-tools and had Copilot take its best shot at making a real content mapper from @vue/language-core. The repo contains a corpus of 222 Vue project fixtures. 210 produce the same output with the content mapper and with
This intentionally doesn’t silence parse errors, so I think most of the remaining errors are things that should at least be investigated as a fix in the language core. The result is 2.4x faster than I did this just to help myself prove out the TypeScript implementation; I looked at the AI code only enough to know the results weren’t completely fabricated. I don’t plan to PR it but the Vue team is welcome to reference it or pull from it insofar as it’s useful. I’m pushing my branch only to back up my claims that it’s mostly working and it’s pretty fast: https://github.com/vuejs/language-tools/compare/master...andrewbranch:language-tools:feat/vue-content-mapper?expand=1 |
|
One more thing I forgot to mention earlier, is that it would be nice to have the ability to have some logging, both in the editor and in the CLI. I imagine a But also a server → client |
…ntity goes into build signatures
|
Bikeshed request: I don’t love the name |
|
How about: {
"typescript": {
"contentMapper": {
// …
}
}
}This provides a namespace that can be reused for other purposes in future TypeScript versions. |
|
|
I’m making one more change to the shape of |
|
I ported most of the MDX Volar mapper to a content mapper, and it works well. What remains mostly is a lot of tests. One specific mapping broke. MDX supports ESM syntax, so auto-imports are useful. However, the file might not have imports yet. As a workaround, I added a strategically placed empty mapping next to a generated import in the virtual content. The TypeScript content mapper breaks on this empty mapping. It would be useful if content mappers can provide explicit locations where auto-imports may be inserted. |
|
I was thinking there might be a fairly easy change for the code that decides where it wants to insert imports to skip over incompatible spans when it's working in a virtual text file. I'll look into that. |
|
Sorry, this run was polluted by memory sampling. It’s actually 2x faster than typescript-native-bridge currently. |
|
vuejs/language-tools@76d07d9 shows how extensions can contribute a JSON schema for their content mapper options that will get merged into the tsconfig schema.
|

Implements #2824 (comment)
Overview
Content mappers are external integrations that allow TypeScript to include otherwise unsupported file types in a program. They transform a foreign file’s original text into valid TypeScript syntax and provide mappings between the original and transformed content.
Users specify a set of file extensions to be handled by a content mapper package in a
tsconfig.jsonfile:{ "compilerOptions": { // ... }, "contentMappers": [ { "package": "vue-content-mapper", "extensions": [".vue"], "options": { "strictTemplates": true } } ], "include": ["src"] // implicitly includes .vue as well as .ts }When
contentMappersare specified,tscmust be run with--runExternalCode. VS Code passes--runExternalCodetotsc --lsponly in trusted workspaces; otherwise,contentMappersare ignored in the LSP server.The
packagefield will be resolved as a Node.js module name. The optionaloptionsfield must be an object and is passed through to the mapper.The package.json of the content mapper package must specify a
typescripttop-level field with a nestedcontentMapperfield describing how to spawn the mapper process and what compiler options its transform requires. A mapper that reads additional project-specific configuration from external sources beyond those compiler options can additionally declaredynamicConfig: true:{ "name": "vue-content-mapper", "version": "1.0.0", "typescript": { "contentMapper": { "exec": ["node", "dist/server.js"], "compilerOptions": ["module", "jsx", "jsxImportSource"], "dynamicConfig": true } } }Note that the content mapper process need not be run with Node.js or implemented in JavaScript; package resolution serves as a convenient way to associate a content mapper with a versioned identity that can be managed alongside other dependencies in a project, but the
execfield can specify any command.VS Code extensions can also register content mapper integrations with the TypeScript extension. A registration always supplies the extensions that should trigger configured-project discovery, and may additionally provide an inline manifest and options for using the mapper in inferred projects. Configured projects continue to use only the
contentMappersdeclared in their config files; extension-provided inferred-project mappers never modify configured project behavior.Protocol
When constructing a program for a config that specifies
contentMappers, module resolution recognizes file lookups for the specified extensions and requests transformed content from mapper processes over STDIO. Content mappers communicate with TypeScript over JSON-RPC. TypeScript sends all requests; mappers do not send requests or notifications. All mappers handleinitializeandtransform. Mappers declaringdynamicConfig: trueadditionally handleopenProjectandcloseProject.A mapper may return supplemental outputs when a file contributes more than one TypeScript or JavaScript file, such as an Astro component containing multiple script blocks. TypeScript automatically includes these outputs in the same program as the canonical output, so they participate in binding and type checking without needing to be imported. Supplemental outputs receive compiler-assigned virtual file names based on their order and
extension, but those names are not module resolution targets and cannot be imported directly. Imports written inside supplemental outputs resolve relative to the directory containing the original file.Span maps
For a content mapper to be useful, it needs to provide a mapping between the transformed output and the original content. In the CLI, these mappings are used to show TypeScript-generated diagnostics in the original, non-TypeScript content. Take a simple example:
TypeScript sees and checks the transformed content, in this case producing a diagnostic for the string literal
"oops"because it is not a number. The span mapping allows TypeScript to report the diagnostic in the original content, at the correct location of the string literal ([7, 13), instead of[10, 16)).In this example,
addmapped to+withSpanMapKind.Atom, indicating a correspondence between the two spans, but with different lengths and content. If the nameaddfailed to resolve, the displayed diagnostic range would cover+, but the message would still reference the identifieradd:The mapper can use
SpanMapKind.Aliasinstead ofSpanMapKind.Atomto indicate that the virtual and original text name the same entity. When the diagnostic is rendered, the original text of the alias span (+) will be substituted for the virtual text (add) in the diagnostic message:Gaps in the span map are treated as fully synthesized content and cannot be mapped to a location in the original text. Unlike in Volar, diagnostics in unmappable regions are not discarded. In the CLI, they cause a short snippet of the transformed content to be shown with the diagnostic. A common case may be a content mapper that synthesizes an import statement at the top of the file used in scaffolding. If that import fails to resolve, the user will see:
Spans in the virtual text must not overlap, but multiple may map to the same span in the original content. In other words, one range in the original content can map to multiple ranges in the transformed content. This can be useful in the language server when combined with
SpanMapFeatureandSpanMapKind. Broadly speaking, when a language server request is received for a position in a content-mapped file, the handler maps it to every projection whose feature mask includes the requested operation, performs analysis on the transformed content, and maps visible results back through spans that participate in the same feature. This lets a mapper independently select, for example, one projection for hover and another for definitions or references.The language server currently supports the following features for content-mapped files:
diagnosticDirectives.SpanMapFeatureflag.SpanMapKind.Verbatimmappings.Language service requests and visible results can be disabled independently for any span by clearing the corresponding bits, or disabled for all features with
SpanMapFeature.None. Iffeaturesis omitted from the span mapping tuple, it defaults toSpanMapFeature.All, enabling every supported language service feature for that span.Note
Unlike with Volar, feature participation must be statically determined by the content mapper during transformation. This level of LSP feature mapping is not intended to replace fully custom language servers. TypeScript’s goal in providing language service support for content-mapped files is to support a good editing experience inside
<script>blocks or similar verbatim ranges that embed normal TypeScript or JavaScript code without a third-party language server needing to proxy every request unchanged. We expect that ecosystems implementing complex transforms may still want to implement their own language servers alongside TypeScript’s, and either augment or replace TypeScript’s implementation of these language service features. Content mappers provide a baseline editing experience, but they also provide the API foundation for more specialized language servers to build on. Vue tooling, for example, may choose to enable TypeScript features only for selected projections while a separate language server handles the rest, accessing the AST, type, and symbol information of transformed content through an API connection to TypeScript’s language server.Diagnostic directives
Virtual text can include
// @ts-ignoreor// @ts-expect-errordirectives to suppress a TypeScript diagnostic on the next line. However, Vue supports its own diagnostic directives, which have different scope than TypeScript’s:The expect-error directive in Vue applies to errors on every line of the
<div>element in the original text, which are broken up into multiple lines in the virtual text too. This behavior can’t be replicated with any number of// @ts-expect-errordirectives. Instead, the content mapper can returndiagnosticDirectivesin the transform result, which specify a virtual range and a policy of eitherDiagnosticDirectivePolicy.ExpectorDiagnosticDirectivePolicy.Ignore. TypeScript will suppress bind/check diagnostics in that virtual range according to the policy, and report unusedExpectdirectives as diagnostics in the original content. Unused-directive diagnostics are stored once inunusedExpectDirectiveDiagnosticsand referenced by index from directive tuples.Note
In the same way that it’s technically possible for a mapper to put a
// @ts-ignorecomment between every line of its output, it’s also possible for a mapper to synthesizeignoreregions without a corresponding directive in the original content, but this is not recommended. It’s not currently possible to filter diagnostics by code. This is a break from Volar; I want to see if mappers can get around this with different code generation strategies rather than relying on filtering before considering a broad diagnostic filtering feature. As a type checker implementer, we really prefer to report all the type errors we see.Failure handling
Mappers return
diagnosticsfor unparseable content, and errors in the transformed text itself are handled by TypeScript like any other file. If the mapper fails in an unexpected way (e.g., crashes or doesn’t conform to the protocol), TypeScript reports a localized diagnostic and treats the file as an empty TypeScript file. After five failures in a single project, TypeScript stops attempting to transform files with that content mapper and issues a final diagnostic reporting the failure.LSP activation
TypeScript’s language server can only know to care about the file extensions registered in
contentMappersonce the server is running and has discovered atsconfig.jsonthat specifies them. In the case where a user opens a directory in VS Code and opens a single.vuefile, the TypeScript VS Code extension hasn’t even activated, much less spawned a server that knows about acontentMappersregistration. To address this, third-party VS Code extensions need to explicitly activate the TypeScript extension and register their content mapper contributions:Registering extensions causes TypeScript to inspect matching documents that are already open and discover any configured projects that provide a mapper for them. The returned disposable removes the contribution.
An extension may also provide a mapper for files that do not belong to a configured project by including an inline inferred-project contribution:
It's recommended that extensions always provide an
inferredProjectContribution, and to supply amanifestbuilt from resolving the workspace-installed content mapper package, falling back to a bundled version if the package is not installed. But ultimately, the extension is responsible for content mapper resolution and version/fallback policy.Emit
Content-mapped files are not emitted to JavaScript. When
--declarationis enabled, however, declaration files are emitted from the transformed content. The declaration file name forApp.svelteisApp.d.svelte.ts. Declaration files for supplemental outputs of a file namedApp.svelteare emitted asApp.svelte.0.d.ts,App.svelte.1.d.ts, etc., and are automatically referenced byApp.d.svelte.ts. Declaration maps are currently not supported.Incremental, build, watch, and process consolidation
Content mappers are supported in
--incremental,--build, and--watchmodes. Each project records sorted mapper transform identities in.tsbuildinfoand compares them during up-to-date checks. Changing an identity forces files handled by that mapper to be transformed again.For a mapper without
dynamicConfig: true, the transform identity is computed without starting its process. It includes the resolved package name and version, the tsconfig entry’soptions, and the values of compiler options named bytypescript.contentMapper.compilerOptions. Consequently, incremental and solution-build status checks do not spawn processes for mappers with static configuration.For a mapper declaring
dynamicConfig: true, TypeScript sendsopenProjectto obtainconfigIdentitybefore an up-to-date decision. The mapper is responsible for changingconfigIdentitywhenever dynamically discovered configuration that can affect transforms changes.TypeScript watches the absolute paths returned in
watchedFiles. A change invalidates only projects that reported that path, closes their current mapper project configuration, obtains a fresh identity and watch set, and performs a normal project rebuild. Other projects using the same mapper package continue using their existing project handles and the shared process.Note
Modifying a static-config content mapper implementation during local development will not change its identity, so you’ll need to bump the local package.json version, or use
--forceor--cleanto clear cached outputs if testing with--incrementalor--build.In
--buildmode with project references, and in some instances in the language server, it’s possible to have a project graph with many projects all defining the same content mapper. To avoid excessive spawning of child processes, TypeScript deduplicates content mapper processes by resolved package name and version. For a mapper withdynamicConfig: true, one process may have many open project handles. Dynamic-config mappers must isolate project-specific state byprojectHandle, accept requests for different projects in any order, and release that state oncloseProject. Static-config mappers receive transforms without a project handle. Processes remain alive while any project using that package is retained.API integration
Content-mapped SourceFiles can be inspected by the JavaScript API. For a content-mapped SourceFile,
file.textis the transformed text,file.originalTextis the original text, andfile.spanMapexposes an API for mapping between the two. Regardless of thepositionEncodingused by the content mapper, accessing the span map through the JavaScript API always yields UTF-16 positions.If the content mapper provided supplemental outputs for a file, the file names are set on
file.supplementalOutputsand can be retrieved withprogram.getSourceFile(fileName).Debugging
On the CLI, when the
TS_CONTENT_MAPPER_DEBUGenvironment variable is set, JSON-RPC communication is logged and the mapper process’s STDERR is captured and redirected to tsc’s STDERR.In the LSP, when the LSP log level is set to Trace, JSON-RPC communication and mapper STDERR flow to the LSP client (you can see the content mapper debug logs in the “TypeScript 7” output channel in VS Code, for example). At lower log levels, mapper STDERR is discarded.
I also have a prototype of a VS Code extension that lets you see the virtual content and span mappings, which I’ll share after this PR is merged.
Later follow-up