Skip to content

Commit 76ba28f

Browse files
authored
Split VFS implementations apart for easier platform-specific additions (#173)
1 parent 90ee42f commit 76ba28f

File tree

7 files changed

+413
-331
lines changed

7 files changed

+413
-331
lines changed

internal/vfs/iofs.go

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package vfs
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"strings"
7+
8+
"github.com/microsoft/typescript-go/internal/tspath"
9+
)
10+
11+
type RealpathFS interface {
12+
fs.FS
13+
Realpath(path string) (string, error)
14+
}
15+
16+
// FromIOFS creates a new FS from an [fs.FS].
17+
//
18+
// For paths like `c:/foo/bar`, fsys will be used as though it's rooted at `/` and the path is `/c:/foo/bar`.
19+
//
20+
// If the provided [fs.FS] implements [RealpathFS], it will be used to implement the Realpath method.
21+
//
22+
// Deprecated: FromIOFS does not actually handle case-insensitivity; ensure the passed in [fs.FS]
23+
// respects case-insensitive file names if needed. Consider using [vfstest.FromMapFS] for testing.
24+
func FromIOFS(fsys fs.FS, useCaseSensitiveFileNames bool) FS {
25+
var realpath func(path string) (string, error)
26+
if fsys, ok := fsys.(RealpathFS); ok {
27+
realpath = func(path string) (string, error) {
28+
rest, hadSlash := strings.CutPrefix(path, "/")
29+
rp, err := fsys.Realpath(rest)
30+
if err != nil {
31+
return "", err
32+
}
33+
if hadSlash {
34+
return "/" + rp, nil
35+
}
36+
return rp, nil
37+
}
38+
} else {
39+
realpath = func(path string) (string, error) {
40+
return path, nil
41+
}
42+
}
43+
44+
return &ioFS{
45+
common: common{
46+
rootFor: func(root string) fs.FS {
47+
if root == "/" {
48+
return fsys
49+
}
50+
51+
p := tspath.RemoveTrailingDirectorySeparator(root)
52+
sub, err := fs.Sub(fsys, p)
53+
if err != nil {
54+
panic(fmt.Sprintf("vfs: failed to create sub file system for %q: %v", p, err))
55+
}
56+
return sub
57+
},
58+
},
59+
useCaseSensitiveFileNames: useCaseSensitiveFileNames,
60+
realpath: realpath,
61+
}
62+
}
63+
64+
type ioFS struct {
65+
common
66+
67+
useCaseSensitiveFileNames bool
68+
realpath func(path string) (string, error)
69+
}
70+
71+
var _ FS = (*ioFS)(nil)
72+
73+
func (vfs *ioFS) UseCaseSensitiveFileNames() bool {
74+
return vfs.useCaseSensitiveFileNames
75+
}
76+
77+
func (vfs *ioFS) Realpath(path string) string {
78+
root, rest := splitPath(path)
79+
// splitPath normalizes the path into parts (e.g. "c:/foo/bar" -> "c:/", "foo/bar")
80+
// Put them back together to call realpath.
81+
realpath, err := vfs.realpath(root + rest)
82+
if err != nil {
83+
return path
84+
}
85+
return realpath
86+
}

