Skip to content
Closed
5 changes: 4 additions & 1 deletion packages/typescript/src/api/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export interface FileSystem {
export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile"] as const;

export interface CreateFileSystemOptions {
/** Complete directory listings. Full filesystems derive these from `files` when omitted. */
/**
* Complete `getAccessibleEntries` results. These do not constrain direct descendant lookups.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not clear on what this means.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Like

Subsequent lookups are not limited to the entries provided. Files may be "opened", or a file may exist on the host file system.

?

* Full filesystems derive listings from `files` when omitted.
*/
directories?: Record<string, RequestDirectoryEntries>;
symlinks?: Record<string, RequestSymlink>;
/** Files or directory trees hidden from an underlying snapshot or host filesystem. */
Expand Down
5 changes: 3 additions & 2 deletions packages/typescript/src/api/proto.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1437,8 +1437,9 @@ export interface EmitOutputFile {
}

/**
* RequestDirectoryEntries is a cached directory listing. Entry names are
* relative to the directory, matching vfs.GetAccessibleEntries.
* RequestDirectoryEntries is a complete cached result for GetAccessibleEntries.
* Entry names are relative to the directory. Listings do not constrain direct
* descendant lookups, which may still fall back in a layered filesystem.
*/
export interface RequestDirectoryEntries {
files: string[];
Expand Down
118 changes: 74 additions & 44 deletions tsc/internal/api/requestfilesystem/filechanges.go
Original file line number Diff line number Diff line change
@@ -1,72 +1,102 @@
package requestfilesystem

import (
"github.com/microsoft/TypeScript/tsc/internal/ls/lsconv"
"cmp"
"slices"

"github.com/microsoft/TypeScript/tsc/internal/project"
"github.com/microsoft/TypeScript/tsc/internal/tspath"
"github.com/microsoft/TypeScript/tsc/internal/vfs"
)

func addFileChanges(summary *project.FileChangeSummary, request *RequestFileSystem, baseFS vfs.FS, currentDirectory string) {
func getFileSourceLayerChanges(
request *RequestFileSystem,
base *requestFileSystem,
currentDirectory string,
useCaseSensitiveNames bool,
) project.FileSourceLayerChanges {
if base != nil {
useCaseSensitiveNames = base.useCaseSensitiveNames
}
toPath := func(fileName string) tspath.Path {
return tspath.ToPath(fileName, currentDirectory, baseFS.UseCaseSensitiveFileNames())
return tspath.ToPath(fileName, currentDirectory, useCaseSensitiveNames)
}
baseRequestFS := getRequestFileSystem(baseFS)
addChange := func(fileName string, deleted bool) {
uri := lsconv.FileNameToDocumentURI(fileName)
if deleted {
if baseFS.FileExists(fileName) || baseFS.DirectoryExists(fileName) {
summary.Deleted.Add(uri)
changes := make(map[tspath.Path]project.FileSourceLayerChange)
add := func(fileName string, structural bool, shadowsDescendants bool) {
absolutePath := tspath.GetNormalizedAbsolutePath(fileName, currentDirectory)
path := toPath(absolutePath)
existing := changes[path]
existing.Path = absolutePath
existing.Structural = existing.Structural || structural
existing.ShadowsDescendants = existing.ShadowsDescendants || shadowsDescendants
changes[path] = existing
}
addWithAliases := func(fileName string, structural bool, shadowsDescendants bool) {
add(fileName, structural, shadowsDescendants)
if base != nil {
for _, alias := range base.aliasesForPath(fileName) {
add(alias, structural, shadowsDescendants)
}
return
}
if baseFS.FileExists(fileName) {
summary.Changed.Add(uri)
} else {
summary.Created.Add(uri)
}
addRequestDescendants := func(fileName string) {
if base == nil {
return
}
node, _ := base.paths.lookup(base.toPath(fileName))
node.walkFiles(func(file *requestFile) {
addWithAliases(file.fileName, false, false)
Comment thread
andrewbranch marked this conversation as resolved.
})
}
addChangeAndAliases := func(fileName string, deleted bool) {
addChange(fileName, deleted)
if baseRequestFS != nil {
for _, alias := range baseRequestFS.aliasesForPath(fileName) {
addChange(alias, deleted)
}
shadowsRequestSymlinks := func(fileName string) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What does this name of this mean? Is requestShadowsSymlinks a better name? pathShadowsHostSymlink?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Okay, sorry, I didn't notice this was in requestfilesystem so I guess this is like shadowsReqFsSymlink?

if base == nil {
return false
}
node, _ := base.paths.lookup(base.toPath(fileName))
return node != nil && node.hasSymlinks
}
overlayFiles := make(map[tspath.Path]struct{}, len(request.Files))

files := make(map[tspath.Path]struct{}, len(request.Files))
for fileName := range request.Files {
absoluteFileName := tspath.GetNormalizedAbsolutePath(fileName, currentDirectory)
overlayFiles[toPath(absoluteFileName)] = struct{}{}
addChangeAndAliases(absoluteFileName, false)
absolutePath := tspath.GetNormalizedAbsolutePath(fileName, currentDirectory)
if shadowsRequestSymlinks(absolutePath) {
return project.FileSourceLayerChanges{InvalidateAll: true}
}
files[toPath(absolutePath)] = struct{}{}
addRequestDescendants(absolutePath)
addWithAliases(absolutePath, false, false)
}
for _, removedPath := range request.RemovedPaths {
absoluteFileName := tspath.GetNormalizedAbsolutePath(removedPath, currentDirectory)
if _, replaced := overlayFiles[toPath(absoluteFileName)]; replaced {
absolutePath := tspath.GetNormalizedAbsolutePath(removedPath, currentDirectory)
if _, replaced := files[toPath(absolutePath)]; replaced {
continue
}
addChangeAndAliases(absoluteFileName, true)
}
// Replacing a listing or a symlink can change every cached descendant.
// Delete events expand through the snapshot's cached directory tree and create
// events that refresh wildcard roots and previously missing module resolutions.
addReplacement := func(path string) {
absolutePath := tspath.GetNormalizedAbsolutePath(path, currentDirectory)
addChangeAndAliases(absolutePath, true)
summary.Created.Add(lsconv.FileNameToDocumentURI(absolutePath))
if baseRequestFS != nil {
for _, alias := range baseRequestFS.aliasesForPath(absolutePath) {
summary.Created.Add(lsconv.FileNameToDocumentURI(alias))
}
if shadowsRequestSymlinks(absolutePath) {
return project.FileSourceLayerChanges{InvalidateAll: true}
}
addRequestDescendants(absolutePath)
addWithAliases(absolutePath, true, true)
Comment on lines +73 to +77

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There's really nothing else to be done for a "removed" file? Maybe I'm out of my depth, but how is removing a host file done with these recursive calls?

}
for directoryName := range request.Directories {
addReplacement(directoryName)
if shadowsRequestSymlinks(directoryName) {
return project.FileSourceLayerChanges{InvalidateAll: true}
}
addRequestDescendants(directoryName)
addWithAliases(directoryName, true, false)
}
for linkName := range request.Symlinks {
addReplacement(linkName)
if shadowsRequestSymlinks(linkName) {
return project.FileSourceLayerChanges{InvalidateAll: true}
}
addRequestDescendants(linkName)
addWithAliases(linkName, true, true)
}
if summary.Changed.Len()+summary.Created.Len()+summary.Deleted.Len() > 0 {
summary.IncludesWatchChangeOutsideNodeModules = true

result := make([]project.FileSourceLayerChange, 0, len(changes))
for _, change := range changes {
result = append(result, change)
}
Comment on lines +94 to 97

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
result := make([]project.FileSourceLayerChange, 0, len(changes))
for _, change := range changes {
result = append(result, change)
}
result := slices.Clone(changes)
}

slices.SortFunc(result, func(left project.FileSourceLayerChange, right project.FileSourceLayerChange) int {
return cmp.Compare(left.Path, right.Path)
})
return project.FileSourceLayerChanges{Changes: result}
}
121 changes: 75 additions & 46 deletions tsc/internal/api/requestfilesystem/filechanges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ import (
"gotest.tools/v3/assert"
)

func TestFileChangesIncludeDirectoryTombstones(t *testing.T) {
func changeMap(changes []project.FileSourceLayerChange) map[string][2]bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are you just doing this to statically avoid duplicates? You could just do a list of FileSourceLayerChange and make it clear which fields are explicitly true.

result := make(map[string][2]bool, len(changes))
for _, change := range changes {
result[change.Path] = [2]bool{change.Structural, change.ShadowsDescendants}
}
return result
}

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

base, err := newRequestFileSystem(&RequestFileSystem{
Expand All @@ -23,30 +31,35 @@ func TestFileChangesIncludeDirectoryTombstones(t *testing.T) {
}, vfstest.FromMap(map[string]string{}, true), "/")
assert.NilError(t, err)

var summary project.FileChangeSummary
addFileChanges(&summary, &RequestFileSystem{
changes := changeMap(getFileSourceLayerChanges(&RequestFileSystem{
Kind: KindLayer,
Files: map[string]string{"/replaced.ts": "new"},
RemovedPaths: []string{"removed", "/missing", "/replaced.ts"},
}, base, "/")
assert.Assert(t, !summary.InvalidateAll)
assert.Assert(t, summary.IncludesWatchChangeOutsideNodeModules)
assert.Equal(t, summary.Deleted.Len(), 2)
assert.Assert(t, summary.Deleted.Has("file:///removed"))
assert.Assert(t, summary.Deleted.Has("file:///alias"))
assert.Equal(t, summary.Changed.Len(), 1)
assert.Assert(t, summary.Changed.Has("file:///replaced.ts"))
}, base, "/", true).Changes)
assert.DeepEqual(t, changes, map[string][2]bool{
"/alias": {true, true},
"/alias/nested/file.ts": {},
"/missing": {true, true},
"/removed": {true, true},
"/removed/nested/file.ts": {},
"/replaced.ts": {},
})
}

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

changes := getFileSourceLayerChanges(&RequestFileSystem{
Kind: KindLayer,
Files: map[string]string{"/replaced": "new"},
}, nil, "/", true).Changes
assert.DeepEqual(t, changes, []project.FileSourceLayerChange{{Path: "/replaced"}})
}

func TestFileChangesIncludeListingsAndSymlinks(t *testing.T) {
func TestFileSourceLayerChangesIncludeListingsAndSymlinks(t *testing.T) {
t.Parallel()

base := vfstest.FromMap(map[string]string{
"/dir/old.ts": "old listing",
"/link/old.ts": "old target",
}, true)
var summary project.FileChangeSummary
addFileChanges(&summary, &RequestFileSystem{
changes := changeMap(getFileSourceLayerChanges(&RequestFileSystem{
Kind: KindLayer,
Directories: map[string]RequestDirectoryEntries{
"/dir": {},
Expand All @@ -55,18 +68,15 @@ func TestFileChangesIncludeListingsAndSymlinks(t *testing.T) {
"/link": {Target: "/target"},
"/new": {Target: "/host", Host: true},
},
}, base, "/")
assert.Assert(t, !summary.InvalidateAll)
assert.Equal(t, summary.Deleted.Len(), 2)
assert.Assert(t, summary.Deleted.Has("file:///dir"))
assert.Assert(t, summary.Deleted.Has("file:///link"))
assert.Equal(t, summary.Created.Len(), 3)
assert.Assert(t, summary.Created.Has("file:///dir"))
assert.Assert(t, summary.Created.Has("file:///link"))
assert.Assert(t, summary.Created.Has("file:///new"))
}, nil, "/", true).Changes)
assert.DeepEqual(t, changes, map[string][2]bool{
"/dir": {true, false},
"/link": {true, true},
"/new": {true, true},
})
}

func TestFileChangesIncludeRecursiveSymlinkAliases(t *testing.T) {
func TestFileSourceLayerChangesIncludeRecursiveSymlinkAliases(t *testing.T) {
t.Parallel()

base, err := newRequestFileSystem(&RequestFileSystem{
Expand All @@ -78,18 +88,17 @@ func TestFileChangesIncludeRecursiveSymlinkAliases(t *testing.T) {
}, vfstest.FromMap(map[string]string{}, true), "/")
assert.NilError(t, err)

var summary project.FileChangeSummary
addFileChanges(&summary, &RequestFileSystem{
changes := getFileSourceLayerChanges(&RequestFileSystem{
Kind: KindLayer,
Files: map[string]string{"/dir/file.ts": "new"},
}, base, "/")
assert.Equal(t, summary.Changed.Len(), 2)
assert.Assert(t, summary.Changed.Has("file:///dir/file.ts"))
assert.Assert(t, summary.Changed.Has("file:///dir/link/file.ts"))
assert.Equal(t, summary.Created.Len(), 0)
}, base, "/", true).Changes
assert.DeepEqual(t, changes, []project.FileSourceLayerChange{
{Path: "/dir/file.ts"},
{Path: "/dir/link/file.ts"},
})
}

func TestFileChangesIncludeRootSymlinkAliases(t *testing.T) {
func TestFileSourceLayerChangesIncludeRootSymlinkAliases(t *testing.T) {
t.Parallel()

base, err := newRequestFileSystem(&RequestFileSystem{
Expand All @@ -100,17 +109,37 @@ func TestFileChangesIncludeRootSymlinkAliases(t *testing.T) {
},
}, vfstest.FromMap(map[string]string{}, true), "/")
assert.NilError(t, err)
content, ok := base.ReadFile("/link/file.ts")
assert.Assert(t, ok)
assert.Equal(t, content, "old")

var summary project.FileChangeSummary
addFileChanges(&summary, &RequestFileSystem{
changes := getFileSourceLayerChanges(&RequestFileSystem{
Kind: KindLayer,
Files: map[string]string{"/file.ts": "new"},
}, base, "/")
assert.Equal(t, summary.Changed.Len(), 2)
assert.Assert(t, summary.Changed.Has("file:///file.ts"))
assert.Assert(t, summary.Changed.Has("file:///link/file.ts"))
assert.Equal(t, summary.Created.Len(), 0)
}, base, "/", true).Changes
assert.DeepEqual(t, changes, []project.FileSourceLayerChange{
{Path: "/file.ts"},
{Path: "/link/file.ts"},
})
}

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

base, err := newRequestFileSystem(&RequestFileSystem{
Kind: KindFull,
Files: map[string]string{"/target/file.ts": "old"},
Symlinks: map[string]RequestSymlink{
"/link": {Target: "/target"},
},
}, vfstest.FromMap(map[string]string{}, true), "/")
assert.NilError(t, err)

for _, request := range []*RequestFileSystem{
{Kind: KindLayer, RemovedPaths: []string{"/link"}},
{Kind: KindLayer, Files: map[string]string{"/link": "replacement"}},
{Kind: KindLayer, Directories: map[string]RequestDirectoryEntries{"/link": {}}},
{Kind: KindLayer, Symlinks: map[string]RequestSymlink{"/link": {Target: "/other"}}},
} {
changes := getFileSourceLayerChanges(request, base, "/", true)
assert.Assert(t, changes.InvalidateAll)
assert.Equal(t, len(changes.Changes), 0)
}
}
Loading