diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index 3ec3a729fd1d2..4b5ba1b295811 100644 --- a/tsc/internal/api/session_apistate_test.go +++ b/tsc/internal/api/session_apistate_test.go @@ -571,3 +571,93 @@ func TestGetCurrentLanguageServerSnapshotCloseProjectRetainsProjectForOpenFile(t assert.NilError(t, err) assert.Assert(t, projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.PathKeyFromCanonical(configFileName)) != nil) } + +func openProjectInNewSnapshot(t *testing.T, session *Session, configFileName string) SnapshotID { + t.Helper() + response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: configFileName}}, + }) + assert.NilError(t, err) + return response.Snapshot +} + +func configFileParsingDiagnosticCodes(t *testing.T, session *Session, snapshot SnapshotID, configFileName string) []int32 { + t.Helper() + configuredProject := session.snapshots[snapshot].snapshot.ProjectCollection.GetProject(configuredProjectID(configFileName)) + assert.Assert(t, configuredProject != nil) + diagnostics, err := session.handleGetConfigFileParsingDiagnostics(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: snapshot, + Project: configuredProject.Handle(), + }) + assert.NilError(t, err) + codes := make([]int32, 0, len(diagnostics)) + for _, diagnostic := range diagnostics { + codes = append(codes, diagnostic.Code) + } + return codes +} + +func TestOpenProjectsExtendingIntoCircularExtendsReportCircularity(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const cycle = "/home/projects/cycle/tsconfig.json" + const outer = "/home/projects/outer/tsconfig.json" + projectSession, _ := projecttestutil.Setup(map[string]any{ + cycle: `{ "extends": "./a.json", "compilerOptions": { "strict": true } }`, + "/home/projects/cycle/a.json": `{ "extends": "./tsconfig.json", "compilerOptions": { "noUnusedLocals": true } }`, + outer: `{ "extends": "../cycle/a.json" }`, + "/home/projects/cycle/main.ts": `export const a = 1;`, + "/home/projects/outer/main.ts": `export const b = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + openProjectInNewSnapshot(t, session, cycle) + for range 2 { + snapshot := openProjectInNewSnapshot(t, session, outer) + assert.DeepEqual(t, configFileParsingDiagnosticCodes(t, session, snapshot, outer), []int32{18000}) + options := session.snapshots[snapshot].snapshot.ProjectCollection.GetProject(configuredProjectID(outer)).CommandLine.CompilerOptions() + assert.Equal(t, options.Strict, core.TSTrue) + assert.Equal(t, options.NoUnusedLocals, core.TSTrue) + } +} + +func TestReopenProjectAfterExtendsChangeUsesNewExtendedConfig(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const first = "/home/projects/p1/tsconfig.json" + const second = "/home/projects/p2/tsconfig.json" + const shared = "/home/projects/shared/a.json" + projectSession, utils := projecttestutil.Setup(map[string]any{ + first: `{ "extends": "../shared/a.json" }`, + second: `{ "extends": "../shared/a.json" }`, + shared: `{ "extends": "./b.json" }`, + "/home/projects/shared/b.json": `{ "compilerOptions": { "strict": true } }`, + "/home/projects/shared/c.json": `{ "compilerOptions": { "strict": false } }`, + "/home/projects/p1/a.ts": `export const a = 1;`, + "/home/projects/p2/b.ts": `export const b = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + openProjectInNewSnapshot(t, session, first) + assert.NilError(t, utils.FS().WriteFile(shared, `{ "extends": "./c.json" }`)) + changed, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: second}}, + FileNotifications: &FileNotifications{Changed: []DocumentIdentifier{{FileName: shared}}}, + }) + assert.NilError(t, err) + _, err = session.handleRelease(context.Background(), &ReleaseParams{Snapshot: changed.Snapshot}) + assert.NilError(t, err) + snapshot := openProjectInNewSnapshot(t, session, second) + options := session.snapshots[snapshot].snapshot.ProjectCollection.GetProject(configuredProjectID(second)).CommandLine.CompilerOptions() + assert.Equal(t, options.Strict, core.TSFalse) +} diff --git a/tsc/internal/project/configfileregistrybuilder.go b/tsc/internal/project/configfileregistrybuilder.go index 9233ad4d48786..dd77835cd5a40 100644 --- a/tsc/internal/project/configfileregistrybuilder.go +++ b/tsc/internal/project/configfileregistrybuilder.go @@ -822,14 +822,22 @@ func (c *configFileRegistryBuilder) GetExtendedConfig(fileName tspath.RootedFile content = fh.Content() } - return c.extendedConfigCache.LoadAndAcquire(path, c.snapshotID, ExtendedConfigParseArgs{ + args := ExtendedConfigParseArgs{ FileName: fileName, Content: content, FS: c.fs.source, ResolutionStack: resolutionStack, ConfigFS: fs, Cache: c, - }).ExtendedConfigCacheEntry + } + entry := c.extendedConfigCache.LoadAndAcquire(path, c.snapshotID, args) + for _, extendedFileName := range entry.ExtendedFileNames() { + extendedPath := fs.CaseSensitivity().PathKey(extendedFileName.AsPath()) + if !slices.Contains(resolutionStack, extendedPath) && !c.extendedConfigCache.TryAddOwner(extendedPath, c.snapshotID) { + return c.extendedConfigCache.ParseAndAcquire(path, c.snapshotID, args, entry).ExtendedConfigCacheEntry + } + } + return entry.ExtendedConfigCacheEntry } func (c *configFileRegistryBuilder) Cleanup() { diff --git a/tsc/internal/project/ownercache.go b/tsc/internal/project/ownercache.go index 801233d748b03..53dbf9a2fc68f 100644 --- a/tsc/internal/project/ownercache.go +++ b/tsc/internal/project/ownercache.go @@ -18,14 +18,14 @@ type ownerCacheEntry[V any] struct { // be Acquired multiple times during config parsing while only appearing once in // the ParsedCommandLine's list of extended files. When updating this code, check // if the same changes should be made to RefCountCache as well. -type OwnerCache[K comparable, V any, LoadArgs any] struct { +type OwnerCache[K comparable, V comparable, LoadArgs any] struct { entries collections.SyncMap[K, *ownerCacheEntry[V]] isExpired func(K, V, LoadArgs) bool parse func(K, LoadArgs) V } -func NewOwnerCache[K comparable, V any, LoadArgs any]( +func NewOwnerCache[K comparable, V comparable, LoadArgs any]( parse func(K, LoadArgs) V, isExpired func(K, V, LoadArgs) bool, ) *OwnerCache[K, V, LoadArgs] { @@ -70,6 +70,31 @@ func (c *OwnerCache[K, V, LoadArgs]) AddOwner(identity K, owner uint64) { entry.owners[owner] = struct{}{} } +func (c *OwnerCache[K, V, LoadArgs]) TryAddOwner(identity K, owner uint64) bool { + entry, ok := c.entries.Load(identity) + if !ok { + return false + } + entry.mu.Lock() + defer entry.mu.Unlock() + if len(entry.owners) == 0 { + return false + } + entry.owners[owner] = struct{}{} + return true +} + +func (c *OwnerCache[K, V, LoadArgs]) ParseAndAcquire(identity K, owner uint64, loadArgs LoadArgs, stale V) V { + value := c.parse(identity, loadArgs) + entry, loaded := c.loadOrStoreLockedEntry(identity) + defer entry.mu.Unlock() + if !loaded || entry.value == stale || c.isExpired != nil && c.isExpired(identity, entry.value, loadArgs) { + entry.value = value + } + entry.owners[owner] = struct{}{} + return entry.value +} + func (c *OwnerCache[K, V, LoadArgs]) Has(identity K) bool { _, ok := c.entries.Load(identity) return ok