internal/vfs/iofs_test.go

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package vfs_test
2+
3+
import (
4+
"slices"
5+
"testing"
6+
"testing/fstest"
7+
8+
"github.com/microsoft/typescript-go/internal/testutil"
9+
"github.com/microsoft/typescript-go/internal/vfs"
10+
"gotest.tools/v3/assert"
11+
)
12+
13+
func TestIOFS(t *testing.T) {
14+
t.Parallel()
15+
16+
testfs := fstest.MapFS{
17+
"foo.ts": &fstest.MapFile{
18+
Data: []byte("hello, world"),
19+
},
20+
"dir1/file1.ts": &fstest.MapFile{
21+
Data: []byte("export const foo = 42;"),
22+
},
23+
"dir1/file2.ts": &fstest.MapFile{
24+
Data: []byte("export const foo = 42;"),
25+
},
26+
"dir2/file1.ts": &fstest.MapFile{
27+
Data: []byte("export const foo = 42;"),
28+
},
29+
}
30+
31+
fs := vfs.FromIOFS(testfs, true)
32+
33+
t.Run("ReadFile", func(t *testing.T) {
34+
t.Parallel()
35+
36+
content, ok := fs.ReadFile("/foo.ts")
37+
assert.Assert(t, ok)
38+
assert.Equal(t, content, "hello, world")
39+
40+
content, ok = fs.ReadFile("/does/not/exist.ts")
41+
assert.Assert(t, !ok)
42+
assert.Equal(t, content, "")
43+
})
44+
45+
t.Run("ReadFileUnrooted", func(t *testing.T) {
46+
t.Parallel()
47+
48+
testutil.AssertPanics(t, func() { fs.ReadFile("bar") }, `vfs: path "bar" is not absolute`)
49+
})
50+
51+
t.Run("FileExists", func(t *testing.T) {
52+
t.Parallel()
53+
54+
assert.Assert(t, fs.FileExists("/foo.ts"))
55+
assert.Assert(t, !fs.FileExists("/bar"))
56+
})
57+
58+
t.Run("DirectoryExists", func(t *testing.T) {
59+
t.Parallel()
60+
61+
assert.Assert(t, fs.DirectoryExists("/"))
62+
assert.Assert(t, fs.DirectoryExists("/dir1"))
63+
assert.Assert(t, fs.DirectoryExists("/dir1/"))
64+
assert.Assert(t, fs.DirectoryExists("/dir1/./"))
65+
assert.Assert(t, !fs.DirectoryExists("/bar"))
66+
})
67+
68+
t.Run("GetDirectories", func(t *testing.T) {
69+
t.Parallel()
70+
71+
dirs := fs.GetDirectories("/")
72+
slices.Sort(dirs)
73+
74+
assert.DeepEqual(t, dirs, []string{"dir1", "dir2"})
75+
})
76+
77+
t.Run("WalkDir", func(t *testing.T) {
78+
t.Parallel()
79+
80+
var files []string
81+
err := fs.WalkDir("/", func(path string, d vfs.DirEntry, err error) error {
82+
if err != nil {
83+
return err
84+
}
85+
if !d.IsDir() {
86+
files = append(files, path)
87+
}
88+
return nil
89+
})
90+
assert.NilError(t, err)
91+
92+
slices.Sort(files)
93+
94+
assert.DeepEqual(t, files, []string{"/dir1/file1.ts", "/dir1/file2.ts", "/dir2/file1.ts", "/foo.ts"})
95+
})
96+
97+
t.Run("WalkDirSkip", func(t *testing.T) {
98+
t.Parallel()
99+
100+
var files []string
101+
err := fs.WalkDir("/", func(path string, d vfs.DirEntry, err error) error {
102+
if err != nil {
103+
return err
104+
}
105+
if !d.IsDir() {
106+
files = append(files, path)
107+
}
108+
109+
if path == "/" {
110+
return nil
111+
}
112+
113+
return vfs.SkipDir
114+
})
115+
assert.NilError(t, err)
116+
117+
slices.Sort(files)
118+
119+
assert.DeepEqual(t, files, []string{"/foo.ts"})
120+
})
121+
122+
t.Run("Realpath", func(t *testing.T) {
123+
t.Parallel()
124+
125+
realpath := fs.Realpath("/foo.ts")
126+
assert.Equal(t, realpath, "/foo.ts")
127+
})
128+
129+
t.Run("UseCaseSensitiveFileNames", func(t *testing.T) {
130+
t.Parallel()
131+
132+
assert.Assert(t, fs.UseCaseSensitiveFileNames())
133+
})
134+
}

