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
4 changes: 3 additions & 1 deletion packages/typescript/schemas/jsconfig.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@
},
"extensions": {
"type": "array",
"description": "Source extensions beginning with a dot and containing no other dots or path separators.",
"items": {
"type": "string"
"type": "string",
"pattern": "^\\.[^./\\\\]+$"
}
},
"options": {
Expand Down
4 changes: 3 additions & 1 deletion packages/typescript/schemas/tsconfig.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@
},
"extensions": {
"type": "array",
"description": "Source extensions beginning with a dot and containing no other dots or path separators.",
"items": {
"type": "string"
"type": "string",
"pattern": "^\\.[^./\\\\]+$"
}
},
"options": {
Expand Down
12 changes: 11 additions & 1 deletion packages/vscode-typescript/src/contentMapperContributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ export function validateContentMapperRegistration(contributorId: string, contrib
if (contribution.extensions.length === 0 || contribution.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 (contribution.extensions.some(extension => !isSourceExtension(extension))) {
throw new TypeError("Content mapper source extensions must contain only one dot.");
}
if (contribution.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,13 +72,20 @@ 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.outputExtensions && Object.keys(inferredProjectContribution.manifest.outputExtensions).some(source => !isSourceExtension(source))) {
throw new TypeError("Content mapper source extensions must contain only one dot.");
}
}
}

function isExtension(value: string): boolean {
return value.length > 1 && value.startsWith(".") && !/[\\/]/.test(value);
}

function isSourceExtension(value: string): boolean {
return isExtension(value) && !value.slice(1).includes(".");
}

export function documentMatchesContentMapperContributions(
document: { readonly uri: { readonly path: string; }; },
registrations: ReadonlyMap<string, readonly ContentMapperContribution[]>,
Expand Down
28 changes: 22 additions & 6 deletions packages/vscode-typescript/test/contentMapperContributions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,33 @@ describe("content mapper contributions", { concurrency: true }, () => {
}
});

test("accepts compound extensions with non-reserved suffixes", () => {
test("accepts single-dot sources and compound output suffixes", () => {
assert.doesNotThrow(() =>
validateContentMapperRegistration("mapper", [{
extensions: [".y.z", ".component.tsx.vue"],
extensions: [".toml", ".vue"],
inferredProjectContribution: {
manifest: { ...documentedContribution.inferredProjectContribution.manifest, outputExtensions: { ".toml": ".part.js" } },
},
}])
);
});

test("rejects builtin extensions and compound extensions ending in them", () => {
for (const extension of [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json", ".d.ts", ".d.mts", ".d.cts"]) {
for (const source of [extension, extension.toUpperCase(), `.component${extension}`, `.component${extension.toUpperCase()}`]) {
test("rejects compound source extensions and output mapping keys", () => {
for (const extension of [".part.toml", ".y.z", ".d.ts", ".d.mts", ".d.cts"]) {
assert.throws(() => validateContentMapperRegistration("mapper", [{ extensions: [extension] }]), /only one dot/);
assert.throws(() =>
validateContentMapperRegistration("mapper", [{
...documentedContribution,
inferredProjectContribution: {
manifest: { ...documentedContribution.inferredProjectContribution.manifest, outputExtensions: { [extension]: ".js" } },
},
}]), /only one dot/);
}
});

test("rejects builtin extensions", () => {
for (const extension of [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"]) {
for (const source of [extension, extension.toUpperCase()]) {
assert.throws(() =>
validateContentMapperRegistration("mapper", [{
extensions: [source],
Expand All @@ -62,7 +78,7 @@ describe("content mapper contributions", { concurrency: true }, () => {
"publisher.extension",
[{ extensions: [".vue"] }],
]]);
const document = { uri: { path: "/workspace/Component.VUE" } };
const document = { uri: { path: "/workspace/Component.part.VUE" } };

assert.equal(documentMatchesContentMapperContributions(document, registrations), true);
});
Expand Down
6 changes: 5 additions & 1 deletion tools/scripts/tsc/options-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,11 @@ function valueSchema(declaration: Declaration): JSONSchema {
type: "object",
properties: {
package: { type: "string", minLength: 1 },
extensions: { type: "array", items: { type: "string" } },
extensions: {
type: "array",
description: "Source extensions beginning with a dot and containing no other dots or path separators.",
items: { type: "string", pattern: "^\\.[^./\\\\]+$" },
},
options: { type: "object" },
},
required: ["package", "extensions"],
Expand Down
6 changes: 5 additions & 1 deletion tools/scripts/tsc/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ test("schemas accept representative valid configs and reject malformed configs",
{ compilerOptions: { target: "ESNext", module: "NodeNext", moduleResolution: "BUNDLER", jsx: "React-JSX", newLine: "LF" } },
{ 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", extensions: [".toml", ".vue"], options: { customSetting: true } }] },
{ "$schema": "./tsconfig.schema.json", "tool-specific": { anything: true } },
];
const invalid = [
Expand Down Expand Up @@ -402,6 +402,10 @@ test("schemas accept representative valid configs and reject malformed configs",
{ contentMappers: [{ package: "", extensions: [] }] },
{ contentMappers: [{ package: "mapper" }] },
{ contentMappers: [{ package: "mapper", extensions: [1] }] },
{ contentMappers: [{ package: "mapper", extensions: [".part.toml"] }] },
{ contentMappers: [{ package: "mapper", extensions: [".y.z"] }] },
{ contentMappers: [{ package: "mapper", extensions: [".vue/foo"] }] },
{ contentMappers: [{ package: "mapper", extensions: [".vue\\foo"] }] },
{ contentMappers: [{ package: "mapper", extensions: [], options: null }] },
];
for (const kind of ["tsconfig", "jsconfig"] as const) {
Expand Down
3 changes: 1 addition & 2 deletions tsc/internal/checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -15635,8 +15635,7 @@ func (c *Checker) resolveExternalModule(
} else if resolvedUsingRewritableExtension && !shouldRewrite && (mappedSource || c.program.SourceFileMayBeEmitted(sourceFile, false)) {
extension := tspath.GetAnyExtensionFromPath(moduleReference, nil, tspath.CaseSensitive)
if mappedSource {
rewrite, _ := sourceFile.FileName().GetExtensionRewrite(rewrites, caseSensitivity)
extension = rewrite.Source
extension = sourceFile.FileName().AnyExtension(nil, caseSensitivity)
}
c.error(
errorNode,
Expand Down
7 changes: 5 additions & 2 deletions tsc/internal/contentmapper/contentmapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,14 @@ func IsValidExtension(extension string) bool {
return len(extension) > 1 && extension[0] == '.' && !strings.ContainsAny(extension, "/\\")
}

func IsValidSourceExtension(extension string) bool {
return IsValidExtension(extension) && !strings.Contains(extension[1:], ".")
}

func HasReservedSourceExtension(extension string) bool {
for _, extensions := range tspath.AllSupportedExtensionsWithJson {
for _, nativeExtension := range extensions {
if len(extension) >= len(nativeExtension) &&
strings.EqualFold(extension[len(extension)-len(nativeExtension):], nativeExtension) {
if strings.EqualFold(extension, nativeExtension) {
return true
}
}
Expand Down
1 change: 1 addition & 0 deletions tsc/internal/diagnostics/diagnosticMessages.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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_source_extension_0_must_contain_only_one_dot_18125": "Content mapper source extension '{0}' must contain only one dot.",
"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.",
Expand Down
4 changes: 4 additions & 0 deletions tsc/internal/diagnostics/diagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -8955,5 +8955,9 @@
"Content mapper extension '{0}' must not contain path separators.": {
"category": "Error",
"code": 18123
},
"Content mapper source extension '{0}' must contain only one dot.": {
"category": "Error",
"code": 18125
}
}
3 changes: 3 additions & 0 deletions tsc/internal/diagnostics/diagnostics_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 2 additions & 5 deletions tsc/internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2580,7 +2580,7 @@ func parseContentMapperContributions(values []*lsproto.ContentMapperContribution
}
}
for source, output := range valueOrZero(manifest.OutputExtensions) {
if !contentmapper.IsValidExtension(source) || !contentmapper.IsValidExtension(output) {
if !contentmapper.IsValidSourceExtension(source) || !contentmapper.IsValidExtension(output) {
return result, fmt.Errorf("content mapper contribution %q has invalid output extension mapping from %q to %q", identity, source, output)
}
}
Expand Down Expand Up @@ -2622,10 +2622,7 @@ func parseContentMapperContributions(values []*lsproto.ContentMapperContribution
}

func isValidContributedContentMapperExtension(extension string) bool {
if !contentmapper.IsValidExtension(extension) {
return false
}
return !contentmapper.HasReservedSourceExtension(extension)
return contentmapper.IsValidSourceExtension(extension) && !contentmapper.HasReservedSourceExtension(extension)
}

func valueOrZero[T any](value *T) T {
Expand Down
26 changes: 18 additions & 8 deletions tsc/internal/lsp/server_contentmapper_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,25 +109,35 @@ func TestParseContentMapperContributionsDefaultsOptionsToObject(t *testing.T) {
assert.Equal(t, string(contributions.Mappers[0].Definition.Options), `{}`)
}

func TestParseContentMapperContributionsCompoundExtensions(t *testing.T) {
func TestParseContentMapperContributionsSourceExtensions(t *testing.T) {
t.Parallel()
for _, extension := range []string{".y.z", ".component.tsx.vue"} {
for _, extension := range []string{".toml", ".vue"} {
outputExtensions := map[string]string{extension: ".part.js"}
contributions, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{
ContributorId: "mapper",
Extensions: []string{extension},
InferredProjectContribution: &lsproto.InferredProjectContentMapperContribution{
Manifest: &lsproto.ContentMapperManifest{Name: "mapper", Exec: []string{"mapper"}},
Manifest: &lsproto.ContentMapperManifest{Name: "mapper", Exec: []string{"mapper"}, OutputExtensions: &outputExtensions},
},
}})
assert.NilError(t, err)
assert.DeepEqual(t, contributions.Extensions, []string{extension})
assert.DeepEqual(t, contributions.Mappers[0].Definition.OutputExtensions, outputExtensions)
}
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},
for _, extension := range append(core.Flatten(tspath.AllSupportedExtensionsWithJson), ".part.toml", ".y.z") {
_, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{
ContributorId: "mapper", Extensions: []string{extension},
}})
assert.ErrorContains(t, err, "invalid extension")
outputExtensions := map[string]string{extension: ".js"}
if extension == ".part.toml" || extension == ".y.z" {
_, err = parseContentMapperContributions([]*lsproto.ContentMapperContribution{{
ContributorId: "mapper", Extensions: []string{".toml"},
InferredProjectContribution: &lsproto.InferredProjectContentMapperContribution{
Manifest: &lsproto.ContentMapperManifest{Name: "mapper", Exec: []string{"mapper"}, OutputExtensions: &outputExtensions},
},
}})
assert.ErrorContains(t, err, "invalid extension")
assert.ErrorContains(t, err, "invalid output extension mapping")
}
}
}
Expand Down
10 changes: 3 additions & 7 deletions tsc/internal/module/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,12 @@ func (c resolutionCandidate) RelativeToDirectory(directory tspath.RootedDirector
return relative
}

func (c resolutionCandidate) SplitExtension(extraExtensions []string) (tspath.FileNameStem, string) {
func (c resolutionCandidate) SplitExtension() (tspath.FileNameStem, string) {
fileName := tspath.RootedFilePathFromPath(c.path)
extension := fileName.Extension()
extensionless := c.RemoveFileExtension()
if extensionless.AsString() == c.path.AsString() {
extension = fileName.LongestExtension(extraExtensions, tspath.CaseSensitive)
if extension == "" {
path := c.path.AsString()
extension = path[strings.LastIndex(path, "."):]
}
extension = fileName.AnyExtension(nil, tspath.CaseSensitive)
extensionless = c.RemoveExtension(extension)
}
return extensionless, extension
Expand Down Expand Up @@ -1642,7 +1638,7 @@ func (r *resolutionState) loadModuleFromFileNoImplicitExtensions(extensions exte
if !strings.Contains(base, ".") {
return continueSearching() // extensionless import, no lookups performed, since we don't support extensionless files
}
extensionless, extension := candidate.SplitExtension(r.resolver.extraExtensions)
extensionless, extension := candidate.SplitExtension()
if r.tracer != nil {
r.tracer.write(diagnostics.File_name_0_has_a_1_extension_stripping_it, candidate, extension)
}
Expand Down
12 changes: 3 additions & 9 deletions tsc/internal/outputpaths/outputpaths.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ func GetOutputJSFileName(inputFileName tspath.RootedFilePath, options *core.Comp
}

func isContentMappedFileName(fileName tspath.RootedFilePath, host OutputPathsHost) bool {
return fileName.LongestExtension(host.ContentMapperExtensions(), host.CaseSensitivity()) != ""
extensions := host.ContentMapperExtensions()
return len(extensions) > 0 && fileName.AnyExtension(extensions, host.CaseSensitivity()) != ""
}

func GetOutputJSFileNameWorker(inputFileName tspath.RootedFilePath, options *core.CompilerOptions, host OutputPathsHost) tspath.RootedFilePath {
Expand Down Expand Up @@ -185,15 +186,8 @@ func getDeclarationEmitOutputFilePathForFileName(file tspath.RootedFilePath, opt

func ChangeToDeclarationExtension(path tspath.RootedFilePath, host OutputPathsHost) tspath.RootedFilePath {
if rewritten, ok := path.RewriteExtension(host.ContentMapperExtensionRewrites(), host.CaseSensitivity()); ok {
return changeToDeclarationExtension(rewritten)
path = rewritten
}
if extension := path.LongestExtension(host.ContentMapperExtensions(), tspath.CaseSensitive); extension != "" {
return path.RemoveExtension(extension).AppendSuffix(".d" + extension + ".ts")
}
return changeToDeclarationExtension(path)
}

func changeToDeclarationExtension(path tspath.RootedFilePath) tspath.RootedFilePath {
pathWithoutExtension := path.RemoveFileExtension()
if pathWithoutExtension.AsString() == path.AsString() {
if extension := path.AnyExtension(nil, tspath.CaseSensitive); extension != "" {
Expand Down
Loading
Loading