diff --git a/tsc/internal/compiler/projectreferencedtsfakinghost.go b/tsc/internal/compiler/projectreferencedtsfakinghost.go index 9eacfa3e64318..a074a6a603cc2 100644 --- a/tsc/internal/compiler/projectreferencedtsfakinghost.go +++ b/tsc/internal/compiler/projectreferencedtsfakinghost.go @@ -55,14 +55,11 @@ func (fs *projectReferenceDtsFakingVfs) CaseSensitivity() tspath.CaseSensitivity // FileExists implements vfs.FS. func (fs *projectReferenceDtsFakingVfs) FileExists(path tspath.RootedFilePath) bool { - if fs.host.FS().FileExists(path) { + // Prefer referenced sources so resolution does not probe their declaration outputs. + if path.IsDeclarationFile() && fs.fileExistsUsingSource(path) { return true } - if !path.IsDeclarationFile() { - return false - } - // Project references go to source file instead of .d.ts file - return fs.fileExistsUsingSource(path) + return fs.host.FS().FileExists(path) } // ReadFile implements vfs.FS. @@ -93,11 +90,14 @@ func (fs *projectReferenceDtsFakingVfs) Chtimes(path tspath.RootedPath, aTime ti // DirectoryExists implements vfs.FS. func (fs *projectReferenceDtsFakingVfs) DirectoryExists(path tspath.RootedDirectoryPath) bool { + if fs.directoryExistsUsingSource(path) { + return true + } if fs.host.FS().DirectoryExists(path) { fs.handleDirectoryCouldBeSymlink(path) return true } - return fs.directoryExistsUsingSource(path) + return false } // GetAccessibleEntries implements vfs.FS. @@ -201,7 +201,12 @@ func (fs *projectReferenceDtsFakingVfs) fileOrDirectoryExistsUsingSource( fs.handleDirectoryCouldBeSymlink(packageRoot) } knownDirectoryLinks := fs.knownSymlinks.Directories() - if knownDirectoryLinks.Size() == 0 { + hasDirectoryLinks := false + knownDirectoryLinks.Range(func(tspath.PathKey, *symlinks.KnownDirectoryLink) bool { + hasDirectoryLinks = true + return false + }) + if !hasDirectoryLinks { return false } if onFileExists != nil { @@ -211,25 +216,27 @@ func (fs *projectReferenceDtsFakingVfs) fileOrDirectoryExistsUsingSource( } } - // If it contains node_modules check if its one of the symlinked path we know of - var exists bool - knownDirectoryLinks.Range(func(directoryPath tspath.PathKey, knownDirectoryLink *symlinks.KnownDirectoryLink) bool { - if directoryPath == fileOrDirectoryPath || !directoryPath.ContainsPath(fileOrDirectoryPath) { - return true - } - realFileOrDirectory, ok := knownDirectoryLink.ResolveFilePath(tspath.RootedFilePathFromPath(fileOrDirectory), fs.CaseSensitivity()) - if !ok { - panic("canonical symlink path did not match its presentation path") - } - if exists = existsUsingSource(tspath.RootedPath(realFileOrDirectory)).IsTrue(); exists { - if onFileExists != nil { - onFileExists(realFileOrDirectory) + // Map through the nearest linked ancestor. Only the path's own ancestors + // are checked, so the cost does not grow with the number of known links. + for directory := fileOrDirectory.Directory(); ; { + if link, ok := knownDirectoryLinks.Load(fs.pathKey(directory.AsPath())); ok && link != nil { + realFileOrDirectory, ok := link.ResolveFilePath(tspath.RootedFilePathFromPath(fileOrDirectory), fs.CaseSensitivity()) + if !ok { + panic("canonical symlink path did not match its presentation path") } + if existsUsingSource(tspath.RootedPath(realFileOrDirectory)).IsTrue() { + if onFileExists != nil { + onFileExists(realFileOrDirectory) + } + return true + } + } + parent := directory.AsPath().Directory() + if parent == directory { return false } - return true - }) - return exists + directory = parent + } } func (fs *projectReferenceDtsFakingVfs) fileExistsIfProjectReferenceDts(file tspath.RootedFilePath) core.Tristate { diff --git a/tsc/internal/compiler/projectreferencedtsfakinghost_test.go b/tsc/internal/compiler/projectreferencedtsfakinghost_test.go new file mode 100644 index 0000000000000..f95ceb599b61a --- /dev/null +++ b/tsc/internal/compiler/projectreferencedtsfakinghost_test.go @@ -0,0 +1,128 @@ +package compiler_test + +import ( + "slices" + "strings" + "sync" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/compiler" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +type projectReferenceAccessFS struct { + vfs.FS + mu sync.Mutex + outputAccesses []string +} + +func (fs *projectReferenceAccessFS) record(operation, path string) { + if strings.HasSuffix(path, "/lib/index.d.ts") || strings.HasSuffix(path, "/lib") { + fs.mu.Lock() + defer fs.mu.Unlock() + fs.outputAccesses = append(fs.outputAccesses, operation+" "+path) + } +} + +func (fs *projectReferenceAccessFS) FileExists(path tspath.RootedFilePath) bool { + fs.record("FileExists", path.AsString()) + return fs.FS.FileExists(path) +} + +func (fs *projectReferenceAccessFS) DirectoryExists(path tspath.RootedDirectoryPath) bool { + fs.record("DirectoryExists", path.AsString()) + return fs.FS.DirectoryExists(path) +} + +func (fs *projectReferenceAccessFS) Realpath(path tspath.RootedPath) tspath.RootedPath { + fs.record("Realpath", path.AsString()) + return fs.FS.Realpath(path) +} + +func TestProjectReferenceSourceRedirectOutputAccesses(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + sourceExists bool + outputExists bool + directoryTypes bool + outputLink string + }{ + {"built", true, true, false, ""}, + {"unbuilt", true, false, false, ""}, + {"source-missing", false, true, false, ""}, + {"missing", false, false, false, ""}, + {"built-directory", true, true, true, ""}, + {"unbuilt-directory", true, false, true, ""}, + {"source-missing-directory", false, true, true, ""}, + {"missing-directory", false, false, true, ""}, + // Outputs that link elsewhere still map to the referenced source. + {"built-linked-file", true, true, false, "file"}, + {"built-linked-directory", true, true, true, "directory"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/workspace/app/tsconfig.json": `{"compilerOptions":{"noLib":true,"module":"commonjs","moduleResolution":"node"},"files":["index.ts"],"references":[{"path":"../dependency"}]}`, + "/workspace/app/index.ts": `import { value } from "dependency"; import "external"; export { value };`, + "/workspace/app/node_modules/dependency": vfstest.Symlink("/workspace/dependency"), + "/workspace/app/node_modules/external/index.d.ts": `export {};`, + "/workspace/dependency/package.json": `{"name":"dependency","types":"lib/index.d.ts"}`, + "/workspace/dependency/tsconfig.json": `{"compilerOptions":{"composite":true,"rootDir":"src","outDir":"lib"},"files":["src/index.ts"]}`, + } + if tc.directoryTypes { + files["/workspace/dependency/package.json"] = `{"name":"dependency","types":"lib"}` + } + if tc.sourceExists { + files["/workspace/dependency/src/index.ts"] = `export const value = 1;` + } + if tc.outputExists { + switch tc.outputLink { + case "file": + files["/cache/index.d.ts"] = `export declare const value: number;` + files["/workspace/dependency/lib/index.d.ts"] = vfstest.Symlink("/cache/index.d.ts") + case "directory": + files["/cache/lib/index.d.ts"] = `export declare const value: number;` + files["/workspace/dependency/lib"] = vfstest.Symlink("/cache/lib") + default: + files["/workspace/dependency/lib/index.d.ts"] = `export declare const value: number;` + } + } + fs := &projectReferenceAccessFS{FS: vfstest.FromMap(files, tspath.CaseSensitive)} + host := compiler.NewCompilerHost(fs, "", nil, nil, nil) + config, diagnostics := tsoptions.GetParsedCommandLineOfConfigFile("/workspace/app/tsconfig.json", nil, nil, fs, nil) + assert.Equal(t, len(diagnostics), 0) + program := compiler.NewProgram(compiler.ProgramOptions{ + Host: host, + Config: config, + UseSourceOfProjectReference: true, + SingleThreaded: core.TSTrue, + }) + actualFiles := []string{} + for _, file := range program.GetSourceFiles() { + actualFiles = append(actualFiles, file.FileName().AsString()) + } + slices.Sort(actualFiles) + expectedFiles := []string{"/workspace/app/index.ts", "/workspace/app/node_modules/external/index.d.ts"} + if tc.sourceExists { + expectedFiles = append(expectedFiles, "/workspace/dependency/src/index.ts") + } + assert.DeepEqual(t, actualFiles, expectedFiles) + main := program.GetSourceFile("/workspace/app/index.ts") + resolved := program.GetResolvedModuleFromModuleSpecifier(main, main.Imports()[0]) + assert.Equal(t, resolved.IsResolved(), tc.sourceExists || tc.outputExists) + t.Logf("declaration output accesses: %v", fs.outputAccesses) + if tc.sourceExists { + assert.Equal(t, len(fs.outputAccesses), 0) + } else { + // Missing sources must not suppress the real output existence check. + assert.Assert(t, slices.Contains(fs.outputAccesses, "FileExists /workspace/app/node_modules/dependency/lib/index.d.ts")) + } + }) + } +}