Skip to content

Commit f726edf

Browse files
authored
Fix LS bugs triggered by file moving (and probably other scenarios) (#2456)
1 parent ceefddd commit f726edf

File tree

4 files changed

+167
-12
lines changed

4 files changed

+167
-12
lines changed

internal/project/configfileregistrybuilder.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,13 +446,25 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo
446446
// Handle possible root file creation
447447
if len(createdFiles) > 0 {
448448
c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool {
449+
var createdOpenFile bool
449450
entry.ChangeIf(
450451
func(config *configFileEntry) bool {
451-
if config.commandLine == nil || config.rootFilesWatch == nil || config.pendingReload != PendingReloadNone {
452+
if config.pendingReload != PendingReloadNone {
452453
return false
453454
}
454455
logger.Logf("Checking if any of %d created files match root files for config %s", len(createdFiles), entry.Key())
455456
for _, fileName := range createdFiles {
457+
if _, ok := config.retainingOpenFiles[c.fs.toPath(fileName)]; ok {
458+
// We saw a create event for a file that's already open, and when we first opened it,
459+
// we tried to see if it belonged to this config, but we may have incorrectly answered
460+
// "no" because we hadn't invalidated the config's file list since the file was created.
461+
// Now that we're seeing a creation event for it, we need to reload the config's file names.
462+
createdOpenFile = true
463+
return true
464+
}
465+
if config.commandLine == nil || config.rootFilesWatch == nil {
466+
continue
467+
}
456468
if config.commandLine.PossiblyMatchesFileName(fileName) {
457469
return true
458470
}
@@ -467,6 +479,16 @@ func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, lo
467479
maps.Copy(affectedProjects, config.retainingProjects)
468480
logger.Logf("Root files for config %s changed", entry.Key())
469481
shouldInvalidateCache = hasExcessiveChanges
482+
if createdOpenFile {
483+
for openFilePath := range config.retainingOpenFiles {
484+
if _, ok := createdFiles[openFilePath]; ok {
485+
if affectedFiles == nil {
486+
affectedFiles = make(map[tspath.Path]struct{})
487+
}
488+
affectedFiles[openFilePath] = struct{}{}
489+
}
490+
}
491+
}
470492
},
471493
)
472494
return !shouldInvalidateCache

internal/project/overlayfs.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma
315315
}
316316
result.Closed[uri] = events.closeChange.Hash
317317
delete(newOverlays, path)
318+
o = nil
318319
}
319320

320321
if events.watchChanged {
@@ -364,7 +365,7 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma
364365
newOverlays[path] = o
365366
}
366367

367-
if events.created && o == nil {
368+
if events.created {
368369
result.Created.Add(uri)
369370
}
370371

internal/project/projectcollectionbuilder.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCo
8989
newProjectCollection.configuredProjects = configuredProjects
9090
}
9191

92-
if !changed && !maps.Equal(b.fileDefaultProjects, b.base.fileDefaultProjects) {
92+
if !maps.Equal(b.fileDefaultProjects, b.base.fileDefaultProjects) {
9393
ensureCloned()
9494
newProjectCollection.fileDefaultProjects = b.fileDefaultProjects
9595
}
@@ -338,6 +338,16 @@ func logChangeFileResult(result changeFileResult, logger *logging.LogTree) {
338338
}
339339
}
340340

341+
func (b *ProjectCollectionBuilder) cleanupInferredProject(logger *logging.LogTree) {
342+
var inferredProjectFiles []string
343+
for path, overlay := range b.fs.overlays {
344+
if b.findDefaultConfiguredProject(overlay.FileName(), path) == nil {
345+
inferredProjectFiles = append(inferredProjectFiles, overlay.FileName())
346+
}
347+
}
348+
b.updateInferredProjectRoots(inferredProjectFiles, logger)
349+
}
350+
341351
func (b *ProjectCollectionBuilder) DidRequestFile(uri lsproto.DocumentUri, logger *logging.LogTree) {
342352
startTime := time.Now()
343353
fileName := uri.FileName()
@@ -349,6 +359,9 @@ func (b *ProjectCollectionBuilder) DidRequestFile(uri lsproto.DocumentUri, logge
349359
if result := b.findDefaultProject(fileName, path); result != nil {
350360
hasChanges = b.updateProgram(result, logger) || hasChanges
351361
if result.Value() != nil {
362+
if hasChanges {
363+
b.cleanupInferredProject(logger)
364+
}
352365
return
353366
}
354367
}
@@ -361,15 +374,7 @@ func (b *ProjectCollectionBuilder) DidRequestFile(uri lsproto.DocumentUri, logge
361374
if hasChanges {
362375
// If the structure of other projects changed, we might need to move files
363376
// in/out of the inferred project.
364-
var inferredProjectFiles []string
365-
for path, overlay := range b.fs.overlays {
366-
if b.findDefaultConfiguredProject(overlay.FileName(), path) == nil {
367-
inferredProjectFiles = append(inferredProjectFiles, overlay.FileName())
368-
}
369-
}
370-
if len(inferredProjectFiles) > 0 {
371-
b.updateInferredProjectRoots(inferredProjectFiles, logger)
372-
}
377+
b.cleanupInferredProject(logger)
373378
}
374379

375380
if b.inferredProject.Value() != nil {

internal/project/projectlifetime_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,4 +217,131 @@ func TestProjectLifetime(t *testing.T) {
217217
assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1)
218218
assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil)
219219
})
220+
221+
t.Run("file move from inferred to configured via didOpen/didClose sequence", func(t *testing.T) {
222+
t.Parallel()
223+
// Start with tsconfig.json that includes "src" but file is at root level
224+
files := map[string]any{
225+
"/home/projects/TS/p1/tsconfig.json": `{
226+
"compilerOptions": {
227+
"noLib": true
228+
},
229+
"include": ["src"]
230+
}`,
231+
"/home/projects/TS/p1/index.ts": `export const x = 1;`,
232+
}
233+
session, utils := projecttestutil.Setup(files)
234+
235+
// Open index.ts at root level - should create inferred project since it's not under src/
236+
// Creates config file registry entry, but has no files
237+
indexUri := lsproto.DocumentUri("file:///home/projects/TS/p1/index.ts")
238+
session.DidOpenFile(context.Background(), indexUri, 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript)
239+
240+
// Should have one inferred project only (file is not included by tsconfig)
241+
snapshot, release := session.Snapshot()
242+
defer release()
243+
assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1)
244+
assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil)
245+
assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) == nil)
246+
247+
// Simulate file move: create src/index.ts on disk
248+
err := utils.FS().WriteFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/index.ts"].(string), false)
249+
assert.NilError(t, err)
250+
err = utils.FS().Remove("/home/projects/TS/p1/index.ts")
251+
assert.NilError(t, err)
252+
253+
// Simulate file move sequence as it would happen in an editor:
254+
// 1. didOpen src/index.ts (new location)
255+
// Open comes in before file create event, so the config file is not marked as needing a file name reload,
256+
// so it's not turned into a configured project yet. This is probably not ideal, but it should sort itself
257+
// out momentarily after the file watcher events are processed. When we try the config file, we mark it
258+
// as "retained by src/index.ts" so the config entry doesn't get deleted before src/index.ts is closed.
259+
// Even though we currently think src/index.ts doesn't belong to the config, the config is in its directory
260+
// path, so we'll always see it as a candidate for containing src/index.ts.
261+
srcIndexUri := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts")
262+
session.DidOpenFile(context.Background(), srcIndexUri, 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript)
263+
264+
// 2. didClose index.ts (old location)
265+
session.DidCloseFile(context.Background(), indexUri)
266+
267+
// 3. didChangeWatchedFiles: create src/index.ts and delete index.ts
268+
// The creation event for src/index.ts now hits the config file registry, and we should notice we
269+
// got a creation event for a file that retained the config, triggering a filename reload.
270+
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{
271+
{
272+
Uri: srcIndexUri,
273+
Type: lsproto.FileChangeTypeCreated,
274+
},
275+
{
276+
Uri: indexUri,
277+
Type: lsproto.FileChangeTypeDeleted,
278+
},
279+
})
280+
281+
// Should now have one configured project only (file is now under src/)
282+
_, err = session.GetLanguageService(context.Background(), srcIndexUri)
283+
assert.NilError(t, err)
284+
snapshot, release = session.Snapshot()
285+
defer release()
286+
assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1)
287+
assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil)
288+
assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil)
289+
})
290+
291+
t.Run("tsconfig move from subdirectory to parent via didChangeWatchedFiles", func(t *testing.T) {
292+
t.Parallel()
293+
// Start with tsconfig.json in src/ that includes "src" - file won't be included initially
294+
files := map[string]any{
295+
"/home/projects/TS/p1/src/tsconfig.json": `{
296+
"compilerOptions": {
297+
"noLib": true
298+
},
299+
"include": ["src"]
300+
}`,
301+
"/home/projects/TS/p1/src/index.ts": `export const x = 1;`,
302+
}
303+
session, utils := projecttestutil.Setup(files)
304+
305+
// Open src/index.ts - should create inferred project since tsconfig.json includes "src"
306+
// relative to its location (src/src/ which doesn't exist)
307+
indexUri := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts")
308+
session.DidOpenFile(context.Background(), indexUri, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
309+
310+
// Should have one inferred project only (file is not included by tsconfig at src/tsconfig.json)
311+
snapshot, release := session.Snapshot()
312+
defer release()
313+
assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1)
314+
assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil)
315+
assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/src/tsconfig.json")) == nil)
316+
317+
// Simulate tsconfig.json move: create tsconfig.json at parent level, delete from src/
318+
tsconfigContent := files["/home/projects/TS/p1/src/tsconfig.json"].(string)
319+
err := utils.FS().WriteFile("/home/projects/TS/p1/tsconfig.json", tsconfigContent, false)
320+
assert.NilError(t, err)
321+
err = utils.FS().Remove("/home/projects/TS/p1/src/tsconfig.json")
322+
assert.NilError(t, err)
323+
324+
// Simulate file move via didChangeWatchedFiles
325+
newTsconfigUri := lsproto.DocumentUri("file:///home/projects/TS/p1/tsconfig.json")
326+
oldTsconfigUri := lsproto.DocumentUri("file:///home/projects/TS/p1/src/tsconfig.json")
327+
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{
328+
{
329+
Uri: newTsconfigUri,
330+
Type: lsproto.FileChangeTypeCreated,
331+
},
332+
{
333+
Uri: oldTsconfigUri,
334+
Type: lsproto.FileChangeTypeDeleted,
335+
},
336+
})
337+
338+
// Should now have one configured project only (tsconfig.json now includes src/index.ts)
339+
_, err = session.GetLanguageService(context.Background(), indexUri)
340+
assert.NilError(t, err)
341+
snapshot, release = session.Snapshot()
342+
defer release()
343+
assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1)
344+
assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil)
345+
assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil)
346+
})
220347
}

0 commit comments

Comments
 (0)