Skip to content
Open
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
16 changes: 7 additions & 9 deletions tsc/internal/project/overlayfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,15 +340,13 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma
o = nil
}

if events.watchChanged {
if o == nil {
result.Changed.Add(uri)
} else if o != nil && !events.saved {
if matchesDiskText, _ := o.computeMatchesDiskText(fs.fs); matchesDiskText != o.MatchesDiskText() {
o = newOverlay(o.FileName(), o.Content(), o.Version(), o.kind)
o.matchesDiskText = matchesDiskText
newOverlays[path] = o
}
if events.watchChanged && o == nil {
result.Changed.Add(uri)
} else if (events.watchChanged || events.created) && o != nil && !events.saved {
if matchesDiskText, _ := o.computeMatchesDiskText(fs.fs); matchesDiskText != o.MatchesDiskText() {
o = newOverlay(o.FileName(), o.Content(), o.Version(), o.kind)
o.matchesDiskText = matchesDiskText
newOverlays[path] = o
}
}

Expand Down
33 changes: 33 additions & 0 deletions tsc/internal/project/overlayfs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,39 @@ func TestProcessChanges(t *testing.T) {
assert.Assert(t, !fs.getFile(testURI1.FileName()).MatchesDiskText())
})

t.Run("watch create on open overlay updates matchesDiskText", func(t *testing.T) {
t.Parallel()
fs := createOverlayFS()

// Open overlay
fs.processChanges([]FileChange{
{
Kind: FileChangeKindOpen,
URI: testURI1,
Content: "overlay content",
},
})
assert.Assert(t, !fs.getFile(testURI1.FileName()).MatchesDiskText())

// Save overlay (matches disk)
fs.processChanges([]FileChange{
{
Kind: FileChangeKindSave,
URI: testURI1,
},
})
assert.Assert(t, fs.getFile(testURI1.FileName()).MatchesDiskText())

// Watch create event on disk (e.g. external overwrite)
fs.processChanges([]FileChange{
{
Kind: FileChangeKindWatchCreate,
URI: testURI1,
},
})
assert.Assert(t, !fs.getFile(testURI1.FileName()).MatchesDiskText())
})

t.Run("save without overlay should not panic", func(t *testing.T) {
t.Parallel()
fs := createOverlayFS()
Expand Down
68 changes: 68 additions & 0 deletions tsc/internal/project/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,74 @@ func TestSession(t *testing.T) {
assert.Check(t, lsAfter.GetProgram() != programBefore)
})

t.Run("create closed program file reloads content (atomic save)", func(t *testing.T) {
t.Parallel()
files := maps.Clone(defaultFiles)
session, utils := projecttestutil.Setup(files)

session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript)

lsBefore, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts")
assert.NilError(t, err)
programBefore := lsBefore.GetProgram()

err = utils.FS().WriteFile("/home/projects/TS/p1/src/x.ts", `export const x = 2;`)
assert.NilError(t, err)

// Simulate atomic save / rename where watcher reports Created (type 1)
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{
{
Type: lsproto.FileChangeTypeCreated,
Uri: "file:///home/projects/TS/p1/src/x.ts",
},
})

lsAfter, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts")
assert.NilError(t, err)
assert.Check(t, lsAfter.GetProgram() != programBefore)
srcX := lsAfter.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts")
assert.Assert(t, srcX != nil)
assert.Equal(t, srcX.Text(), "export const x = 2;")
})

t.Run("atomic save sequence with temp file reloads content", func(t *testing.T) {
t.Parallel()
files := maps.Clone(defaultFiles)
session, utils := projecttestutil.Setup(files)

session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript)

lsBefore, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts")
assert.NilError(t, err)
programBefore := lsBefore.GetProgram()

err = utils.FS().WriteFile("/home/projects/TS/p1/src/x.ts", `export const x = 42;`)
assert.NilError(t, err)

// Sequence sent by editors like VS Code on atomic save: tmp create -> target create -> tmp delete
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{
{
Type: lsproto.FileChangeTypeCreated,
Uri: "file:///home/projects/TS/p1/src/x.ts.tmp",
},
{
Type: lsproto.FileChangeTypeCreated,
Uri: "file:///home/projects/TS/p1/src/x.ts",
},
{
Type: lsproto.FileChangeTypeDeleted,
Uri: "file:///home/projects/TS/p1/src/x.ts.tmp",
},
})

