diff --git a/packages/typescript/schemas/jsconfig.schema.json b/packages/typescript/schemas/jsconfig.schema.json index 942fc702a92b7..7a1d9a50dbd41 100644 --- a/packages/typescript/schemas/jsconfig.schema.json +++ b/packages/typescript/schemas/jsconfig.schema.json @@ -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 } diff --git a/packages/typescript/schemas/tsconfig.schema.json b/packages/typescript/schemas/tsconfig.schema.json index 6679e3ee65697..5bc10ba9414fb 100644 --- a/packages/typescript/schemas/tsconfig.schema.json +++ b/packages/typescript/schemas/tsconfig.schema.json @@ -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 } diff --git a/packages/typescript/src/vscode/extensionApi.ts b/packages/typescript/src/vscode/extensionApi.ts index 36cf8432723f4..1b6f264063d79 100644 --- a/packages/typescript/src/vscode/extensionApi.ts +++ b/packages/typescript/src/vscode/extensionApi.ts @@ -25,10 +25,11 @@ export interface ContentMapperManifest { readonly compilerOptions?: readonly string[] | undefined; readonly dynamicConfig?: boolean | undefined; readonly outputExtensions?: Readonly> | undefined; + readonly extensions?: readonly string[] | undefined; } export interface ContentMapperContribution { - readonly extensions: readonly string[]; + readonly extensions?: readonly string[] | undefined; readonly inferredProjectContribution?: { readonly options?: Readonly> | undefined; readonly manifest: ContentMapperManifest; diff --git a/packages/vscode-typescript/src/contentMapperContributions.ts b/packages/vscode-typescript/src/contentMapperContributions.ts index d6c506a83f293..b3108ad7ea654 100644 --- a/packages/vscode-typescript/src/contentMapperContributions.ts +++ b/packages/vscode-typescript/src/contentMapperContributions.ts @@ -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>; readonly manifest: { @@ -14,6 +14,7 @@ export interface SerializedContentMapperContribution { readonly compilerOptions?: readonly string[]; readonly dynamicConfig?: boolean; readonly outputExtensions?: Readonly>; + readonly extensions?: readonly string[]; }; }; } @@ -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: { @@ -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], + }), }, }, }); @@ -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; @@ -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."); + } } } @@ -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; } } diff --git a/packages/vscode-typescript/test/contentMapperContributions.test.ts b/packages/vscode-typescript/test/contentMapperContributions.test.ts index 57e3771d28765..eeea7d9d851f3 100644 --- a/packages/vscode-typescript/test/contentMapperContributions.test.ts +++ b/packages/vscode-typescript/test/contentMapperContributions.test.ts @@ -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, + }, + }, + }]); + }); }); diff --git a/tools/scripts/lsp/generate.mts b/tools/scripts/lsp/generate.mts index 5afe911d2457b..b05beb6f56f39 100644 --- a/tools/scripts/lsp/generate.mts +++ b/tools/scripts/lsp/generate.mts @@ -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.", }, @@ -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.", diff --git a/tools/scripts/tsc/options-schema.ts b/tools/scripts/tsc/options-schema.ts index cacc817c3349a..8dee3358b5516 100644 --- a/tools/scripts/tsc/options-schema.ts +++ b/tools/scripts/tsc/options-schema.ts @@ -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, }; } diff --git a/tools/scripts/tsc/options.test.ts b/tools/scripts/tsc/options.test.ts index 27c9827bdb056..d4fa3ab8b1448 100644 --- a/tools/scripts/tsc/options.test.ts +++ b/tools/scripts/tsc/options.test.ts @@ -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 = [ @@ -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) { diff --git a/tsc/internal/api/module_resolution.go b/tsc/internal/api/module_resolution.go index d7640b28892c1..985d05a867977 100644 --- a/tsc/internal/api/module_resolution.go +++ b/tsc/internal/api/module_resolution.go @@ -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, diff --git a/tsc/internal/api/session_module_resolution_test.go b/tsc/internal/api/session_module_resolution_test.go index ccffe90b69709..731145f291ff1 100644 --- a/tsc/internal/api/session_module_resolution_test.go +++ b/tsc/internal/api/session_module_resolution_test.go @@ -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() diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index d689a6453e8a6..e80cbefa5edaf 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -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" @@ -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() != "" { diff --git a/tsc/internal/checker/nodebuilderimpl.go b/tsc/internal/checker/nodebuilderimpl.go index 7c90f3f320fbc..281ba55b11c40 100644 --- a/tsc/internal/checker/nodebuilderimpl.go +++ b/tsc/internal/checker/nodebuilderimpl.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ast" "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/jsnum" @@ -1350,6 +1351,9 @@ func (b *NodeBuilderImpl) rewriteContentMappedModuleSpecifier(specifier tspath.M if b.ctx.internalFlags&nodebuilder.InternalFlagsRewriteModuleSpecifiers != 0 && b.ch.compilerOptions.RewriteRelativeImportExtensions.IsTrue() { rewrites := b.ctx.host.ContentMapperExtensionRewrites() caseSensitivity := b.ctx.host.CaseSensitivity() + if b.ctx.enclosingFile != nil { + rewrites = contentmapper.RewritesForImport(rewrites, b.ctx.enclosingFile.FileName(), specifier, b.ctx.host.ContentMapperExtensionForFileName, caseSensitivity) + } if specifier.ShouldRewriteExtension(rewrites, caseSensitivity) { rewritten, _ := specifier.RewriteExtension(rewrites, caseSensitivity) return rewritten diff --git a/tsc/internal/compiler/contentmapper_test.go b/tsc/internal/compiler/contentmapper_test.go index 31b8f831870e8..8a0a29e3c8e14 100644 --- a/tsc/internal/compiler/contentmapper_test.go +++ b/tsc/internal/compiler/contentmapper_test.go @@ -66,6 +66,118 @@ func newContentMapperProgramWithOptions(t *testing.T, contentMapperProject conte }) } +func TestContentMapperSymlinkedPackage(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + include []string + wantFiltered bool + preserveSymlinks bool + }{ + {name: "logical include", include: []string{"node_modules/@scope/lib"}}, + {name: "unchanged association"}, + {name: "realpath mapper", include: []string{"packages/lib"}, wantFiltered: true}, + {name: "logical then real include", include: []string{"node_modules/@scope/lib", "packages/lib"}, wantFiltered: true}, + {name: "real then logical include", include: []string{"packages/lib", "node_modules/@scope/lib"}, wantFiltered: true}, + {name: "preserved logical include", include: []string{"node_modules/@scope/lib"}, wantFiltered: true, preserveSymlinks: true}, + {name: "preserved real include", include: []string{"packages/lib"}, preserveSymlinks: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + // A root and both package and relative imports must share the same loaded path. + dataPath := tspath.RootedFilePath("/project/packages/lib/data.vue") + directImport := "./packages/lib/data.vue" + if test.preserveSymlinks { + dataPath = "/project/node_modules/@scope/lib/data.vue" + directImport = "./node_modules/@scope/lib/data.vue" + } + fs := vfstest.FromMap(map[string]any{ + "/project/main.ts": `import { value } from "@scope/lib/data.vue"; import { value as direct } from "` + directImport + `"; export { value, direct };`, + "/project/node_modules/@scope/lib": vfstest.Symlink("/project/packages/lib"), + "/project/packages/lib/package.json": `{"name": "@scope/lib", "version": "1.0.0"}`, + "/project/packages/lib/data.vue": "