internal/vfs/os.go

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package vfs
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"strings"
8+
"sync"
9+
"unicode"
10+
11+
"github.com/microsoft/typescript-go/internal/tspath"
12+
)
13+
14+
// FromOS creates a new FS from the OS file system.
15+
func FromOS() FS {
16+
return osVFS
17+
}
18+
19+
var osVFS FS = &osFS{
20+
common: common{
21+
rootFor: os.DirFS,
22+
},
23+
}
24+
25+
type osFS struct {
26+
common
27+
}
28+
29+
var isFileSystemCaseSensitive = sync.OnceValue(func() bool {
30+
// win32/win64 are case insensitive platforms
31+
if runtime.GOOS == "windows" {
32+
return false
33+
}
34+
35+
// If the current executable exists under a different case, we must be case-insensitve.
36+
if _, err := os.Stat(swapCase(os.Args[0])); os.IsNotExist(err) {
37+
return false
38+
}
39+
return true
40+
})
41+
42+
// Convert all lowercase chars to uppercase, and vice-versa
43+
func swapCase(str string) string {
44+
return strings.Map(func(r rune) rune {
45+
upper := unicode.ToUpper(r)
46+
if upper == r {
47+
return unicode.ToLower(r)
48+
} else {
49+
return upper
50+
}
51+
}, str)
52+
}
53+
54+
func (vfs *osFS) UseCaseSensitiveFileNames() bool {
55+
return isFileSystemCaseSensitive()
56+
}
57+
58+
var osReadSema = make(chan struct{}, 128)
59+
60+
func (vfs *osFS) ReadFile(path string) (contents string, ok bool) {
61+
osReadSema <- struct{}{}
62+
defer func() { <-osReadSema }()
63+
64+
return vfs.common.ReadFile(path)
65+
}
66+
67+
func (vfs *osFS) Realpath(path string) string {
68+
_ = rootLength(path) // Assert path is rooted
69+
70+
orig := path
71+
path = filepath.FromSlash(path)
72+
path, err := filepath.EvalSymlinks(path)
73+
if err != nil {
74+
return orig
75+
}
76+
path, err = filepath.Abs(path)
77+
if err != nil {
78+
return orig
79+
}
80+
return tspath.NormalizeSlashes(path)
81+
}

internal/vfs/os_test.go

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package vfs_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
9+
"github.com/microsoft/typescript-go/internal/repo"
10+
"github.com/microsoft/typescript-go/internal/tspath"
11+
"github.com/microsoft/typescript-go/internal/vfs"
12+
"gotest.tools/v3/assert"
13+
)
14+
15+
func TestOS(t *testing.T) {
16+
t.Parallel()
17+
18+
fs := vfs.FromOS()
19+
20+
goMod := filepath.Join(repo.RootPath, "go.mod")
21+
goModPath := tspath.NormalizePath(goMod)
22+
23+
t.Run("ReadFile", func(t *testing.T) {
24+
t.Parallel()
25+
26+
expectedRaw, err := os.ReadFile(goMod)
27+
assert.NilError(t, err)
28+
expected := string(expectedRaw)
29+
30+
contents, ok := fs.ReadFile(goModPath)
31+
assert.Assert(t, ok)
32+
assert.Equal(t, contents, expected)
33+
})
34+
35+
t.Run("Realpath", func(t *testing.T) {
36+
t.Parallel()
37+
38+
realpath := fs.Realpath(goModPath)
39+
assert.Equal(t, realpath, goModPath)
40+
})
41+
42+
t.Run("UseCaseSensitiveFileNames", func(t *testing.T) {
43+
t.Parallel()
44+
45+
// Just check that it works.
46+
fs.UseCaseSensitiveFileNames()
47+
48+
if runtime.GOOS == "windows" {
49+
assert.Assert(t, !fs.UseCaseSensitiveFileNames())
50+
}
51+
})
52+
}

0 commit comments

Comments
 (0)