lsAfter, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts")
assert.NilError(t, err)
assert.Check(t, lsAfter.GetProgram() != programBefore)
srcX := lsAfter.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts")
assert.Assert(t, srcX != nil)
assert.Equal(t, srcX.Text(), "export const x = 42;")
})

t.Run("change program file not in tsconfig root files", func(t *testing.T) {
t.Parallel()
for _, workspaceDir := range []string{"/", "/home/projects/TS/p1", "/somewhere/else/entirely"} {
Expand Down
76 changes: 44 additions & 32 deletions tsc/internal/project/snapshotfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,15 @@ func (s *snapshotFSBuilder) watchChangesOverlapCache(change FileChangeSummary) b
return true
}
}
for uri := range change.Created.Keys() {
path := s.toPath(uri.FileName())
if _, ok := s.diskFiles.Load(path); ok {
return true
}
if _, ok := s.nodeModulesRealpathAliases.Load(path); ok {
return true
}
}
for uri := range change.Deleted.Keys() {
path := s.toPath(uri.FileName())
if _, ok := s.diskFiles.Load(path); ok {
Expand Down Expand Up @@ -478,33 +487,40 @@ func (s *snapshotFSBuilder) invalidateNodeModulesCache() {
}

func (s *snapshotFSBuilder) markDirtyFiles(change FileChangeSummary) FileChangeSummary {
if change.Changed.Len() > 0 {
var filteredChanged collections.SyncSet[lsproto.DocumentUri]
filterChanges := func(set collections.Set[lsproto.DocumentUri]) collections.Set[lsproto.DocumentUri] {
if set.Len() == 0 {
return set
}
var filtered collections.SyncSet[lsproto.DocumentUri]
wg := core.NewWorkGroup(false)
for uri := range change.Changed.Keys() {
for uri := range set.Keys() {
path := s.toPath(uri.FileName())
if _, ok := s.overlays[path]; ok {
filteredChanged.Add(uri)
filtered.Add(uri)
continue
}
entry, ok := s.diskFiles.Load(path)
if !ok {
filteredChanged.Add(uri)
filtered.Add(uri)
continue
}
wg.Queue(func() {
if s.reloadEntryIfContentChanged(entry) {
filteredChanged.Add(uri)
filtered.Add(uri)
}
})
}
wg.RunAndWait()
newChanged := collections.NewSetWithSizeHint[lsproto.DocumentUri](filteredChanged.Size())
for uri := range filteredChanged.Keys() {
newChanged.Add(uri)
newSet := collections.NewSetWithSizeHint[lsproto.DocumentUri](filtered.Size())
for uri := range filtered.Keys() {
newSet.Add(uri)
}
change.Changed = *newChanged
return *newSet
}

change.Changed = filterChanges(change.Changed)
change.Created = filterChanges(change.Created)

for uri := range change.Deleted.Keys() {
path := s.toPath(uri.FileName())
if entry, ok := s.diskFiles.Load(path); ok {
Expand Down Expand Up @@ -548,7 +564,7 @@ func (s *snapshotFSBuilder) reloadEntryIfContentChanged(entry *dirty.SyncMapEntr
return changed
}

// expandRealpathAliases adds synthetic URIs to the Changed and Deleted sets for
// expandRealpathAliases adds synthetic URIs to the Changed, Created, and Deleted sets for
// files that were accessed through node_modules symlinks. When a watch event arrives
// using a realpath, this expands it to include the symlink-based path so that
// downstream consumers (markDirtyFiles, markFilesChanged) can find cached entries.
Expand All @@ -557,31 +573,27 @@ func (s *SnapshotFS) expandRealpathAliases(change FileChangeSummary) FileChangeS
return change
}

var additionalChanged collections.Set[lsproto.DocumentUri]
for uri := range change.Changed.Keys() {
path := s.toPath(uri.FileName())
if aliases, ok := s.nodeModulesRealpathAliases[path]; ok {
for aliasPath := range aliases.paths.Keys() {
additionalChanged.Add(lsconv.FileNameToDocumentURI(string(aliasPath)))
}
expandSet := func(set *collections.Set[lsproto.DocumentUri]) {
if set.Len() == 0 {
return
}
}
for uri := range additionalChanged.Keys() {
change.Changed.Add(uri)
}

var additionalDeleted collections.Set[lsproto.DocumentUri]
for uri := range change.Deleted.Keys() {
path := s.toPath(uri.FileName())
if aliases, ok := s.nodeModulesRealpathAliases[path]; ok {
for aliasPath := range aliases.paths.Keys() {
additionalDeleted.Add(lsconv.FileNameToDocumentURI(string(aliasPath)))
var additional collections.Set[lsproto.DocumentUri]
for uri := range set.Keys() {
path := s.toPath(uri.FileName())
if aliases, ok := s.nodeModulesRealpathAliases[path]; ok {
for aliasPath := range aliases.paths.Keys() {
additional.Add(lsconv.FileNameToDocumentURI(string(aliasPath)))
}
}
}
for uri := range additional.Keys() {
set.Add(uri)
}
}
for uri := range additionalDeleted.Keys() {
change.Deleted.Add(uri)
}

expandSet(&change.Changed)
expandSet(&change.Created)
expandSet(&change.Deleted)

return change
}
Expand Down
55 changes: 55 additions & 0 deletions tsc/internal/project/snapshotfs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,61 @@ func TestRealpathAliasLifecycle(t *testing.T) {
assert.Equal(t, file.Content(), `{"name": "mylib"}`, "content should be updated")
})

t.Run("markDirtyFiles invalidates existing cached file on created event", func(t *testing.T) {
t.Parallel()
testFS := vfstest.FromMap(map[string]any{
"/project/src/helper.ts": `export const other = 1;`,
}, false)

// Build first snapshot — read the file.
builder1 := newSnapshotFSBuilder(
testFS,
make(map[tspath.Path]*Overlay),
make(map[tspath.Path]*Overlay),
make(map[tspath.Path]*diskFile),
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
nil,
lsproto.PositionEncodingKindUTF16,
toPath,
)
fh := builder1.GetFile("/project/src/helper.ts")
assert.Assert(t, fh != nil)
assert.Equal(t, fh.Content(), `export const other = 1;`)
snapshot1, _ := builder1.Finalize()

// Modify the file on disk (e.g. via atomic save rename).
err := testFS.WriteFile("/project/src/helper.ts", `export const thing = 1;`)
assert.NilError(t, err)

// Build second snapshot — simulate Created event.
builder2 := newSnapshotFSBuilder(
testFS,
make(map[tspath.Path]*Overlay),
make(map[tspath.Path]*Overlay),
snapshot1.diskFiles,
snapshot1.diskDirectories,
snapshot1.nodeModulesRealpathAliases,
lsproto.PositionEncodingKindUTF16,
toPath,
)

change := FileChangeSummary{}
change.Created.Add("file:///project/src/helper.ts")

change = builder2.markDirtyFiles(change)
assert.Assert(t, change.Created.Has("file:///project/src/helper.ts"))

// Reading the file should return the updated content.
fh = builder2.GetFile("/project/src/helper.ts")
assert.Assert(t, fh != nil)
assert.Equal(t, fh.Content(), `export const thing = 1;`)

snapshot2, _ := builder2.Finalize()
file, ok := snapshot2.diskFiles[tspath.Path("/project/src/helper.ts")]
assert.Assert(t, ok)
assert.Equal(t, file.Content(), `export const thing = 1;`)
})

t.Run("alias clone isolation between snapshots", func(t *testing.T) {
t.Parallel()
testFS := vfstest.FromMap(map[string]any{
Expand Down