Content mappers - #4712
Conversation
|
This is way more powerful than what I expected. It'll definitely save a lot of work for all the non-TS file tooling. Some of the features will just work without needing enhancement. There are a few questions I want to ask:
For mapping, Svelte currently uses sourcemap generated by magic-string. We're using the |
|
Michael Arnaldi (@mikearnaldi) good catches, thank you! I will push some of these to this PR. I think patch 0002 isn’t quite right; I started on a different fix today but didn’t have time to finish it. It got surprisingly complicated. When you map a position at the very end of a span, what to return is ambiguous, but it doesn’t depend on whether that position is part of the last segment in the file. The ambiguity is there at the end of any span, and it’s particularly problematic at the boundary between two original text spans that map to non-contiguous virtual text spans: At position 3 in the original text, where do you map to? It looks like span B in the diagram, but if that were a completions request at position 3, that would put the insertion caret right between |
|
Wanted to give a quick update here before I’m out on vacation for a week and ask for feedback on a couple specific things from any implementers, especially anyone coming from Volar. First, I’m reconsidering the
I stand by the motivation here, but I think I also want to get feedback on the constraints I’ve put on overlapping segments. The current rules are
I’m not sure if Volar has either of these restrictions; I think it at least doesn’t have the latter. I couldn’t think of a realistic example that would rely on overlapping but non-duplicate spans in the original text, and had trouble reasoning about the semantic implications of that kind of mapping. Another question that came up in the latest design meeting about this was whether content mapper processes would need to read their own configuration files, beyond the
These are solvable problems, but at a performance and complexity penalty. How do existing transforms work? Are they consuming arbitrary config from the workspace? Lyu, Wei-Da (@jasonlyu123) I just saw your comment as I was about to paste this—I’m on a plane with terrible WiFi so I’ll have to look closer at your links in a week. But your input about specific language feature overrides supports the first change I mentioned I was considering, so that’s helpful feedback! I’m not sure I fully understand the questions about the IPC API. The protocol in this PR is totally separate from the IPC API you can get by importing Also, yes, there will be a way to get a parsed SourceFile without setting up a whole program, so that would help with the script tag parsing. Coming soon. |
I am sure my patches are not perfect, I am mostly iterating with agents so please take everything in my repo as a "maybe", the reason for that patch is: when the file is empty or when the cursor is pointed at the end of a file I would not get LSP completions, with my patch I get LSP completions but I am not sure it's correct :) |
Yeah. I should have worded it more clearly. The IPC API question is related to the first question. And in both cases, what I want to say is LSP-connected IPC API.
I think this should answer my question. The scenario of my question is: when the Svelte language server handles a completions request, whether it's triggered in TS client-side middleware or a standard LSP request, the Svelte language server uses the LSP-connected IPC API to ask about the completion result. In that case, should the parameter sent to the IPC API be a generated position or the source position? Another thing is whether we can ask for positions that are purely generated? From the current implementation, it looks like the LSP-connected IPC API should all use generated positions and won't have mapping validations. So I am mostly asking for confirmation. |
|
First of all, this is a great start! Thanks for working on this. ❤️ I only read the PR description and comments. I have some thoughts and feedback.
This is probably correct for many languages, but not all. I believe Svelte, Vue, and Astro files are published to npm as-is. MDX is different though. MDX is a language that is syntactic sugar for JS(X). It gets compiled to plain JS. It should be treated the same as JSX. For example, the following MDX: # Hello {props.user.name}
<Avatar user={props.user} />gets compiled to roughly the same JavaScript as the following JSX: export default function MDXContent(props) {
return (
<>
<h1>
Hello {props.user.name}
</h1>
<Avatar user={props.user} />
</>
)
}I don’t see a reason to publish to MDX to npm, but I believe the situation is similar for Ember (cc Alex Matchneer (@machty)). I imagine a content mapper should be able to specify how its declarations are emitted. I’m also not sure how emit should work with mapped files. I can imagine it it works with
I really like that
I think we need for fine-grained control. Notably, actions that read are generally safe, but actions that write may be unsafe, as the edit would be based on mapped content, but be applied to to the actual content. Edits produce valid TypeScript, but might produce content that’s not valid in the original content. For example, say we have an MDX file with YAML frontmatter. The pipe represents the cursor position. ---
|
---Now autocomplete might turn this into something like: ---
{
created: new Date(),
title: ''
}|
---This completion made sense in TypeScript, but it doesn’t make sense in YAML. There are no I see that content mappers can specify which compiler options they consume. This is a great start, but they may also need other options. How are users supposed to specify those? Should content mappers support custom compiler options? {
"compilerOptions": {
"mdxRemarkPlugins": [],
// …
},
"contentMappers": [
{
"package": "@mdx-js/content-mapper",
"extensions": [".mdx"]
}
]
}Or should they get their own config option? In this case, how should extended tsconfigs be handled? {
"compilerOptions": {
// …
},
"contentMappers": [
{
"package": "@mdx-js/content-mapper",
"extensions": [".mdx"],
"options": {
"remarkPlugins": []
}
}
]
}For editor JSON schema support it might also be nicer to use a mapping instead of an array. {
"compilerOptions": {
// …
},
"contentMappers": {
"@mdx-js/content-mapper": {
"extensions": [".mdx"],
"options": {
"remarkPlugins": []
}
}
}
}Can multiple mapped content ranges overlap with the same position in a source file? For example, you can either import or inject JSX components in MDX. So say we have the following MDX, where the pipe is the cursor: import { Imported } from 'module'
<Imported />
<Injected| />This is roughly equivalent to the following JSX: import { Imported } from 'module'
export default function MDXContent(props) {
return (
<>
<Imported />
<props.components.Injected />
</>
)
}This means that while typing JSX, it’s nice to get completions from imported members as well as members on the import { Imported } from 'module'
export default function MDXContent(props) {
return (
<>
<Imported />
<props.components.Injected| />
</>
)
}
Injected|Notice that there are two cursors in the virtual content. A related issue for content mappers is microsoft/TypeScript#31894. This doesn’t need to be resolved in the first iteration, but it’s a big pain point in the current Volar based approach that I want to highlight. A scenario:
JSX support is builtin to TypeScript. So they broke their own rule. It’s common for other content mappers to depend on types that may or may not be defined. Currently this is done by depending on undefined behaviour of I feel like it would be useful to add support for injecting a custom TypeScript file into the program. I believe some Volar integrations add boilerplate that could be reused to avoid the need to re-parse them all. But another situation I have in mind is Next.js. They support typed routes by emitting a TypeScript file and continuously updating it when you run the server. I feel like they could abuse the content mapper system by making the user commit an empty file named IMO this might as well be supported without burdening the end user with the hassle of requiring an empty file for the content mapper. |
About completion, I found another problem. Completion position is currently mapped to the right of the cursor. If the span right after the symbol is a ranges := l.converters.FromLSPRange(file, lsproto.Range{
Start: lsproto.Position{Line: LSPPosition.Line, Character: LSPPosition.Character - 1},
End: LSPPosition,
}, spanmap.FeatureCompletion)
position := int(ranges[0].Span.End())
One problem I found is with adding a quote to an identifier. Svelte transforms element attributes to an object literal. <button foo="" >to { svelteHTML.createElement("element", { "foo":"",});}Because JavaScript's identifier rules differ from HTML attribute rules, attributes need to be surrounded by quotes. This caused a conflict with diagnostics and references-related features. The diagnostics range includes the quote, but references don't. If I made the quote mapped to the first character, document highlight will highlight Another question about mapping: should the span be broken down by identifier or tokens? It seems so in your example. Or can it just be one span? For example, should an unedited expression like |
It’s actually the same problem Michael Arnaldi (@mikearnaldi) found. I have a fix stashed but it’s more complicated than I’d like so I’m working on some other things while I think about it. Thanks for the quoted attribute example; I think we can relax the span overlap rules a little bit. Another question about mapping: should the span be broken down by identifier or tokens? It seems so in your example. Or can it just be one span? For example, should an unedited expression like This is totally up to the content mapper implementation, but generally speaking, there’s no reason to have contiguous spans with the same kind/features. You should aim for as few spans as it takes to get the behavior you want. |
|
Made a few significant updates and edited the PR description:
|
|
Another significant change: a content mapper may now emit additional supplemental files as part of any Transform response. PR description updated again. |
|
Quick question. I've been beavering away on adding 7.1 TS support to ts-loader: One of the incomplete pieces is custom transformers: https://github.com/TypeStrong/ts-loader#getcustomtransformers It's possible I've asked this elsewhere and forgotten the answer, if so apologies! But I'm wondering if this functionality is likely to cover what transformers did in the TS version of the API? See: https://github.com/microsoft/TypeScript/blob/b465fdbfe175304d9b977da137b2c178ae1091d3/src/compiler/program.ts#L2693 |
|
No, but custom transformers are still planned, mentioned in #4830. I’ll add ts-loader to the list of projects that needs it! |
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--loadExternalPlugins. VS Code passes--loadExternalPluginstotsc --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
tsContentMappertop-level field describing how to spawn the content 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", "tsContentMapper": { "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.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
scriptKind, 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 generated and original text name the same entity. When the diagnostic is rendered, the original text of the alias span (+) will be substituted for the generated 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 generated output 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:
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.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 request that it search for content mappers handling open files:Extension-provided content mappers are not yet supported, but a follow-up exploration is planned.
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 bytsContentMapper.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.To-do
--extendedDiagnosticsand LSP logs