From 935c8126420bd2377690f5d63ce335853f9088e7 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 29 Oct 2026 16:48:59 -0700 Subject: [PATCH 1/2] Allow content mappers to inherit extensions from package manifests Use manifest extensions when a registration omits them, while preserving explicit overrides. Carry these defaults through extension contributions, validation, and config schemas. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../typescript/schemas/jsconfig.schema.json | 6 +- .../typescript/schemas/tsconfig.schema.json | 6 +- .../typescript/src/vscode/extensionApi.ts | 3 +- .../src/contentMapperContributions.ts | 19 ++++-- .../test/contentMapperContributions.test.ts | 26 ++++++++ tools/scripts/lsp/generate.mts | 3 +- tools/scripts/tsc/options-schema.ts | 8 ++- tools/scripts/tsc/options.test.ts | 2 +- tsc/internal/contentmapper/contentmapper.go | 3 + .../diagnosticMessages.generated.json | 1 + .../diagnostics/diagnosticMessages.json | 4 ++ .../diagnostics/diagnostics_generated.go | 3 + tsc/internal/lsp/lsproto/lsp_generated.go | 5 +- tsc/internal/lsp/server.go | 9 ++- .../lsp/server_contentmapper_internal_test.go | 47 ++++++++++---- tsc/internal/lsp/server_contentmapper_test.go | 4 +- tsc/internal/packagejson/packagejson.go | 1 + tsc/internal/tsoptions/contentmappers.go | 8 ++- tsc/internal/tsoptions/contentmappers_test.go | 65 +++++++++++++++++++ tsc/internal/tsoptions/parsedoptions_test.go | 2 + tsc/internal/tsoptions/parsinghelpers.go | 2 - tsc/internal/tsoptions/tsconfigparsing.go | 49 +++++++++----- .../tsoptions/tsconfigparsing_test.go | 5 +- 23 files changed, 228 insertions(+), 53 deletions(-) diff --git a/packages/typescript/schemas/jsconfig.schema.json b/packages/typescript/schemas/jsconfig.schema.json index 942fc702a92b7..2dcb5f37add6e 100644 --- a/packages/typescript/schemas/jsconfig.schema.json +++ b/packages/typescript/schemas/jsconfig.schema.json @@ -85,15 +85,15 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "File extensions handled by this mapper. Defaults to the value of 'typescript.contentMapper.extensions' in the package manifest." }, "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..9f0934744d3f2 100644 --- a/packages/typescript/schemas/tsconfig.schema.json +++ b/packages/typescript/schemas/tsconfig.schema.json @@ -85,15 +85,15 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "File extensions handled by this mapper. Defaults to the value of 'typescript.contentMapper.extensions' in the package manifest." }, "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..148728b66610c 100644 --- a/tools/scripts/tsc/options-schema.ts +++ b/tools/scripts/tsc/options-schema.ts @@ -110,10 +110,14 @@ 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.", + }, 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..870729a30466c 100644 --- a/tools/scripts/tsc/options.test.ts +++ b/tools/scripts/tsc/options.test.ts @@ -352,6 +352,7 @@ 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" }] }, { "$schema": "./tsconfig.schema.json", "tool-specific": { anything: true } }, ]; const invalid = [ @@ -378,7 +379,6 @@ 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", extensions: [], options: null }] }, ]; diff --git a/tsc/internal/contentmapper/contentmapper.go b/tsc/internal/contentmapper/contentmapper.go index 158e6ee4be1b5..d9486384cc59a 100644 --- a/tsc/internal/contentmapper/contentmapper.go +++ b/tsc/internal/contentmapper/contentmapper.go @@ -48,6 +48,7 @@ type Manifest struct { CompilerOptions []string DynamicConfig bool DefaultOutputExtensions map[string]string + DefaultExtensions []string } // Mapper is a resolved content mapper: its tsconfig Definition combined with the Manifest resolved from @@ -139,6 +140,8 @@ func (m *Mapper) Equals(other *Mapper) bool { (m.CompilerOptions == nil) == (other.CompilerOptions == nil) && slices.Equal(m.CompilerOptions, other.CompilerOptions) && m.DynamicConfig == other.DynamicConfig && + (m.DefaultExtensions == nil) == (other.DefaultExtensions == nil) && + slices.Equal(m.DefaultExtensions, other.DefaultExtensions) && (m.Manifest.DefaultOutputExtensions == nil) == (other.Manifest.DefaultOutputExtensions == nil) && maps.Equal(m.Manifest.DefaultOutputExtensions, other.Manifest.DefaultOutputExtensions) && m.PackageDirectory == other.PackageDirectory && diff --git a/tsc/internal/diagnostics/diagnosticMessages.generated.json b/tsc/internal/diagnostics/diagnosticMessages.generated.json index bf7f327e371c0..2e172b906bf0b 100644 --- a/tsc/internal/diagnostics/diagnosticMessages.generated.json +++ b/tsc/internal/diagnostics/diagnosticMessages.generated.json @@ -1959,6 +1959,7 @@ "The_typescript_contentMapper_outputExtensions_of_the_content_mapper_package_0_must_be_an_object_with_18122": "The 'typescript.contentMapper.outputExtensions' of the content mapper package '{0}' must be an object with string values.", "Content_mapper_extension_0_must_not_contain_path_separators_18123": "Content mapper extension '{0}' must not contain path separators.", "The_content_mapper_package_0_is_outside_the_open_workspace_and_will_not_be_loaded_18124": "The content mapper package '{0}' is outside the open workspace and will not be loaded.", + "Content_mapper_0_must_specify_extensions_in_tsconfig_or_its_package_manifest_18125": "Content mapper '{0}' must specify 'extensions' in tsconfig or its package manifest.", "nodenext_if_module_is_nodenext_node16_if_module_is_node16_or_node18_otherwise_bundler_69010": "`nodenext` if `module` is `nodenext`; `node16` if `module` is `node16` or `node18`; otherwise, `bundler`.", "File_is_a_CommonJS_module_it_may_be_converted_to_an_ES_module_80001": "File is a CommonJS module; it may be converted to an ES module.", "This_constructor_function_may_be_converted_to_a_class_declaration_80002": "This constructor function may be converted to a class declaration.", diff --git a/tsc/internal/diagnostics/diagnosticMessages.json b/tsc/internal/diagnostics/diagnosticMessages.json index 895ede2e4fa92..da44141279c3d 100644 --- a/tsc/internal/diagnostics/diagnosticMessages.json +++ b/tsc/internal/diagnostics/diagnosticMessages.json @@ -8955,5 +8955,9 @@ "Content mapper extension '{0}' must not contain path separators.": { "category": "Error", "code": 18123 + }, + "Content mapper '{0}' must specify 'extensions' in tsconfig or its package manifest.": { + "category": "Error", + "code": 18125 } } diff --git a/tsc/internal/diagnostics/diagnostics_generated.go b/tsc/internal/diagnostics/diagnostics_generated.go index fbd75c71bbe08..b2d20517c8f97 100644 --- a/tsc/internal/diagnostics/diagnostics_generated.go +++ b/tsc/internal/diagnostics/diagnostics_generated.go @@ -3922,6 +3922,8 @@ var Content_mapper_extension_0_must_not_contain_path_separators = &Message{code: var The_content_mapper_package_0_is_outside_the_open_workspace_and_will_not_be_loaded = &Message{code: 18124, category: CategoryError, key: "The_content_mapper_package_0_is_outside_the_open_workspace_and_will_not_be_loaded_18124", text: "The content mapper package '{0}' is outside the open workspace and will not be loaded."} +var Content_mapper_0_must_specify_extensions_in_tsconfig_or_its_package_manifest = &Message{code: 18125, category: CategoryError, key: "Content_mapper_0_must_specify_extensions_in_tsconfig_or_its_package_manifest_18125", text: "Content mapper '{0}' must specify 'extensions' in tsconfig or its package manifest."} + var X_nodenext_if_module_is_nodenext_node16_if_module_is_node16_or_node18_otherwise_bundler = &Message{code: 69010, category: CategoryMessage, key: "nodenext_if_module_is_nodenext_node16_if_module_is_node16_or_node18_otherwise_bundler_69010", text: "`nodenext` if `module` is `nodenext`; `node16` if `module` is `node16` or `node18`; otherwise, `bundler`."} var File_is_a_CommonJS_module_it_may_be_converted_to_an_ES_module = &Message{code: 80001, category: CategorySuggestion, key: "File_is_a_CommonJS_module_it_may_be_converted_to_an_ES_module_80001", text: "File is a CommonJS module; it may be converted to an ES module."} @@ -6423,6 +6425,7 @@ var allMessages = [...]**Message{ &The_typescript_contentMapper_outputExtensions_of_the_content_mapper_package_0_must_be_an_object_with_string_values, &Content_mapper_extension_0_must_not_contain_path_separators, &The_content_mapper_package_0_is_outside_the_open_workspace_and_will_not_be_loaded, + &Content_mapper_0_must_specify_extensions_in_tsconfig_or_its_package_manifest, &X_nodenext_if_module_is_nodenext_node16_if_module_is_node16_or_node18_otherwise_bundler, &File_is_a_CommonJS_module_it_may_be_converted_to_an_ES_module, &This_constructor_function_may_be_converted_to_a_class_declaration, diff --git a/tsc/internal/lsp/lsproto/lsp_generated.go b/tsc/internal/lsp/lsproto/lsp_generated.go index 9b2220387c7bb..bd805e7d711b3 100644 --- a/tsc/internal/lsp/lsproto/lsp_generated.go +++ b/tsc/internal/lsp/lsproto/lsp_generated.go @@ -9132,6 +9132,9 @@ type ContentMapperManifest struct { // Default source-to-runtime output extension mappings. OutputExtensions *map[string]string `json:"outputExtensions,omitzero"` + + // Default source extensions handled by the mapper. + Extensions *[]string `json:"extensions,omitzero"` } var _ json.UnmarshalerFrom = (*ContentMapperManifest)(nil) @@ -9161,7 +9164,7 @@ type ContentMapperContribution struct { ContributorId string `json:"contributorId" lsp:"required"` // File extensions handled by this content mapper. - Extensions []string `json:"extensions" lsp:"required"` + Extensions *[]string `json:"extensions,omitzero"` // When present, contributes this mapper to inferred projects. InferredProjectContribution *InferredProjectContentMapperContribution `json:"inferredProjectContribution,omitzero"` diff --git a/tsc/internal/lsp/server.go b/tsc/internal/lsp/server.go index dcb020b0ca1b7..0944e95d4888c 100644 --- a/tsc/internal/lsp/server.go +++ b/tsc/internal/lsp/server.go @@ -2559,8 +2559,12 @@ func parseContentMapperContributions(values []*lsproto.ContentMapperContribution return result, errors.New("content mapper contribution requires a contributorId") } identity := fmt.Sprintf("%s[%d]", value.ContributorId, index) - validExtensions := make([]string, 0, len(value.Extensions)) - for _, extension := range value.Extensions { + extensions := valueOrZero(value.Extensions) + if len(extensions) == 0 && value.InferredProjectContribution != nil && value.InferredProjectContribution.Manifest != nil { + extensions = valueOrZero(value.InferredProjectContribution.Manifest.Extensions) + } + validExtensions := make([]string, 0, len(extensions)) + for _, extension := range extensions { if !isValidContributedContentMapperExtension(extension) { return result, fmt.Errorf("content mapper contribution %q has invalid extension %q", identity, extension) } @@ -2608,6 +2612,7 @@ func parseContentMapperContributions(values []*lsproto.ContentMapperContribution ContributionID: identity, } mapper.Manifest.DefaultOutputExtensions = maps.Clone(valueOrZero(manifest.OutputExtensions)) + mapper.Manifest.DefaultExtensions = slices.Clone(valueOrZero(manifest.Extensions)) mapper.Definition.OutputExtensions = maps.Clone(mapper.Manifest.DefaultOutputExtensions) if manifest.Cwd != nil { if !tspath.PathIsAbsolute(*manifest.Cwd) { diff --git a/tsc/internal/lsp/server_contentmapper_internal_test.go b/tsc/internal/lsp/server_contentmapper_internal_test.go index 6f3af6a07c41e..784ddcc88d5e9 100644 --- a/tsc/internal/lsp/server_contentmapper_internal_test.go +++ b/tsc/internal/lsp/server_contentmapper_internal_test.go @@ -10,6 +10,8 @@ import ( "gotest.tools/v3/assert" ) +func stringSlice(values ...string) *[]string { return &values } + func TestContentMapperLoggerRequiresTrace(t *testing.T) { t.Parallel() var output strings.Builder @@ -33,7 +35,7 @@ func TestParseContentMapperContributions(t *testing.T) { contributions, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{ { ContributorId: "publisher.extension", - Extensions: []string{".vue"}, + Extensions: stringSlice(".vue"), InferredProjectContribution: &lsproto.InferredProjectContentMapperContribution{ Options: &options, Manifest: &lsproto.ContentMapperManifest{ @@ -42,13 +44,14 @@ func TestParseContentMapperContributions(t *testing.T) { Exec: []string{"node", "mapper.js"}, Cwd: &cwd, CompilerOptions: &compilerOptions, + Extensions: stringSlice(".toml"), OutputExtensions: &outputExtensions, }, }, }, { ContributorId: "publisher.extension", - Extensions: []string{".svelte"}, + Extensions: stringSlice(".svelte"), }, }) assert.NilError(t, err) @@ -57,6 +60,8 @@ func TestParseContentMapperContributions(t *testing.T) { mapper := contributions.Mappers[0] assert.Equal(t, mapper.Identity(), "publisher.extension[0] (Vue mapper@2.3.4)") assert.Equal(t, mapper.PackageDirectory.AsString(), cwd) + assert.DeepEqual(t, mapper.Manifest.DefaultExtensions, []string{".toml"}) + assert.DeepEqual(t, mapper.Definition.Extensions, []string{".vue"}) assert.DeepEqual(t, mapper.Manifest.DefaultOutputExtensions, map[string]string{".vue": ".js"}) assert.DeepEqual(t, mapper.Definition.OutputExtensions, map[string]string{".vue": ".js"}) outputExtensions[".vue"] = ".mjs" @@ -72,8 +77,8 @@ func TestParseContentMapperContributionsRejectsConflictingInlineMappers(t *testi return &lsproto.InferredProjectContentMapperContribution{Manifest: &lsproto.ContentMapperManifest{Name: name, Exec: []string{name}}} } _, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{ - {ContributorId: "first", Extensions: []string{".vue"}, InferredProjectContribution: inferredProjectContribution("first")}, - {ContributorId: "second", Extensions: []string{".vue"}, InferredProjectContribution: inferredProjectContribution("second")}, + {ContributorId: "first", Extensions: stringSlice(".vue"), InferredProjectContribution: inferredProjectContribution("first")}, + {ContributorId: "second", Extensions: stringSlice(".vue"), InferredProjectContribution: inferredProjectContribution("second")}, }) assert.ErrorContains(t, err, `both claim extension ".vue"`) } @@ -84,14 +89,14 @@ func TestParseContentMapperContributionsUsesCaseInsensitiveExtensions(t *testing return &lsproto.InferredProjectContentMapperContribution{Manifest: &lsproto.ContentMapperManifest{Name: name, Exec: []string{name}}} } _, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{ - {ContributorId: "first", Extensions: []string{".vue"}, InferredProjectContribution: inferredProjectContribution("first")}, - {ContributorId: "second", Extensions: []string{".VUE"}, InferredProjectContribution: inferredProjectContribution("second")}, + {ContributorId: "first", Extensions: stringSlice(".vue"), InferredProjectContribution: inferredProjectContribution("first")}, + {ContributorId: "second", Extensions: stringSlice(".VUE"), InferredProjectContribution: inferredProjectContribution("second")}, }) assert.ErrorContains(t, err, `both claim extension ".VUE"`) _, err = parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ ContributorId: "built-in", - Extensions: []string{".TS"}, + Extensions: stringSlice(".TS"), }}) assert.ErrorContains(t, err, `invalid extension ".TS"`) } @@ -100,7 +105,7 @@ func TestParseContentMapperContributionsDefaultsOptionsToObject(t *testing.T) { t.Parallel() contributions, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ ContributorId: "publisher.extension", - Extensions: []string{".vue"}, + Extensions: stringSlice(".vue"), InferredProjectContribution: &lsproto.InferredProjectContentMapperContribution{ Manifest: &lsproto.ContentMapperManifest{Name: "mapper", Exec: []string{"mapper"}}, }, @@ -109,12 +114,28 @@ func TestParseContentMapperContributionsDefaultsOptionsToObject(t *testing.T) { assert.Equal(t, string(contributions.Mappers[0].Definition.Options), `{}`) } +func TestParseContentMapperContributionsUsesManifestExtensions(t *testing.T) { + t.Parallel() + contributions, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ + ContributorId: "mapper", + InferredProjectContribution: &lsproto.InferredProjectContentMapperContribution{ + Manifest: &lsproto.ContentMapperManifest{ + Name: "mapper", Exec: []string{"mapper"}, Extensions: stringSlice(".toml"), + }, + }, + }}) + assert.NilError(t, err) + assert.DeepEqual(t, contributions.Extensions, []string{".toml"}) + assert.DeepEqual(t, contributions.Mappers[0].Definition.Extensions, []string{".toml"}) + assert.DeepEqual(t, contributions.Mappers[0].Manifest.DefaultExtensions, []string{".toml"}) +} + func TestParseContentMapperContributionsCompoundExtensions(t *testing.T) { t.Parallel() for _, extension := range []string{".y.z", ".component.tsx.vue"} { contributions, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ ContributorId: "mapper", - Extensions: []string{extension}, + Extensions: stringSlice(extension), InferredProjectContribution: &lsproto.InferredProjectContentMapperContribution{ Manifest: &lsproto.ContentMapperManifest{Name: "mapper", Exec: []string{"mapper"}}, }, @@ -125,7 +146,7 @@ func TestParseContentMapperContributionsCompoundExtensions(t *testing.T) { for _, nativeExtension := range core.Flatten(tspath.AllSupportedExtensionsWithJson) { for _, extension := range []string{".component" + nativeExtension, ".component" + strings.ToUpper(nativeExtension)} { _, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ - ContributorId: "mapper", Extensions: []string{extension}, + ContributorId: "mapper", Extensions: stringSlice(extension), }}) assert.ErrorContains(t, err, "invalid extension") } @@ -138,7 +159,7 @@ func TestParseContentMapperContributionsRejectsInvalidOutputExtensions(t *testin outputExtensions := map[string]string{".vue": "js"} _, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ ContributorId: "publisher.extension", - Extensions: []string{".vue"}, + Extensions: stringSlice(".vue"), InferredProjectContribution: &lsproto.InferredProjectContentMapperContribution{ Manifest: &lsproto.ContentMapperManifest{ Name: "mapper", Exec: []string{"mapper"}, @@ -154,12 +175,12 @@ func TestParseContentMapperContributionsRejectsExtensionPaths(t *testing.T) { t.Parallel() for _, extension := range []string{".vue/foo", `.vue\foo`} { _, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ - ContributorId: "mapper", Extensions: []string{extension}, + ContributorId: "mapper", Extensions: stringSlice(extension), }}) assert.ErrorContains(t, err, "invalid extension") for _, outputExtensions := range []map[string]string{{extension: ".js"}, {".vue": extension}} { _, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ - ContributorId: "mapper", Extensions: []string{".vue"}, + ContributorId: "mapper", Extensions: stringSlice(".vue"), InferredProjectContribution: &lsproto.InferredProjectContentMapperContribution{ Manifest: &lsproto.ContentMapperManifest{Name: "mapper", Exec: []string{"mapper"}, OutputExtensions: &outputExtensions}, }, diff --git a/tsc/internal/lsp/server_contentmapper_test.go b/tsc/internal/lsp/server_contentmapper_test.go index 2a8595d1c8da5..61b160e4cc617 100644 --- a/tsc/internal/lsp/server_contentmapper_test.go +++ b/tsc/internal/lsp/server_contentmapper_test.go @@ -17,6 +17,8 @@ import ( "gotest.tools/v3/assert" ) +func stringSlice(values ...string) *[]string { return &values } + func TestSetContentMapperContributionsBeforeDidOpen(t *testing.T) { t.Parallel() if !bundled.Embedded { @@ -122,7 +124,7 @@ export const title = "Profile"; OpenDocuments: []lsproto.TextDocumentIdentifier{{Uri: uri}}, Contributions: []*lsproto.ContentMapperContribution{{ ContributorId: "test", - Extensions: []string{".vue", ".svelte"}, + Extensions: stringSlice(".vue", ".svelte"), }}, }) assert.Assert(t, ok && msg.AsResponse().Error == nil) diff --git a/tsc/internal/packagejson/packagejson.go b/tsc/internal/packagejson/packagejson.go index 5251e8764611b..bb68004e15a23 100644 --- a/tsc/internal/packagejson/packagejson.go +++ b/tsc/internal/packagejson/packagejson.go @@ -115,6 +115,7 @@ type Fields struct { } type ContentMapperFields struct { + Extensions Expected[[]string] `json:"extensions"` Exec Expected[[]string] `json:"exec"` CompilerOptions Expected[[]string] `json:"compilerOptions"` DynamicConfig Expected[bool] `json:"dynamicConfig"` diff --git a/tsc/internal/tsoptions/contentmappers.go b/tsc/internal/tsoptions/contentmappers.go index e3d2a4bc04c10..14123ad8dbfe1 100644 --- a/tsc/internal/tsoptions/contentmappers.go +++ b/tsc/internal/tsoptions/contentmappers.go @@ -78,6 +78,12 @@ func resolveContentMapperManifest(host ParseConfigHost, containingFile tspath.Ro return contentmapper.Manifest{}, packageDirectory, ast.NewCompilerDiagnostic(diagnostics.The_typescript_contentMapper_exec_of_the_content_mapper_package_0_must_be_a_non_empty_array_of_strings, packageName) } compilerOptions, _ := cm.CompilerOptions.GetValue() + extensions, valid := cm.Extensions.GetValue() + if cm.Extensions.IsPresent() && !valid { + return contentmapper.Manifest{}, packageDirectory, ast.NewCompilerDiagnostic( + diagnostics.Compiler_option_0_requires_a_value_of_type_1, "typescript.contentMapper.extensions", "string[]", + ) + } dynamicConfig, _ := cm.DynamicConfig.GetValue() outputExtensions, valid := cm.OutputExtensions.GetValue() if cm.OutputExtensions.IsPresent() && !valid { @@ -105,5 +111,5 @@ func resolveContentMapperManifest(host ParseConfigHost, containingFile tspath.Ro ) } } - return contentmapper.Manifest{Name: name, Version: version, Exec: exec, CompilerOptions: compilerOptions, DynamicConfig: dynamicConfig, DefaultOutputExtensions: outputExtensions}, packageDirectory, nil + return contentmapper.Manifest{Name: name, Version: version, Exec: exec, CompilerOptions: compilerOptions, DynamicConfig: dynamicConfig, DefaultOutputExtensions: outputExtensions, DefaultExtensions: extensions}, packageDirectory, nil } diff --git a/tsc/internal/tsoptions/contentmappers_test.go b/tsc/internal/tsoptions/contentmappers_test.go index 68137b0ac1203..dc49da084e2de 100644 --- a/tsc/internal/tsoptions/contentmappers_test.go +++ b/tsc/internal/tsoptions/contentmappers_test.go @@ -38,6 +38,71 @@ func TestGetContentMapperForFileNameUsesHostCaseSensitivity(t *testing.T) { assert.Assert(t, sensitive.GetContentMapperForFileName("/src/Component.VUE") == nil) } +func TestContentMapperManifestExtensionValidation(t *testing.T) { + t.Parallel() + for _, extensions := range []string{`null`, `"toml"`, `[1]`, `["toml"]`, `[".ts"]`, `[".foo.ts"]`, `[".a/b"]`} { + t.Run(extensions, func(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]string{ + "/project/tsconfig.json": `{"contentMappers": [{"package": "mapper"}]}`, + "/project/index.ts": "", + "/project/node_modules/mapper/package.json": `{ + "name": "mapper", "typescript": {"contentMapper": {"exec": ["run"], "extensions": ` + extensions + `}} + }`, + }, tspath.CaseSensitive) + config, errors := GetParsedCommandLineOfConfigFile( + "/project/tsconfig.json", &core.CompilerOptions{RunExternalCode: core.TSTrue}, nil, NewDefaultParseConfigHost(fs), nil, + ) + assert.Equal(t, len(errors), 0) + assert.Equal(t, len(config.Errors), 1) + assert.Assert(t, config.Errors[0].File() != nil) + assert.Equal(t, len(config.ContentMapperExtensions()), 0) + }) + } +} + +func TestContentMapperIncludeAndManifestExtensions(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]string{ + "/project/tsconfig.json": `{ + "compilerOptions": { "noLib": true }, + "include": ["blob.toml", "index.ts", "src"], + "contentMappers": [ + { "package": "mapper" } + ] + }`, + "/project/node_modules/mapper/package.json": `{ + "name": "mapper", + "typescript": { "contentMapper": { "exec": ["run"], "extensions": [".toml"] } } + }`, + "/project/src/config.toml": "", + "/project/src/.hidden/config.toml": "", + "/project/blob.toml": "", + "/project/other.toml": "", + "/project/index.ts": "", + "/project/node_modules/pkg/blob.toml": "", + "/project/node_modules/pkg/other.toml": "", + }, tspath.CaseSensitive) + commandLine, errors := GetParsedCommandLineOfConfigFile( + "/project/tsconfig.json", + &core.CompilerOptions{RunExternalCode: core.TSTrue}, nil, + NewDefaultParseConfigHost(fs), nil, + ) + assert.Equal(t, len(errors), 0) + assert.Equal(t, len(commandLine.GetConfigFileParsingDiagnostics()), 0) + assert.DeepEqual(t, commandLine.ContentMapperExtensions(), []string{".toml"}) + assert.DeepEqual(t, commandLine.FileNames(), []tspath.RootedFilePath{ + "/project/blob.toml", "/project/index.ts", "/project/src/config.toml", + }) + for _, file := range []tspath.RootedFilePath{ + "/project/src/config.toml", "/project/src/.hidden/config.toml", + "/project/blob.toml", "/project/node_modules/pkg/blob.toml", + } { + assert.Assert(t, commandLine.GetContentMapperForFileName(file) != nil, file) + } + assert.Assert(t, commandLine.GetContentMapperForFileName("/project/src/file.ts") == nil) +} + func TestGetOutputFileNamesExcludesMapperOwnedOutputs(t *testing.T) { t.Parallel() mapper := &contentmapper.Mapper{Extensions: []string{".vue"}} diff --git a/tsc/internal/tsoptions/parsedoptions_test.go b/tsc/internal/tsoptions/parsedoptions_test.go index 3bfd5dc5ee81e..cb21d447c08fe 100644 --- a/tsc/internal/tsoptions/parsedoptions_test.go +++ b/tsc/internal/tsoptions/parsedoptions_test.go @@ -39,6 +39,7 @@ func TestParsedOptionsEquality(t *testing.T) { } result.ContentMappers[0].Definition.OutputExtensions = map[string]string{".vue": ".mjs"} result.ContentMappers[0].Manifest.DefaultOutputExtensions = map[string]string{".vue": ".js"} + result.ContentMappers[0].Manifest.DefaultExtensions = []string{".vue"} return result } check(t, makeOptions(), makeOptions()) @@ -88,6 +89,7 @@ func TestParsedOptionsEquality(t *testing.T) { }}, {"mapper package", func(p *ParsedOptions) { p.ContentMappers[0].Package = "other" }}, {"mapper extension", func(p *ParsedOptions) { p.ContentMappers[0].Extensions[0] = ".other" }}, + {"manifest extension", func(p *ParsedOptions) { p.ContentMappers[0].DefaultExtensions[0] = ".other" }}, {"mapper output extension", func(p *ParsedOptions) { p.ContentMappers[0].Definition.OutputExtensions[".vue"] = ".js" }}, {"mapper options", func(p *ParsedOptions) { p.ContentMappers[0].Options = []byte(`{}`) }}, {"mapper name", func(p *ParsedOptions) { p.ContentMappers[0].Name = "other" }}, diff --git a/tsc/internal/tsoptions/parsinghelpers.go b/tsc/internal/tsoptions/parsinghelpers.go index a4fa63f7ced5f..aff2c22bd426e 100644 --- a/tsc/internal/tsoptions/parsinghelpers.go +++ b/tsc/internal/tsoptions/parsinghelpers.go @@ -157,8 +157,6 @@ func parseContentMapper(value any) (*contentmapper.Mapper, []*ast.Diagnostic) { } else { errors = append(errors, ast.NewCompilerDiagnostic(diagnostics.Compiler_option_0_requires_a_value_of_type_1, "contentMapper.extensions", "string[]")) } - } else { - errors = append(errors, ast.NewCompilerDiagnostic(diagnostics.Compiler_option_0_requires_a_value_of_type_1, "contentMapper.extensions", "string[]")) } if outputExtensions, ok := v.Get("outputExtensions"); ok && outputExtensions != nil { if values, isStringMap := parseStringStringMapStrict(outputExtensions); isStringMap { diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go index f2ea2fdc07f73..31cfd7006afdc 100644 --- a/tsc/internal/tsoptions/tsconfigparsing.go +++ b/tsc/internal/tsoptions/tsconfigparsing.go @@ -1326,6 +1326,10 @@ func parseJsonConfigFileContentWorker( } var contentMappers []*contentmapper.Mapper var contentMapperIndices []int + containingFile := configFileName + if containingFile == "" { + containingFile = basePathForFileNames.ResolveFile("tsconfig.json") + } contentMappersOfRaw := getPropFromRaw("contentMappers", func(element any) bool { return reflect.TypeOf(element) == orderedMapType }, "object") for i, element := range contentMappersOfRaw.sliceValue { mapper, mapperErrors := parseContentMapper(element) @@ -1333,6 +1337,28 @@ func parseJsonConfigFileContentWorker( errors = append(errors, setContentMapperDiagnosticLocation(mapperError, contentMapperSourceFile, getContentMapperSyntax(contentMapperSourceFile, i, ""))) } if mapper != nil { + rawMapper := element.(*collections.OrderedMap[string, any]) + if parsedConfig.options != nil && parsedConfig.options.RunExternalCode.IsTrue() { + manifest, packageDirectory, diagnostic := resolveContentMapperManifest(host, containingFile, mapper.Package) + mapper.PackageDirectory = packageDirectory + if diagnostic != nil { + errors = append(errors, setContentMapperDiagnosticLocation(diagnostic, contentMapperSourceFile, getContentMapperSyntax(contentMapperSourceFile, i, "package"))) + } else { + mapper.Manifest = manifest + if mapper.Extensions == nil { + mapper.Extensions = slices.Clone(manifest.DefaultExtensions) + if mapper.Extensions == nil { + errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic( + diagnostics.Content_mapper_0_must_specify_extensions_in_tsconfig_or_its_package_manifest, mapper.Package, + ), contentMapperSourceFile, getContentMapperSyntax(contentMapperSourceFile, i, "package"))) + continue + } + } + if !rawMapper.Has("outputExtensions") { + mapper.Definition.OutputExtensions = maps.Clone(manifest.DefaultOutputExtensions) + } + } + } contentMappers = append(contentMappers, mapper) contentMapperIndices = append(contentMapperIndices, i) } @@ -1347,9 +1373,13 @@ func parseJsonConfigFileContentWorker( return fs.CaseSensitivity().Canonicalize(extension) } for j, mapper := range contentMappers { + rawMapper := contentMappersOfRaw.sliceValue[contentMapperIndices[j]].(*collections.OrderedMap[string, any]) validExtensions := make([]string, 0, len(mapper.Definition.Extensions)) for _, ext := range mapper.Definition.Extensions { extNode := getContentMapperExtensionSyntax(contentMapperSourceFile, contentMapperIndices[j], ext) + if !rawMapper.Has("extensions") { + extNode = getContentMapperSyntax(contentMapperSourceFile, contentMapperIndices[j], "package") + } canonicalExt := canonicalExtension(ext) switch { case !strings.HasPrefix(ext, "."): @@ -1382,7 +1412,7 @@ func parseJsonConfigFileContentWorker( } else if !contentmapper.IsValidExtension(outputExtension) { errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mapper_output_extension_0_must_be_non_empty_and_begin_with_a, outputExtension), contentMapperSourceFile, outputExtensionsNode)) } - if !slices.ContainsFunc(validExtensions, func(extension string) bool { + if rawMapper.Has("outputExtensions") && !slices.ContainsFunc(validExtensions, func(extension string) bool { return canonicalExtension(extension) == canonicalExtension(sourceExtension) }) { errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mapper_output_extension_mapping_source_0_is_not_registered_by_this_content_mapper, sourceExtension), contentMapperSourceFile, outputExtensionsNode)) @@ -1396,25 +1426,12 @@ func parseJsonConfigFileContentWorker( contentMappers = nil contentMapperExtensions = nil } else if len(contentMappers) > 0 { - // Resolve each mapper's package.json now so its name, version, and run command are available to - // everything downstream (diagnostics, build-info staleness) without executing anything. - containingFile := configFileName - if containingFile == "" { - containingFile = basePathForFileNames.ResolveFile("tsconfig.json") - } resolvedContentMappers := make([]*contentmapper.Mapper, 0, len(contentMappers)) for j, mapper := range contentMappers { - manifest, packageDirectory, diagnostic := resolveContentMapperManifest(host, containingFile, mapper.Package) - mapper.PackageDirectory = packageDirectory - if diagnostic != nil { - errors = append(errors, setContentMapperDiagnosticLocation(diagnostic, contentMapperSourceFile, getContentMapperSyntax(contentMapperSourceFile, contentMapperIndices[j], "package"))) + if mapper.Name == "" { + // Manifest resolution failed. continue } - mapper.Manifest = manifest - rawMapper := contentMappersOfRaw.sliceValue[contentMapperIndices[j]].(*collections.OrderedMap[string, any]) - if _, present := rawMapper.Get("outputExtensions"); !present { - mapper.Definition.OutputExtensions = maps.Clone(manifest.DefaultOutputExtensions) - } for _, sourceExtension := range mapper.Definition.Extensions { outputExtension, ok := getContentMapperOutputExtension(mapper.Definition.OutputExtensions, sourceExtension, fs.CaseSensitivity().IsCaseInsensitive()) identity := sourceExtension == outputExtension || fs.CaseSensitivity().IsCaseInsensitive() && strings.EqualFold(sourceExtension, outputExtension) diff --git a/tsc/internal/tsoptions/tsconfigparsing_test.go b/tsc/internal/tsoptions/tsconfigparsing_test.go index c5f50dc795f43..1a0c6ca87c24b 100644 --- a/tsc/internal/tsoptions/tsconfigparsing_test.go +++ b/tsc/internal/tsoptions/tsconfigparsing_test.go @@ -1463,7 +1463,7 @@ func TestContentMappersValidation(t *testing.T) { { name: "missing extensions", contentMappers: `[{ "package": "x" }]`, - expectedCode: diagnostics.Compiler_option_0_requires_a_value_of_type_1.Code(), + expectedCode: diagnostics.Content_mapper_0_must_specify_extensions_in_tsconfig_or_its_package_manifest.Code(), }, { name: "duplicate extension across mappers", @@ -1511,6 +1511,9 @@ func TestContentMappersValidation(t *testing.T) { config.allFileList["/node_modules/a/package.json"] = `{ "name": "a", "version": "1.0.0", "typescript": { "contentMapper": { "exec": ["a"] } } }` config.allFileList["/node_modules/b/package.json"] = `{ "name": "b", "version": "1.0.0", "typescript": { "contentMapper": { "exec": ["b"] } } }` } + if test.name == "missing extensions" { + config.allFileList["/node_modules/x/package.json"] = `{ "name": "x", "typescript": { "contentMapper": { "exec": ["x"] } } }` + } for apiName, getParsed := range map[string]func(testConfig, vfs.FS, tspath.RootedDirectoryPath) *tsoptions.ParsedCommandLine{ "json api": getParsedWithJsonApi, "jsonSourceFile api": getParsedWithJsonSourceFileApi, From 77aabee7f7f2209fc439c4150fb09de936d85297 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 29 Oct 2026 16:51:55 -0700 Subject: [PATCH 2/2] Allow content mappers to filter their files by an include glob Narrow mapper claims with tsconfig-style include patterns. Use the winning mapper consistently for discovery, resolution, language services, and output naming, and reject incompatible output mappings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../typescript/schemas/jsconfig.schema.json | 21 ++ .../typescript/schemas/tsconfig.schema.json | 21 ++ tools/scripts/tsc/options-schema.ts | 9 + tools/scripts/tsc/options.test.ts | 6 + tsc/internal/api/module_resolution.go | 11 +- .../api/session_module_resolution_test.go | 50 +++ tsc/internal/checker/checker.go | 2 + tsc/internal/checker/nodebuilderimpl.go | 4 + tsc/internal/compiler/contentmapper_test.go | 112 +++++++ tsc/internal/compiler/emitHost.go | 4 +- tsc/internal/compiler/emitter.go | 15 +- tsc/internal/compiler/fileloader.go | 15 +- tsc/internal/compiler/program.go | 4 +- tsc/internal/contentmapper/contentmapper.go | 5 +- tsc/internal/contentmapper/filematcher.go | 94 ++++++ .../diagnosticMessages.generated.json | 4 + .../diagnostics/diagnosticMessages.json | 16 + .../diagnostics/diagnostics_generated.go | 12 + .../tsctests/contentmapper_inclusion_test.go | 46 +++ .../tests/contentMapperInclusion_test.go | 57 ++++ tsc/internal/ls/autoimport/aliasresolver.go | 5 +- tsc/internal/ls/sourcedefinition.go | 9 +- tsc/internal/ls/string_completions.go | 22 +- tsc/internal/lsp/server.go | 42 ++- .../lsp/server_contentmapper_internal_test.go | 22 ++ tsc/internal/lsp/server_contentmapper_test.go | 18 +- tsc/internal/module/cache.go | 32 +- tsc/internal/module/resolver.go | 43 ++- tsc/internal/module/resolver_test.go | 38 +++ .../modulespecifiers/specifiers_test.go | 4 +- tsc/internal/modulespecifiers/types.go | 2 +- tsc/internal/outputpaths/outputpaths.go | 36 +- tsc/internal/outputpaths/outputpaths_test.go | 4 +- tsc/internal/printer/emithost.go | 1 + tsc/internal/project/client.go | 18 +- tsc/internal/project/configfileregistry.go | 57 +++- .../project/configfileregistry_test.go | 73 ++++ tsc/internal/project/contentmapper_test.go | 61 +++- .../project/extendedconfigcache_test.go | 2 +- tsc/internal/project/session.go | 34 +- .../testutil/harnessutil/harnessutil.go | 3 +- .../projecttestutil/clientmock_generated.go | 84 ++--- tsc/internal/transformers/chain.go | 15 +- .../transformers/declarations/transform.go | 2 + .../moduletransforms/commonjsmodule.go | 38 ++- .../transformers/moduletransforms/esmodule.go | 26 +- .../moduletransforms/utilities.go | 4 +- .../tstransforms/importelision_test.go | 4 +- tsc/internal/tsoptions/contentmappers_test.go | 314 +++++++++++++++++- tsc/internal/tsoptions/parsedcommandline.go | 32 +- tsc/internal/tsoptions/parsedoptions_test.go | 3 +- tsc/internal/tsoptions/parsinghelpers.go | 7 + tsc/internal/tsoptions/tsconfigparsing.go | 173 +++++++--- .../tsoptions/tsconfigparsing_test.go | 25 +- .../vfs/vfsmatch/MATCHING_ALGORITHM.md | 9 +- .../vfs/vfsmatch/stringer_generated.go | 5 +- tsc/internal/vfs/vfsmatch/vfsmatch.go | 10 +- ...-unclaimed-imports-keep-their-extension.js | 81 +++++ ...rowing-include-and-declaration-fallback.js | 81 +++++ 59 files changed, 1674 insertions(+), 273 deletions(-) create mode 100644 tsc/internal/contentmapper/filematcher.go create mode 100644 tsc/internal/execute/tsctests/contentmapper_inclusion_test.go create mode 100644 tsc/internal/fourslash/tests/contentMapperInclusion_test.go create mode 100644 tsc/internal/project/configfileregistry_test.go create mode 100644 tsc/testdata/baselines/reference/tsc/contentMapperInclusion/filtered-files-rewrite-but-unclaimed-imports-keep-their-extension.js create mode 100644 tsc/testdata/baselines/reference/tsc/contentMapperInclusion/manifest-extensions-with-a-narrowing-include-and-declaration-fallback.js diff --git a/packages/typescript/schemas/jsconfig.schema.json b/packages/typescript/schemas/jsconfig.schema.json index 2dcb5f37add6e..7a1d9a50dbd41 100644 --- a/packages/typescript/schemas/jsconfig.schema.json +++ b/packages/typescript/schemas/jsconfig.schema.json @@ -88,6 +88,27 @@ }, "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" } diff --git a/packages/typescript/schemas/tsconfig.schema.json b/packages/typescript/schemas/tsconfig.schema.json index 9f0934744d3f2..5bc10ba9414fb 100644 --- a/packages/typescript/schemas/tsconfig.schema.json +++ b/packages/typescript/schemas/tsconfig.schema.json @@ -88,6 +88,27 @@ }, "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" } diff --git a/tools/scripts/tsc/options-schema.ts b/tools/scripts/tsc/options-schema.ts index 148728b66610c..8dee3358b5516 100644 --- a/tools/scripts/tsc/options-schema.ts +++ b/tools/scripts/tsc/options-schema.ts @@ -115,6 +115,15 @@ function valueSchema(declaration: Declaration): JSONSchema { 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"], diff --git a/tools/scripts/tsc/options.test.ts b/tools/scripts/tsc/options.test.ts index 870729a30466c..d4fa3ab8b1448 100644 --- a/tools/scripts/tsc/options.test.ts +++ b/tools/scripts/tsc/options.test.ts @@ -353,6 +353,9 @@ test("schemas accept representative valid configs and reject malformed configs", { 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 = [ @@ -380,6 +383,9 @@ test("schemas accept representative valid configs and reject malformed configs", { references: [{ path: null }] }, { contentMappers: [{ package: "", extensions: [] }] }, { 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": "