Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions packages/typescript/schemas/jsconfig.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,36 @@
"type": "array",
"items": {
"type": "string"
}
},
"description": "File extensions handled by this mapper. Defaults to the value of 'typescript.contentMapper.extensions' in the package manifest."
},
"include": {
"type": "array",
"items": {
"type": "string"
},
"description": "Patterns relative to this config file that narrow which files the mapper handles."
},
"outputExtensions": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Maps source file extensions to output extensions for declaration filenames and rewritten imports."
},
"options": {
"type": "object"
}
},
"required": [
"package",
"extensions"
"package"
],
"additionalProperties": false
}
Expand Down
27 changes: 24 additions & 3 deletions packages/typescript/schemas/tsconfig.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,36 @@
"type": "array",
"items": {
"type": "string"
}
},
"description": "File extensions handled by this mapper. Defaults to the value of 'typescript.contentMapper.extensions' in the package manifest."
},
"include": {
"type": "array",
"items": {
"type": "string"
},
"description": "Patterns relative to this config file that narrow which files the mapper handles."
},
"outputExtensions": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Maps source file extensions to output extensions for declaration filenames and rewritten imports."
},
"options": {
"type": "object"
}
},
"required": [
"package",
"extensions"
"package"
],
"additionalProperties": false
}
Expand Down
3 changes: 2 additions & 1 deletion packages/typescript/src/vscode/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ export interface ContentMapperManifest {
readonly compilerOptions?: readonly string[] | undefined;
readonly dynamicConfig?: boolean | undefined;
readonly outputExtensions?: Readonly<Record<string, string>> | undefined;
readonly extensions?: readonly string[] | undefined;
}

