package project import ( "context" "fmt" "testing" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" "github.com/microsoft/typescript-go/internal/vfs/vfstest" "gotest.tools/v3/assert" ) type noopClient struct{} func (noopClient) WatchFiles(ctx context.Context, id WatcherID, watchers []*lsproto.FileSystemWatcher) error { return nil } func (noopClient) UnwatchFiles(ctx context.Context, id WatcherID) error { return nil } func (noopClient) RefreshDiagnostics(ctx context.Context) error { return nil } func (noopClient) PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error { return nil } func (noopClient) RefreshInlayHints(ctx context.Context) error { return nil } func (noopClient) RefreshCodeLens(ctx context.Context) error { return nil } func (noopClient) ProgressStart(message *diagnostics.Message, args ...any) {} func (noopClient) ProgressFinish(message *diagnostics.Message, args ...any) {} func (noopClient) SendTelemetry(ctx context.Context, telemetry lsproto.TelemetryEvent) error { return nil } func (noopClient) IsActive() bool { return true } // TestExtendedConfigCacheOwnership tests the invariant that each ExtendedSourceFile // of a config in the ConfigFileRegistry is owned exactly once per snapshot that // references it, and released exactly once when that snapshot is removed. func TestExtendedConfigCacheOwnership(t *testing.T) { t.Parallel() if !bundled.Embedded { t.Skip("bundled files are not embedded") } setup := func(files map[string]any) *Session { fsFromMap := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) fs := bundled.WrapFS(fsFromMap) session := NewSession(&SessionInit{ BackgroundCtx: context.Background(), Options: &SessionOptions{ CurrentDirectory: "/", DefaultLibraryPath: bundled.LibPath(), TypingsLocation: "/home/src/Library/Caches/typescript", PositionEncoding: lsproto.PositionEncodingKindUTF8, WatchEnabled: false, LoggingEnabled: false, }, FS: fs, Client: noopClient{}, Logger: logging.NewTestLogger(), NpmExecutor: nil, }) return session } untitledSeq := 0 openUntitled := func(session *Session) { untitledSeq++ uri := lsproto.DocumentUri(fmt.Sprintf("untitled:Untitled-%d", untitledSeq)) session.DidOpenFile(context.Background(), uri, 1, "", lsproto.LanguageKindTypeScript) } // flushCloseProject is the canonical way to ensure project close work is applied. // Close the file, then open an unrelated file. flushCloseProject := func(session *Session, fileURI lsproto.DocumentUri) { session.DidCloseFile(context.Background(), fileURI) openUntitled(session) } ownerCount := func(session *Session, path tspath.Path) int { entry, ok := session.extendedConfigCache.entries.Load(path) if !ok { return 0 } return len(entry.owners) } assertNoEntry := func(t *testing.T, session *Session, fileName string) { t.Helper() path := session.toPath(fileName) _, ok := session.extendedConfigCache.entries.Load(path) assert.Equal(t, ok, false) } expectedExtendedOwnerCounts := func(session *Session, snapshot *Snapshot) map[tspath.Path]int { result := make(map[tspath.Path]int) for _, cfg := range snapshot.ConfigFileRegistry.configs { if cfg.commandLine == nil || cfg.commandLine.ConfigFile == nil { continue } for _, file := range cfg.commandLine.ExtendedSourceFiles() { result[session.toPath(file)]++ } } return result } assertExtendedOwnerCountsMatchRegistry := func(t *testing.T, session *Session, snapshot *Snapshot) { t.Helper() expected := expectedExtendedOwnerCounts(session, snapshot) for path, want := range expected { got := ownerCount(session, path) assert.Equal(t, got, want, "extended config %s owner count mismatch", path) } } t.Run("multi-extends shared ancestor counted once", func(t *testing.T) { t.Parallel() // One config extends *two* configs; both extend a shared root. // Expected behavior: ExtendedSourceFiles() is deduped, so the shared root should only // be ref'd once for this config. files := map[string]any{ "/project/tsconfig.json": `{ "extends": ["./tsconfig.base1.json", "./tsconfig.base2.json"] }`, "/project/tsconfig.base1.json": `{ "extends": "./tsconfig.root.json", "compilerOptions": {"strict": true} }`, "/project/tsconfig.base2.json": `{ "extends": "./tsconfig.root.json", "compilerOptions": {"noImplicitAny": true} }`, "/project/tsconfig.root.json": `{ "compilerOptions": {"target": "ES2020"} }`, "/project/src/main.ts": "export const x = 1;", } session := setup(files) session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///project/src/main.ts"), 1, files["/project/src/main.ts"].(string), lsproto.LanguageKindTypeScript) snapshot := session.Snapshot() config := snapshot.ConfigFileRegistry.GetConfig("/project/tsconfig.json") assert.Assert(t, config != nil) // Shared root should only appear once in the flattened list. var rootCount int for _, f := range config.ExtendedSourceFiles() { if f == "/project/tsconfig.root.json" { rootCount++ } } assert.Equal(t, rootCount, 1) // And the cache owner counts should match the registry's deduped list. assertExtendedOwnerCountsMatchRegistry(t, session, snapshot) flushCloseProject(session, lsproto.DocumentUri("file:///project/src/main.ts")) assertNoEntry(t, session, "/project/tsconfig.base1.json") assertNoEntry(t, session, "/project/tsconfig.base2.json") assertNoEntry(t, session, "/project/tsconfig.root.json") }) t.Run("ExtendedSourceFiles can contain same path twice (case-insensitive)", func(t *testing.T) { t.Parallel() // This test is descriptive, not prescriptive. This seems bad and unintentional, // but is here to show that while the problem exists in the underlying config parsing // API, it doesn't disrupt cache ownership. files := map[string]any{ "/project/tsconfig.json": `{ "extends": ["./Shared.json", "./shared.json"] }`, "/project/shared.json": `{ "compilerOptions": {"strict": true} }`, } // This test intentionally bypasses the project system's ExtendedConfigCache so we can // observe how ExtendedSourceFiles behaves when the same underlying file is referenced // with different casing on a case-insensitive FS. fsFromMap := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) fs := bundled.WrapFS(fsFromMap) // Minimal ParseConfigHost implementation. h := &testParseConfigHost{fs: fs, cwd: "/"} cmd, diags := tsoptions.GetParsedCommandLineOfConfigFile("/project/tsconfig.json", nil, nil, h, nil /*extendedConfigCache*/) assert.Equal(t, len(diags), 0) assert.Assert(t, cmd != nil) extended := cmd.ExtendedSourceFiles() assert.Equal(t, len(extended), 2) assert.Equal(t, extended[0], "/project/Shared.json") assert.Equal(t, extended[1], "/project/shared.json") }) t.Run("project system dedupes case-only extends via cache", func(t *testing.T) { t.Parallel() files := map[string]any{ "/project/tsconfig.json": `{ "extends": ["./Shared.json", "./shared.json"] }`, "/project/shared.json": `{ "compilerOptions": {"strict": true} }`, "/project/src/main.ts": "export const x = 1;", } session := setup(files) session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///project/src/main.ts"), 1, files["/project/src/main.ts"].(string), lsproto.LanguageKindTypeScript) snapshot := session.Snapshot() config := snapshot.ConfigFileRegistry.GetConfig("/project/tsconfig.json") assert.Assert(t, config != nil) extended := config.ExtendedSourceFiles() assert.Equal(t, len(extended), 1) assert.Equal(t, session.toPath(extended[0]), session.toPath("/project/shared.json")) }) t.Run("transitive extended config ownership with new project", func(t *testing.T) { t.Parallel() // Scenario: transitive extends chain where a new project reuses a cached // extended config without reparsing it, which should still acquire the transitive deps. // // projectA/tsconfig.json extends shared/tsconfig.base.json extends shared/tsconfig.common.json // projectB/tsconfig.json extends shared/tsconfig.base.json extends shared/tsconfig.common.json // // When projectB is opened AFTER projectA, tsconfig.base.json is retrieved from cache // (not reparsed), so tsconfig.common.json still needs projectB's snapshot ownership. files := map[string]any{ "/user/username/projects/shared/tsconfig.common.json": `{ "compilerOptions": { "strict": true } }`, "/user/username/projects/shared/tsconfig.base.json": `{ "extends": "./tsconfig.common.json", "compilerOptions": { "target": "ES2020" } }`, "/user/username/projects/projectA/tsconfig.json": `{ "extends": "../shared/tsconfig.base.json" }`, "/user/username/projects/projectA/src/main.ts": "const a = 1;", "/user/username/projects/projectB/tsconfig.json": `{ "extends": "../shared/tsconfig.base.json" }`, "/user/username/projects/projectB/src/main.ts": "const b = 2;", "/user/username/projects/other/src/main.ts": "const other = 3;", } session := setup(files) // Step 1: Open file in projectA - this parses the full extends chain session.DidOpenFile(context.Background(), "file:///user/username/projects/projectA/src/main.ts", 1, files["/user/username/projects/projectA/src/main.ts"].(string), lsproto.LanguageKindTypeScript) // Verify extended configs are in cache with correct owner counts baseEntry, baseOk := session.extendedConfigCache.entries.Load("/user/username/projects/shared/tsconfig.base.json") commonEntry, commonOk := session.extendedConfigCache.entries.Load("/user/username/projects/shared/tsconfig.common.json") assert.Assert(t, baseOk, "tsconfig.base.json should be in cache") assert.Assert(t, commonOk, "tsconfig.common.json should be in cache") assert.Equal(t, len(baseEntry.owners), 1) assert.Equal(t, len(commonEntry.owners), 1) // Step 2: Open file in projectB - this should acquire tsconfig.base.json from cache // (not reparse it), and should also acquire tsconfig.common.json. session.DidOpenFile(context.Background(), "file:///user/username/projects/projectB/src/main.ts", 1, files["/user/username/projects/projectB/src/main.ts"].(string), lsproto.LanguageKindTypeScript) // Step 3: Close projectA file and open an unrelated file to force projectA cleanup session.DidCloseFile(context.Background(), "file:///user/username/projects/projectA/src/main.ts") // Opening another file triggers cleanup of closed projects session.DidOpenFile(context.Background(), "file:///user/username/projects/other/src/main.ts", 1, files["/user/username/projects/other/src/main.ts"].(string), lsproto.LanguageKindTypeScript) // Close the other file too so only projectB remains session.DidCloseFile(context.Background(), "file:///user/username/projects/other/src/main.ts") // Step 4: Trigger another snapshot clone for projectB session.DidChangeFile(context.Background(), "file:///user/username/projects/projectB/src/main.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ { Partial: &lsproto.TextDocumentContentChangePartial{ Range: lsproto.Range{ Start: lsproto.Position{Line: 0, Character: 0}, End: lsproto.Position{Line: 0, Character: 12}, }, Text: "const b = 3;", }, }, }) // This call triggered the panic _, err := session.GetLanguageService(context.Background(), "file:///user/username/projects/projectB/src/main.ts") assert.NilError(t, err) }) } type testParseConfigHost struct { fs vfs.FS cwd string } func (h *testParseConfigHost) FS() vfs.FS { return h.fs } func (h *testParseConfigHost) GetCurrentDirectory() string { return h.cwd }