Skip to content

Add some symlink support #382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Feb 25, 2025
Merged
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
10 changes: 6 additions & 4 deletions internal/bundled/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,16 @@ func (vfs *wrappedFS) DirectoryExists(path string) bool {
return vfs.fs.DirectoryExists(path)
}

func (vfs *wrappedFS) GetDirectories(path string) []string {
func (vfs *wrappedFS) GetAccessibleEntries(path string) (result vfs.Entries) {
if rest, ok := splitPath(path); ok {
if rest == "" {
return []string{"libs"}
result.Directories = []string{"libs"}
} else if rest == "libs" {
result.Files = LibNames
}
return []string{}
return result
}
return vfs.fs.GetDirectories(path)
return vfs.fs.GetAccessibleEntries(path)
}

var rootEntries = []fs.DirEntry{
Expand Down
2 changes: 1 addition & 1 deletion internal/compiler/module/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1783,7 +1783,7 @@ func GetAutomaticTypeDirectiveNames(options *core.CompilerOptions, host Resoluti
typeRoots, _ := options.GetEffectiveTypeRoots(host.GetCurrentDirectory())
for _, root := range typeRoots {
if host.FS().DirectoryExists(root) {
for _, typeDirectivePath := range host.FS().GetDirectories(root) {
for _, typeDirectivePath := range host.FS().GetAccessibleEntries(root).Directories {
normalized := tspath.NormalizePath(typeDirectivePath)
packageJsonPath := tspath.CombinePaths(root, normalized, "package.json")
isNotNeededPackage := false
Expand Down
40 changes: 5 additions & 35 deletions internal/tsoptions/utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,6 @@ import (
"github.com/microsoft/typescript-go/internal/vfs"
)

type FileSystemEntries struct {
files []string
directories []string
}

func getAccessibleFileSystemEntries(path string, host vfs.FS) FileSystemEntries {
entries := host.GetEntries(path)
var files []string
var directories []string
for _, dirent := range entries {
entry := dirent.Name()

// This is necessary because on some file system node fails to exclude
// "." and "..". See https://github.com/nodejs/node/issues/4002
if entry == "." || entry == ".." {
continue
}

// !!! symlinks
if dirent.IsDir() {
directories = append(directories, entry)
} else {
files = append(files, entry)
}
}
return FileSystemEntries{files: files, directories: directories}
}

type FileMatcherPatterns struct {
// One pattern for each "include" spec.
includeFilePatterns []string
Expand Down Expand Up @@ -379,7 +351,6 @@ type visitor struct {
extensions []string
useCaseSensitiveFileNames bool
host vfs.FS
getFileSystemEntries func(path string, host vfs.FS) FileSystemEntries
visited core.Set[string]
results [][]string
}
Expand All @@ -394,9 +365,9 @@ func (v *visitor) visitDirectory(
return
}
v.visited.Add(canonicalPath)
systemEntries := v.getFileSystemEntries(absolutePath, v.host)
files := systemEntries.files
directories := systemEntries.directories
systemEntries := v.host.GetAccessibleEntries(absolutePath)
files := systemEntries.Files
directories := systemEntries.Directories

for _, current := range files {
name := tspath.CombinePaths(path, current)
Expand Down Expand Up @@ -435,7 +406,7 @@ func (v *visitor) visitDirectory(
}

// path is the directory of the tsconfig.json
func matchFiles(path string, extensions []string, excludes []string, includes []string, useCaseSensitiveFileNames bool, currentDirectory string, depth *int, host vfs.FS, getFileSystemEntries func(path string, host vfs.FS) FileSystemEntries, realpath func(path string) string) []string {
func matchFiles(path string, extensions []string, excludes []string, includes []string, useCaseSensitiveFileNames bool, currentDirectory string, depth *int, host vfs.FS) []string {
path = tspath.NormalizePath(path)
currentDirectory = tspath.NormalizePath(currentDirectory)

Expand Down Expand Up @@ -468,7 +439,6 @@ func matchFiles(path string, extensions []string, excludes []string, includes []
v := visitor{
useCaseSensitiveFileNames: useCaseSensitiveFileNames,
host: host,
getFileSystemEntries: getFileSystemEntries,
includeFileRegexes: includeFileRegexes,
excludeRegex: excludeRegex,
includeDirectoryRegex: includeDirectoryRegex,
Expand All @@ -483,5 +453,5 @@ func matchFiles(path string, extensions []string, excludes []string, includes []
}

func readDirectory(host vfs.FS, currentDir string, path string, extensions []string, excludes []string, includes []string, depth *int) []string {
return matchFiles(path, extensions, excludes, includes, host.UseCaseSensitiveFileNames(), currentDir, depth, host, getAccessibleFileSystemEntries, host.Realpath)
return matchFiles(path, extensions, excludes, includes, host.UseCaseSensitiveFileNames(), currentDir, depth, host)
}
54 changes: 45 additions & 9 deletions internal/vfs/internal/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (
"unicode/utf16"

"github.com/microsoft/typescript-go/internal/tspath"
"github.com/microsoft/typescript-go/internal/vfs"
)

type Common struct {
RootFor func(root string) fs.FS
RootFor func(root string) fs.FS
Realpath func(path string) string
}

func RootLength(p string) int {
Expand Down Expand Up @@ -60,16 +62,50 @@ func (vfs *Common) DirectoryExists(path string) bool {
return stat != nil && stat.IsDir()
}

func (vfs *Common) GetDirectories(path string) []string {
entries := vfs.GetEntries(path)
// TODO: should this really exist? ReadDir with manual filtering seems like a better idea.
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, entry.Name())
func (vfs *Common) GetAccessibleEntries(path string) (result vfs.Entries) {
addToResult := func(name string, mode fs.FileMode) (added bool) {
if mode.IsDir() {
result.Directories = append(result.Directories, name)
return true
}

if mode.IsRegular() {
result.Files = append(result.Files, name)
return true
}

return false
}

for _, entry := range vfs.GetEntries(path) {
entryType := entry.Type()

if addToResult(entry.Name(), entryType) {
continue
}

if entryType&fs.ModeSymlink != 0 {
// Easy case; UNIX-like system will clearly mark symlinks.
if stat := vfs.stat(path + "/" + entry.Name()); stat != nil {
addToResult(entry.Name(), stat.Mode())
}
continue
}

if entryType&fs.ModeIrregular != 0 && vfs.Realpath != nil {
// Could be a Windows junction. Try Realpath.
// TODO(jakebailey): use syscall.Win32FileAttributeData instead
fullPath := path + "/" + entry.Name()
if realpath := vfs.Realpath(fullPath); fullPath != realpath {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this is not the fastest way to do this at all; but the code to do this correctly on Windows is pretty funky (see the intermediate state in #186, which I will bring back). Something I want to address in a follow-up PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference: 8998046

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(realpath however, is very very reliable!)

if stat := vfs.stat(realpath); stat != nil {
addToResult(entry.Name(), stat.Mode())
}
}
continue
}
}
return dirs

return result
}

func (vfs *Common) GetEntries(path string) []fs.DirEntry {
Expand Down
4 changes: 2 additions & 2 deletions internal/vfs/iovfs/iofs.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ func (vfs *ioFS) FileExists(path string) bool {
return vfs.common.FileExists(path)
}

func (vfs *ioFS) GetDirectories(path string) []string {
return vfs.common.GetDirectories(path)
func (vfs *ioFS) GetAccessibleEntries(path string) vfs.Entries {
return vfs.common.GetAccessibleEntries(path)
}

func (vfs *ioFS) GetEntries(path string) []fs.DirEntry {
Expand Down
9 changes: 4 additions & 5 deletions internal/vfs/iovfs/iofs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,12 @@ func TestIOFS(t *testing.T) {
assert.Assert(t, !fs.DirectoryExists("/bar"))
})

t.Run("GetDirectories", func(t *testing.T) {
t.Run("GetAccessibleEntries", func(t *testing.T) {
t.Parallel()

dirs := fs.GetDirectories("/")
slices.Sort(dirs)

assert.DeepEqual(t, dirs, []string{"dir1", "dir2"})
entries := fs.GetAccessibleEntries("/")
assert.DeepEqual(t, entries.Directories, []string{"dir1", "dir2"})
assert.DeepEqual(t, entries.Files, []string{"foo.ts"})
})

t.Run("WalkDir", func(t *testing.T) {
Expand Down
11 changes: 8 additions & 3 deletions internal/vfs/osvfs/os.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ func FS() vfs.FS {

var osVFS vfs.FS = &osFS{
common: internal.Common{
RootFor: os.DirFS,
RootFor: os.DirFS,
Realpath: osFSRealpath,
},
}

Expand Down Expand Up @@ -88,8 +89,8 @@ func (vfs *osFS) FileExists(path string) bool {
return vfs.common.FileExists(path)
}

func (vfs *osFS) GetDirectories(path string) []string {
return vfs.common.GetDirectories(path)
func (vfs *osFS) GetAccessibleEntries(path string) vfs.Entries {
return vfs.common.GetAccessibleEntries(path)
}

func (vfs *osFS) GetEntries(path string) []fs.DirEntry {
Expand All @@ -101,6 +102,10 @@ func (vfs *osFS) WalkDir(root string, walkFn vfs.WalkDirFunc) error {
}

func (vfs *osFS) Realpath(path string) string {
return osFSRealpath(path)
}

func osFSRealpath(path string) string {
_ = internal.RootLength(path) // Assert path is rooted

orig := path
Expand Down
53 changes: 47 additions & 6 deletions internal/vfs/osvfs/realpath_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,7 @@ func TestSymlinkRealpath(t *testing.T) {
assert.NilError(t, os.MkdirAll(target, 0o777))
assert.NilError(t, os.WriteFile(targetFile, []byte(expectedContents), 0o666))

if runtime.GOOS == "windows" {
// Don't use os.Symlink on Windows, as it creates a "real" symlink, not a junction.
assert.NilError(t, exec.Command("cmd", "/c", "mklink", "/J", link, target).Run())
} else {
assert.NilError(t, os.Symlink(target, link))
}
mklink(t, target, link, true)

gotContents, err := os.ReadFile(linkFile)
assert.NilError(t, err)
Expand All @@ -51,3 +46,49 @@ func TestSymlinkRealpath(t *testing.T) {
t.Logf("node: %s", out)
}
}

func mklink(t *testing.T, target, link string, isDir bool) {
t.Helper()

if runtime.GOOS == "windows" && isDir {
// Don't use os.Symlink on Windows, as it creates a "real" symlink, not a junction.
assert.NilError(t, exec.Command("cmd", "/c", "mklink", "/J", link, target).Run())
} else {
assert.NilError(t, os.Symlink(target, link))
}
}

func TestGetAccessibleEntries(t *testing.T) {
t.Parallel()

tmp := t.TempDir()
target := filepath.Join(tmp, "target")
link := filepath.Join(tmp, "link")

assert.NilError(t, os.MkdirAll(target, 0o777))
assert.NilError(t, os.MkdirAll(link, 0o777))

targetFile1 := filepath.Join(target, "file1")
targetFile2 := filepath.Join(target, "file2")

assert.NilError(t, os.WriteFile(targetFile1, []byte("hello"), 0o666))
assert.NilError(t, os.WriteFile(targetFile2, []byte("world"), 0o666))

targetDir1 := filepath.Join(target, "dir1")
targetDir2 := filepath.Join(target, "dir2")

assert.NilError(t, os.MkdirAll(targetDir1, 0o777))
assert.NilError(t, os.MkdirAll(targetDir2, 0o777))

mklink(t, targetFile1, filepath.Join(link, "file1"), false)
mklink(t, targetFile2, filepath.Join(link, "file2"), false)
mklink(t, targetDir1, filepath.Join(link, "dir1"), true)
mklink(t, targetDir2, filepath.Join(link, "dir2"), true)

fs := FS()

entries := fs.GetAccessibleEntries(tspath.NormalizePath(link))

assert.DeepEqual(t, entries.Directories, []string{"dir1", "dir2"})
assert.DeepEqual(t, entries.Files, []string{"file1", "file2"})
}
10 changes: 8 additions & 2 deletions internal/vfs/vfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ type FS interface {
// DirectoryExists returns true if the path is a directory.
DirectoryExists(path string) bool

// GetDirectories returns the names of the directories in the specified directory.
GetDirectories(path string) []string
// GetAccessibleEntries returns the files/directories in the specified directory.
// If any entry is a symlink, it will be followed.
GetAccessibleEntries(path string) Entries

// GetEntries returns the entries in the specified directory.
GetEntries(path string) []fs.DirEntry
Expand All @@ -36,6 +37,11 @@ type FS interface {
Realpath(path string) string
}

type Entries struct {
Files []string
Directories []string
}

// DirEntry is [fs.DirEntry].
type DirEntry = fs.DirEntry

Expand Down
2 changes: 1 addition & 1 deletion internal/vfs/vfstest/vfstest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func TestStress(t *testing.T) {
func() { fs.DirectoryExists("/foo/bar") },
func() { fs.FileExists("/foo/bar") },
func() { fs.FileExists("/foo/bar/baz.txt") },
func() { fs.GetDirectories("/foo/bar") },
func() { fs.GetAccessibleEntries("/foo/bar") },
func() { fs.Realpath("/foo/bar/baz.txt") },
func() {
_ = fs.WalkDir("/", func(path string, d vfs.DirEntry, err error) error {
Expand Down