export interface ContentMapperContribution {
readonly extensions: readonly string[];
readonly extensions?: readonly string[] | undefined;
readonly inferredProjectContribution?: {
readonly options?: Readonly<Record<string, unknown>> | undefined;
readonly manifest: ContentMapperManifest;
Expand Down
19 changes: 14 additions & 5 deletions packages/vscode-typescript/src/contentMapperContributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type { ContentMapperContribution, ContentMapperManifest } from "@typescri

export interface SerializedContentMapperContribution {
readonly contributorId: string;
readonly extensions: readonly string[];
readonly extensions?: readonly string[];
readonly inferredProjectContribution?: {
readonly options?: Readonly<Record<string, unknown>>;
readonly manifest: {
Expand All @@ -14,6 +14,7 @@ export interface SerializedContentMapperContribution {
readonly compilerOptions?: readonly string[];
readonly dynamicConfig?: boolean;
readonly outputExtensions?: Readonly<Record<string, string>>;
readonly extensions?: readonly string[];
};
};
}
Expand All @@ -26,7 +27,7 @@ export function serializeContentMapperContributions(
contributions.forEach(contribution => {
result.push({
contributorId,
extensions: [...contribution.extensions],
...(contribution.extensions && { extensions: [...contribution.extensions] }),
inferredProjectContribution: contribution.inferredProjectContribution && {
options: contribution.inferredProjectContribution.options,
manifest: {
Expand All @@ -37,6 +38,9 @@ export function serializeContentMapperContributions(
...(contribution.inferredProjectContribution.manifest.outputExtensions && {
outputExtensions: { ...contribution.inferredProjectContribution.manifest.outputExtensions },
}),
...(contribution.inferredProjectContribution.manifest.extensions && {
extensions: [...contribution.inferredProjectContribution.manifest.extensions],
}),
},
},
});
Expand All @@ -50,10 +54,11 @@ export function validateContentMapperRegistration(contributorId: string, contrib
throw new TypeError("Content mapper contributor ID must not be empty.");
}
for (const contribution of contributions) {
if (contribution.extensions.length === 0 || contribution.extensions.some(extension => !isExtension(extension))) {
const extensions = contribution.extensions ?? contribution.inferredProjectContribution?.manifest.extensions;
if (!extensions || extensions.length === 0 || extensions.some(extension => !isExtension(extension))) {
throw new TypeError("Content mapper contributions require non-empty extensions beginning with '.' and containing no path separators.");
}
if (contribution.extensions.some(extension => /\.(?:[cm]?[jt]s|[jt]sx|json)$/i.test(extension))) {
if (extensions.some(extension => /\.(?:[cm]?[jt]s|[jt]sx|json)$/i.test(extension))) {
throw new TypeError("Content mapper extensions must not end in a built-in extension.");
}
const inferredProjectContribution = contribution.inferredProjectContribution;
Expand All @@ -69,6 +74,9 @@ export function validateContentMapperRegistration(contributorId: string, contrib
if (inferredProjectContribution?.manifest.outputExtensions && Object.entries(inferredProjectContribution.manifest.outputExtensions).some(([source, output]) => !isExtension(source) || !isExtension(output))) {
throw new TypeError("Content mapper output extensions must be non-empty, begin with '.', and contain no path separators.");
}
if (inferredProjectContribution?.manifest.extensions?.some(extension => !isExtension(extension))) {
throw new TypeError("Content mapper manifest extensions must be non-empty, begin with '.', and contain no path separators.");
}
}
}

Expand All @@ -83,7 +91,8 @@ export function documentMatchesContentMapperContributions(
const documentPath = document.uri.path.toLowerCase();
for (const contributions of registrations.values()) {
for (const contribution of contributions) {
if (contribution.extensions.some(extension => documentPath.endsWith(extension.toLowerCase()))) {
const extensions = contribution.extensions ?? contribution.inferredProjectContribution?.manifest.extensions ?? [];
if (extensions.some(extension => documentPath.endsWith(extension.toLowerCase()))) {
return true;
}
}
Expand Down
26 changes: 26 additions & 0 deletions packages/vscode-typescript/test/contentMapperContributions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,30 @@ describe("content mapper contributions", { concurrency: true }, () => {
},
}]);
});

test("uses manifest extensions when the contribution omits extensions", () => {
const contribution = {
inferredProjectContribution: {
manifest: {
name: "mapper",
exec: ["mapper"],
extensions: [".toml"],
},
},
} satisfies ContentMapperContribution;
assert.doesNotThrow(() => validateContentMapperRegistration("mapper", [contribution]));
assert.deepEqual(serializeContentMapperContributions(new Map([["mapper", [contribution]]])), [{
contributorId: "mapper",
inferredProjectContribution: {
options: undefined,
manifest: {
name: "mapper",
exec: ["mapper"],
extensions: [".toml"],
cwd: undefined,
compilerOptions: undefined,
},
},
}]);
});
});
3 changes: 2 additions & 1 deletion tools/scripts/lsp/generate.mts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ const customStructures: Structure[] = [
{ name: "compilerOptions", type: { kind: "array", element: { kind: "base", name: "string" } }, optional: true, documentation: "Compiler option names forwarded to the mapper." },
{ name: "dynamicConfig", type: { kind: "base", name: "boolean" }, optional: true, documentation: "Whether the mapper uses project-scoped dynamic configuration." },
{ name: "outputExtensions", type: { kind: "map", key: { kind: "base", name: "string" }, value: { kind: "base", name: "string" } }, optional: true, documentation: "Default source-to-runtime output extension mappings." },
{ name: "extensions", type: { kind: "array", element: { kind: "base", name: "string" } }, optional: true, documentation: "Default source extensions handled by the mapper." },
],
documentation: "Inline content mapper manifest supplied by a contributing extension.",
},
Expand All @@ -475,7 +476,7 @@ const customStructures: Structure[] = [
name: "ContentMapperContribution",
properties: [
{ name: "contributorId", type: { kind: "base", name: "string" }, documentation: "Unique identifier of the contributor extension." },
{ name: "extensions", type: { kind: "array", element: { kind: "base", name: "string" } }, documentation: "File extensions handled by this content mapper." },
{ name: "extensions", type: { kind: "array", element: { kind: "base", name: "string" } }, optional: true, documentation: "File extensions handled by this content mapper." },
{ name: "inferredProjectContribution", type: { kind: "reference", name: "InferredProjectContentMapperContribution" }, optional: true, documentation: "When present, contributes this mapper to inferred projects." },
],
documentation: "One extension-provided content mapper contribution.",
Expand Down
17 changes: 15 additions & 2 deletions tools/scripts/tsc/options-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,23 @@ function valueSchema(declaration: Declaration): JSONSchema {
type: "object",
properties: {
package: { type: "string", minLength: 1 },
extensions: { type: "array", items: { type: "string" } },
extensions: {
type: "array",
items: { type: "string" },
description: "File extensions handled by this mapper. Defaults to the value of 'typescript.contentMapper.extensions' in the package manifest.",
},
include: {
type: "array",
items: { type: "string" },
description: "Patterns relative to this config file that narrow which files the mapper handles.",
},
outputExtensions: {
...nullable({ type: "object", additionalProperties: { type: "string" } }),
description: "Maps source file extensions to output extensions for declaration filenames and rewritten imports.",
},
options: { type: "object" },
},
required: ["package", "extensions"],
required: ["package"],
additionalProperties: false,
};
}
Expand Down
8 changes: 7 additions & 1 deletion tools/scripts/tsc/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,10 @@ test("schemas accept representative valid configs and reject malformed configs",
{ compilerOptions: { paths: { "@/*": ["src/*"] }, plugins: [{ name: "some-plugin", customSetting: true }], moduleSuffixes: ["", ".native"] } },
{ typeAcquisition: { enable: true, include: ["node"], exclude: [] } },
{ contentMappers: [{ package: "mapper", extensions: [".vue"], options: { customSetting: true } }] },
{ contentMappers: [{ package: "mapper" }] },
{ contentMappers: [{ package: "mapper", include: ["src", "**/blob.toml"] }] },
{ contentMappers: [{ package: "mapper", include: [], outputExtensions: { ".vue": ".js" } }] },
{ contentMappers: [{ package: "mapper", outputExtensions: null }] },
{ "$schema": "./tsconfig.schema.json", "tool-specific": { anything: true } },
];
const invalid = [
Expand All @@ -378,8 +382,10 @@ test("schemas accept representative valid configs and reject malformed configs",
{ references: [{ path: "" }] },
{ references: [{ path: null }] },
{ contentMappers: [{ package: "", extensions: [] }] },
{ contentMappers: [{ package: "mapper" }] },
{ contentMappers: [{ package: "mapper", extensions: [1] }] },
{ contentMappers: [{ package: "mapper", include: "src" }] },
{ contentMappers: [{ package: "mapper", include: [1] }] },
{ contentMappers: [{ package: "mapper", outputExtensions: { ".vue": 1 } }] },
{ contentMappers: [{ package: "mapper", extensions: [], options: null }] },
];
for (const kind of ["tsconfig", "jsconfig"] as const) {
Expand Down
11 changes: 9 additions & 2 deletions tsc/internal/api/module_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,11 +356,18 @@ func (s *Session) handleResolveModuleName(ctx context.Context, params *ResolveMo
if err != nil {
return nil, err
}
resolver = module.NewResolver(module.ResolverOptions{
options := module.ResolverOptions{
Host: sd.snapshot,
CompilerOptions: data.compilerOptions,
ExtraExtensions: sd.snapshot.ContentMapperExtensions(),
})
}
if configPath := data.compilerOptions.ConfigFilePath; configPath != "" {
if config := sd.snapshot.ConfigFileRegistry.GetConfig(s.caseSensitivity().PathKey(configPath.AsPath())); config != nil {
options.ExtraExtensions = config.ContentMapperExtensions()
options.ContentMapperFileMatcher = config.ContentMapperFileMatcher()
}
}
resolver = module.NewResolver(options)
} else {
resolver = module.NewResolver(module.ResolverOptions{
Host: s,
Expand Down
50 changes: 50 additions & 0 deletions tsc/internal/api/session_module_resolution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,56 @@ func TestModuleResolverUsesSnapshotFileSystem(t *testing.T) {
assert.Assert(t, len(result.Trace) > 0)
}

func TestContentMapperModuleResolverProjectContext(t *testing.T) {
t.Parallel()
init, _ := projecttestutil.GetSessionInitOptions(map[string]any{
"/a/tsconfig.json": `{
"files": ["a.ts"], "compilerOptions": {"noLib": true},
"contentMappers": [{"package": "mapper", "extensions": [".blob.toml"], "include": ["../b"]}]
}`,
"/b/tsconfig.json": `{
"files": ["b.ts"], "compilerOptions": {"noLib": true},
"contentMappers": [{"package": "mapper", "extensions": [".toml"]}]
}`,
"/a/a.ts": "export {};",
"/b/b.ts": "export {};",
"/b/x.blob.toml": "",
"/c/x.blob.toml": "",
"/node_modules/mapper/package.json": `{
"name": "mapper", "typescript": {"contentMapper": {"exec": ["run"]}}
}`,
}, nil, nil)
init.Options.RunExternalCode = true
session := NewStandaloneSession(init, nil)
defer session.Close()
snapshot, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{
OpenProjects: []DocumentIdentifier{{FileName: "/a/tsconfig.json"}, {FileName: "/b/tsconfig.json"}},
})
assert.NilError(t, err)
for _, test := range []struct {
configPath tspath.RootedFilePath
extension string
directory string
}{
{configPath: "/b/tsconfig.json", directory: "/b", extension: ".toml"},
{directory: "/c", extension: ".blob.toml"},
} {
resolver, err := session.handleCreateModuleResolver(&CreateModuleResolverParams{
CompilerOptions: core.CompilerOptions{
ConfigFilePath: test.configPath, Module: core.ModuleKindPreserve, ModuleResolution: core.ModuleResolutionKindBundler,
},
})
assert.NilError(t, err)
result, err := session.handleResolveModuleName(t.Context(), &ResolveModuleNameParams{
Snapshot: snapshot.Snapshot, Resolver: resolver, ModuleName: "./x.blob.toml",
ContainingDirectory: DocumentIdentifier{FileName: test.directory},
})
assert.NilError(t, err)
assert.Assert(t, result.ResolvedModule != nil)
assert.Equal(t, result.ResolvedModule.Extension, test.extension)
}
}

func TestStaticModuleResolutionSpecificityAndLifetime(t *testing.T) {
t.Parallel()

Expand Down
2 changes: 2 additions & 0 deletions tsc/internal/checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/microsoft/TypeScript/tsc/internal/ast"
"github.com/microsoft/TypeScript/tsc/internal/binder"
"github.com/microsoft/TypeScript/tsc/internal/collections"
"github.com/microsoft/TypeScript/tsc/internal/contentmapper"
"github.com/microsoft/TypeScript/tsc/internal/core"
"github.com/microsoft/TypeScript/tsc/internal/debug"
"github.com/microsoft/TypeScript/tsc/internal/diagnostics"
Expand Down Expand Up @@ -15601,6 +15602,7 @@ func (c *Checker) resolveExternalModule(
!tspath.IsDeclarationFileName(moduleReference) &&
(!ast.IsLiteralImportTypeNode(location) && !ast.IsPartOfTypeOnlyImportOrExportDeclaration(location) ||
checkDeclarationRewrite) {
rewrites = contentmapper.RewritesForImport(rewrites, importingSourceFile.FileName(), specifier, c.program.ContentMapperExtensionForFileName, caseSensitivity)
shouldRewrite := specifier.ShouldRewriteExtension(rewrites, caseSensitivity)
mappedSource := false
if sourceFile.ContentMapper() != "" {
Expand Down
Loading
Loading