diff --git a/tsc/internal/module/resolver.go b/tsc/internal/module/resolver.go index 4b59f0582f927..653f922982ff6 100644 --- a/tsc/internal/module/resolver.go +++ b/tsc/internal/module/resolver.go @@ -538,6 +538,14 @@ func (r *DefaultResolver) ResolvePackageDirectory(moduleName string, containingF compilerOptions := GetCompilerOptionsWithRedirect(r.compilerOptions, redirectedReference) containingDirectory := containingFile.Directory() state := newResolutionState(moduleName, containingDirectory, false /*isTypeReferenceDirective*/, resolutionMode, compilerOptions, redirectedReference, r, nil) + if tspath.IsExternalModuleNameRelative(moduleName) { + packageDirectory := containingDirectory.ResolveDirectory(moduleName) + if r.fs.DirectoryExists(packageDirectory) { + originalPath, resolvedPath := state.getOriginalAndResolvedFileName(tspath.RootedFilePathFromPath(packageDirectory.AsPath())) + return state.createResolvedModuleHandlingSymlink(&resolved{path: resolvedPath, originalPath: originalPath}) + } + return nil + } state.resolvePackageDirectoryOnly = true if result := state.loadModuleFromNearestNodeModulesDirectory(false /*typesScopeOnly*/); result != nil && result.path != "" { return state.createResolvedModuleHandlingSymlink(result) diff --git a/tsc/internal/project/configfileregistrybuilder.go b/tsc/internal/project/configfileregistrybuilder.go index 3a76381512f4c..9233ad4d48786 100644 --- a/tsc/internal/project/configfileregistrybuilder.go +++ b/tsc/internal/project/configfileregistrybuilder.go @@ -799,12 +799,18 @@ func (c *configFileRegistryBuilder) IsContentMapperPackageAllowed(packageDirecto if c.sessionOptions.InitialWorkspaceRoots == nil { return true } + caseSensitivity := c.fs.CaseSensitivity() + if slices.ContainsFunc(c.sessionOptions.InitialWorkspaceRoots, func(root tspath.RootedDirectoryPath) bool { + return caseSensitivity.ContainsPath(root, packageDirectory.AsPath()) + }) { + return true + } _, nodeModulesPath, ok := tspath.RootedFilePathFromPath(packageDirectory.AsPath()).SplitAtLastComponent("node_modules") if !ok { return false } return slices.ContainsFunc(c.sessionOptions.InitialWorkspaceRoots, func(root tspath.RootedDirectoryPath) bool { - return c.fs.CaseSensitivity().ContainsPath(root, nodeModulesPath.AsPath()) + return caseSensitivity.ContainsPath(root, nodeModulesPath.AsPath()) }) } diff --git a/tsc/internal/project/contentmapper_test.go b/tsc/internal/project/contentmapper_test.go index 8c04ede60b246..3386573079048 100644 --- a/tsc/internal/project/contentmapper_test.go +++ b/tsc/internal/project/contentmapper_test.go @@ -303,6 +303,7 @@ func TestContentMapperPackageMustBeInWorkspace(t *testing.T) { name string projectDirectory string workspaceRoots []string + mapperPackage string mapperPath string mapperTarget string allowed bool @@ -314,6 +315,30 @@ func TestContentMapperPackageMustBeInWorkspace(t *testing.T) { mapperPath: "/home/trusted/node_modules/mapper/package.json", allowed: true, }, + { + name: "directory mapper in workspace", + projectDirectory: "/home/trusted", + workspaceRoots: []string{"/home/trusted"}, + mapperPackage: "./mapper", + mapperPath: "/home/trusted/mapper/package.json", + allowed: true, + }, + { + name: "workspace directory mapper symlinked outside", + projectDirectory: "/home/trusted", + workspaceRoots: []string{"/home/trusted"}, + mapperPackage: "./mapper", + mapperPath: "/home/trusted/mapper", + mapperTarget: "/home/outside/mapper", + allowed: true, + }, + { + name: "directory mapper outside workspace", + projectDirectory: "/home/untrusted", + workspaceRoots: []string{"/home/trusted"}, + mapperPackage: "./mapper", + mapperPath: "/home/untrusted/mapper/package.json", + }, { name: "loose file outside workspace", projectDirectory: "/home/untrusted", @@ -357,10 +382,14 @@ func TestContentMapperPackageMustBeInWorkspace(t *testing.T) { configPath := test.projectDirectory + "/tsconfig.json" mainPath := test.projectDirectory + "/main.ts" boxPath := test.projectDirectory + "/app.box" + mapperPackage := test.mapperPackage + if mapperPackage == "" { + mapperPackage = "mapper" + } files := map[string]any{ configPath: `{ "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" }, - "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + "contentMappers": [ { "package": "` + mapperPackage + `", "extensions": [".box"] } ] }`, mainPath: "import { version } from \"./app.box\";\nexport { version };\n", boxPath: "export const version = #{target};\n", @@ -491,6 +520,65 @@ func TestContentMapperPackageManifestChangeReloadsConfig(t *testing.T) { assert.Assert(t, strings.Contains(boxFile.Text(), "export const version = 7;"), "expected fixed mapper manifest to be reloaded: %q", boxFile.Text()) } +func TestRelativeSymlinkedContentMapperPackageManifestChangeReloadsConfig(t *testing.T) { + t.Parallel() + const packageJsonPath = "/home/mapper/package.json" + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" }, + "contentMappers": [{ "package": "./mapper", "extensions": [".box"] }] + }`, + "/home/project/mapper": vfstest.Symlink("/home/mapper"), + packageJsonPath: `{ + "name": "mapper", + "version": "1.0.0", + "typescript": { "contentMapper": { "exec": ["compiler-test-mapper"] } } + }`, + "/home/project/app.box": "export const version = #{target};\n", + "/home/project/main.ts": "import { version } from \"./app.box\";\n", + } + caps := &lsproto.ResolvedClientCapabilities{} + caps.Workspace.DidChangeWatchedFiles.RelativePatternSupport = true + ctx := lsproto.WithClientCapabilities(context.Background(), caps) + init, utils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: projecttestutil.TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + RunExternalCode: true, + WatchEnabled: true, + }, nil) + init.BackgroundCtx = ctx + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + defer session.Close() + + mainURI := lsproto.DocumentUri("file:///home/project/main.ts") + session.DidOpenFile(ctx, mainURI, 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(ctx, mainURI) + assert.NilError(t, err) + configuredProject := session.Snapshot().GetDefaultProject(mainURI) + assert.Assert(t, configuredProject != nil) + mappers := configuredProject.CommandLine.ContentMappers() + assert.Equal(t, len(mappers), 1) + assert.Equal(t, mappers[0].PackageDirectory, tspath.RootedDirectoryPath("/home/mapper")) + session.WaitForBackgroundTasks() + assert.Assert(t, utils.WatchesFile(packageJsonPath), "expected the mapper package manifest to be watched") + + fixedManifest := contentmappertest.PackageJSON(contentmappertest.TransformingMapper) + assert.NilError(t, utils.FS().WriteFile(packageJsonPath, fixedManifest)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + packageJsonPath), + Type: lsproto.FileChangeTypeChanged, + }}) + + languageService, err := session.GetLanguageService(ctx, mainURI) + assert.NilError(t, err) + boxFile := languageService.GetProgram().GetSourceFile("/home/project/app.box") + assert.Assert(t, boxFile != nil, "expected app.box in the rebuilt program") + assert.Assert(t, strings.Contains(boxFile.Text(), "export const version = 7;"), "expected fixed mapper manifest to be reloaded: %q", boxFile.Text()) +} + func TestContentMapperSupplementalFileClonedOnEdit(t *testing.T) { t.Parallel() files := map[string]any{ diff --git a/tsc/internal/tsoptions/contentmappers.go b/tsc/internal/tsoptions/contentmappers.go index e3d2a4bc04c10..6274da08a9539 100644 --- a/tsc/internal/tsoptions/contentmappers.go +++ b/tsc/internal/tsoptions/contentmappers.go @@ -15,8 +15,8 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/vfs" ) -// resolveContentMapperManifest locates packageName in node_modules (walking up from the directory of -// containingFile via node module resolution) and reads its package.json to produce the mapper's manifest +// resolveContentMapperManifest locates packageName as a directory path relative to containingFile or in +// node_modules (walking up via node module resolution) and reads its package.json to produce the mapper's manifest // and package directory. It never executes the package. On failure it returns a diagnostic describing why // the mapper could not be resolved; on success the diagnostic is nil. type contentMapperResolutionHost struct { diff --git a/tsc/internal/tsoptions/contentmappers_test.go b/tsc/internal/tsoptions/contentmappers_test.go index 68137b0ac1203..c07be24e91472 100644 --- a/tsc/internal/tsoptions/contentmappers_test.go +++ b/tsc/internal/tsoptions/contentmappers_test.go @@ -270,3 +270,77 @@ func TestResolveContentMapperManifest(t *testing.T) { _, _, diagnostic = resolveContentMapperManifest(NewDefaultParseConfigHost(fs), "/home/project/tsconfig.json", "bad-output-extension") assert.Equal(t, diagnostic.Code(), diagnostics.The_typescript_contentMapper_outputExtensions_of_the_content_mapper_package_0_contains_an_invalid_mapping_from_1_to_2_Extensions_must_be_non_empty_and_begin_with_a.Code()) } + +func TestResolveContentMapperManifestPaths(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + packageName string + packageDirectory tspath.RootedDirectoryPath + }{ + {".", "/home/project"}, + {"..", "/home"}, + {"./mapper", "/home/project/mapper"}, + {"../mapper", "/home/mapper"}, + {`.\mapper`, "/home/project/mapper"}, + {`..\mapper`, "/home/mapper"}, + {"/home/mapper", "/home/mapper"}, + {`C:\mapper`, "C:/mapper"}, + } { + t.Run(test.packageName, func(t *testing.T) { + t.Parallel() + files := map[string]string{ + "/home/node_modules/unrelated/package.json": `{ + "name": "unrelated", + "typescript": { "contentMapper": { "exec": ["run"] } } + }`, + "/home/package.json": `{"name": "unrelated"}`, + } + files[test.packageDirectory.ResolveFile("package.json").AsString()] = `{ + "name": "mapper", + "typescript": { "contentMapper": { "exec": ["run"] } } + }` + containingFile := tspath.RootedFilePath("/home/project/tsconfig.json") + if test.packageName == `C:\mapper` { + files = map[string]string{ + "C:/mapper/package.json": files["C:/mapper/package.json"], + } + containingFile = "C:/project/tsconfig.json" + } + fs := vfstest.FromMap(files, tspath.CaseSensitive) + for _, allowed := range []bool{false, true} { + host := &contentMapperTestParseConfigHost{ParseConfigHost: NewDefaultParseConfigHost(fs), allowed: allowed} + manifest, packageDirectory, diagnostic := resolveContentMapperManifest(host, containingFile, test.packageName) + assert.Equal(t, host.packageDirectory, test.packageDirectory) + if allowed { + assert.Assert(t, diagnostic == nil) + assert.Equal(t, packageDirectory, test.packageDirectory) + assert.Equal(t, manifest.Name, "mapper") + assert.DeepEqual(t, manifest.Exec, []string{"run"}) + } else { + assert.Assert(t, diagnostic != nil) + assert.Equal(t, diagnostic.Code(), diagnostics.The_content_mapper_package_0_is_outside_the_open_workspace_and_will_not_be_loaded.Code()) + } + } + }) + } +} + +func TestResolveContentMapperManifestMissingPaths(t *testing.T) { + t.Parallel() + + for _, packageName := range []string{"./does-not-exist", "../does-not-exist", "/does-not-exist", `C:\does-not-exist`, "../no-manifest", "../file"} { + t.Run(packageName, func(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]string{ + "/home/package.json": `{"name": "unrelated"}`, + "/home/node_modules/unrelated/package.json": `{"name": "unrelated"}`, + "/home/no-manifest/index.js": "", + "/home/file": "", + }, tspath.CaseSensitive) + _, _, diagnostic := resolveContentMapperManifest(NewDefaultParseConfigHost(fs), "/home/project/tsconfig.json", packageName) + assert.Assert(t, diagnostic != nil) + assert.Equal(t, diagnostic.Code(), diagnostics.The_content_mapper_package_0_could_not_be_resolved.Code()) + }) + } +} diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go index cb4c7d01d723d..ae4dda1931cfd 100644 --- a/tsc/internal/tsoptions/tsconfigparsing.go +++ b/tsc/internal/tsoptions/tsconfigparsing.go @@ -25,13 +25,14 @@ import ( ) type extendsResult struct { - options *parsedCompilerOptions - include []any - exclude []any - files []any - contentMappers []any - compileOnSave bool - extendedSourceFiles collections.Set[tspath.RootedFilePath] + options *parsedCompilerOptions + include []any + exclude []any + files []any + contentMappers []any + contentMappersConfigFile tspath.RootedFilePath + compileOnSave bool + extendedSourceFiles collections.Set[tspath.RootedFilePath] } type configFileSpecs struct { @@ -85,10 +86,11 @@ func (e *ExtendedConfigCacheEntry) ExtendedFileNames() []tspath.RootedFilePath { } type parsedTsconfig struct { - raw any - options *parsedCompilerOptions - typeAcquisition *core.TypeAcquisition - extendedConfigPaths []tspath.RootedFilePath + raw any + options *parsedCompilerOptions + typeAcquisition *core.TypeAcquisition + extendedConfigPaths []tspath.RootedFilePath + contentMappersConfigFile tspath.RootedFilePath } type parsedCompilerOptions struct { @@ -199,10 +201,11 @@ func parseOwnConfigOfJsonSourceFile( )) } return &parsedTsconfig{ - raw: json, - options: compilerOptions, - typeAcquisition: typeAcquisition, - extendedConfigPaths: extendedConfigPaths, + raw: json, + options: compilerOptions, + typeAcquisition: typeAcquisition, + extendedConfigPaths: extendedConfigPaths, + contentMappersConfigFile: configFileName, }, errors } @@ -901,10 +904,11 @@ func parseOwnConfigOfJson( errors = append(errors, err...) } parsedConfig := &parsedTsconfig{ - raw: json, - options: options, - typeAcquisition: typeAcquisition, - extendedConfigPaths: extendedConfigPaths, + raw: json, + options: options, + typeAcquisition: typeAcquisition, + extendedConfigPaths: extendedConfigPaths, + contentMappersConfigFile: configFileName, } return parsedConfig, errors } @@ -1087,6 +1091,7 @@ func parseConfig( setPropertyValue("files") if extendedRawMap, ok := extendsRaw.(*collections.OrderedMap[string, any]); ok && extendedRawMap.Has("contentMappers") { result.contentMappers, _ = extendedRawMap.GetOrZero("contentMappers").([]any) + result.contentMappersConfigFile = extendedConfig.contentMappersConfigFile } if extendedRawMap, ok := extendsRaw.(*collections.OrderedMap[string, any]); ok && extendedRawMap.Has("compileOnSave") { if compileOnSave, ok := extendedRawMap.GetOrZero("compileOnSave").(bool); ok { @@ -1120,6 +1125,7 @@ func parseConfig( } if result.contentMappers != nil && !ownConfig.raw.(*collections.OrderedMap[string, any]).Has("contentMappers") { ownConfig.raw.(*collections.OrderedMap[string, any]).Set("contentMappers", result.contentMappers) + ownConfig.contentMappersConfigFile = result.contentMappersConfigFile } if result.compileOnSave && !ownConfig.raw.(*collections.OrderedMap[string, any]).Has("compileOnSave") { ownConfig.raw.(*collections.OrderedMap[string, any]).Set("compileOnSave", result.compileOnSave) @@ -1398,9 +1404,12 @@ func parseJsonConfigFileContentWorker( } 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 + containingFile := parsedConfig.contentMappersConfigFile if containingFile == "" { - containingFile = basePathForFileNames.ResolveFile("tsconfig.json") + containingFile = configFileName + if containingFile == "" { + containingFile = basePathForFileNames.ResolveFile("tsconfig.json") + } } resolvedContentMappers := make([]*contentmapper.Mapper, 0, len(contentMappers)) for j, mapper := range contentMappers { diff --git a/tsc/internal/tsoptions/tsconfigparsing_test.go b/tsc/internal/tsoptions/tsconfigparsing_test.go index 7d9fa696bcb20..806c67f3223d3 100644 --- a/tsc/internal/tsoptions/tsconfigparsing_test.go +++ b/tsc/internal/tsoptions/tsconfigparsing_test.go @@ -1390,6 +1390,58 @@ func TestContentMappersAreInheritedFromExtendedConfig(t *testing.T) { } } +func TestInheritedContentMapperPackagePathIsResolvedFromDeclaringConfig(t *testing.T) { + t.Parallel() + config := testConfig{ + jsonText: `{ "extends": "../config/middle.json" }`, + configFileName: "tsconfig.json", + basePath: "/repo/app", + allFileList: map[string]string{ + "/repo/config/middle.json": `{ "extends": "./base.json" }`, + "/repo/config/base.json": `{ "contentMappers": [ + { "package": "./mapper", "extensions": [".vue"] }, + { "package": "named-mapper", "extensions": [".view"] } + ] }`, + "/repo/config/mapper/package.json": `{ + "name": "config-mapper", + "typescript": { "contentMapper": { "exec": ["run"] } } + }`, + "/repo/config/node_modules/named-mapper/package.json": `{ + "name": "config-named-mapper", + "typescript": { "contentMapper": { "exec": ["run"] } } + }`, + "/repo/app/mapper/package.json": `{ + "name": "app-mapper", + "typescript": { "contentMapper": { "exec": ["run"] } } + }`, + "/repo/app/node_modules/named-mapper/package.json": `{ + "name": "app-named-mapper", + "typescript": { "contentMapper": { "exec": ["run"] } } + }`, + "/repo/app/index.ts": "", + }, + existingOptions: &core.CompilerOptions{RunExternalCode: core.TSTrue}, + } + + for name, getParsed := range map[string]func(testConfig, vfs.FS, tspath.RootedDirectoryPath) *tsoptions.ParsedCommandLine{ + "json api": getParsedWithJsonApi, + "jsonSourceFile api": getParsedWithJsonSourceFileApi, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + fs := tsoptionstest.NewVFS(config.allFileList, tspath.CaseSensitive /*caseSensitivity*/) + parsed := getParsed(config, fs, config.basePath) + assert.Equal(t, len(parsed.Errors), 0) + assert.Equal(t, len(parsed.ContentMappers()), 2) + assert.Equal(t, parsed.ContentMappers()[0].PackageDirectory, tspath.RootedDirectoryPath("/repo/config/mapper")) + assert.Equal(t, parsed.ContentMappers()[0].Manifest.Name, "config-mapper") + assert.Equal(t, parsed.ContentMappers()[1].Package, "named-mapper") + assert.Equal(t, parsed.ContentMappers()[1].PackageDirectory, tspath.RootedDirectoryPath("/repo/config/node_modules/named-mapper")) + assert.Equal(t, parsed.ContentMappers()[1].Manifest.Name, "config-named-mapper") + }) + } +} + func TestContentMappersRequireFlag(t *testing.T) { t.Parallel()