package project import ( "context" "fmt" "slices" "strings" "sync" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project/ata" "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" ) const ( inferredProjectName = "/dev/null/inferred" // lowercase so toPath is a no-op regardless of settings hr = "-----------------------------------------------" ) //go:generate go tool golang.org/x/tools/cmd/stringer -type=Kind -trimprefix=Kind -output=project_stringer_generated.go //go:generate npx dprint fmt project_stringer_generated.go type Kind int const ( KindInferred Kind = iota KindConfigured ) type ProgramUpdateKind int const ( ProgramUpdateKindNone ProgramUpdateKind = iota ProgramUpdateKindCloned ProgramUpdateKindSameFileNames ProgramUpdateKindNewFiles ) type PendingReload int const ( PendingReloadNone PendingReload = iota PendingReloadFileNames PendingReloadFull ) // Project represents a TypeScript project. // If changing struct fields, also update the Clone method. type Project struct { Kind Kind currentDirectory string configFileName string configFilePath tspath.Path dirty bool dirtyFilePath tspath.Path host *compilerHost CommandLine *tsoptions.ParsedCommandLine commandLineWithTypingsFiles *tsoptions.ParsedCommandLine commandLineWithTypingsFilesOnce sync.Once Program *compiler.Program // The kind of update that was performed on the program last time it was updated. ProgramUpdateKind ProgramUpdateKind // The ID of the snapshot that created the program stored in this project. ProgramLastUpdate uint64 // Set of projects that this project could be referencing. // Only set before actually loading config file to get actual project references potentialProjectReferences *collections.Set[tspath.Path] programFilesWatch *WatchedFiles[*collections.SyncSet[tspath.Path]] typingsWatch *WatchedFiles[PatternsAndIgnored] checkerPool *checkerPool // installedTypingsInfo is the value of `project.ComputeTypingsInfo()` that was // used during the most recently completed typings installation. installedTypingsInfo *ata.TypingsInfo // typingsFiles are the root files added by the typings installer. typingsFiles []string } var _ ls.Project = (*Project)(nil) func NewConfiguredProject( configFileName string, configFilePath tspath.Path, builder *ProjectCollectionBuilder, logger *logging.LogTree, ) *Project { return NewProject(configFileName, KindConfigured, tspath.GetDirectoryPath(configFileName), builder, logger) } func NewInferredProject( currentDirectory string, compilerOptions *core.CompilerOptions, rootFileNames []string, builder *ProjectCollectionBuilder, logger *logging.LogTree, ) *Project { p := NewProject(inferredProjectName, KindInferred, currentDirectory, builder, logger) if compilerOptions == nil { compilerOptions = &core.CompilerOptions{ AllowJs: core.TSTrue, Module: core.ModuleKindESNext, ModuleResolution: core.ModuleResolutionKindBundler, Target: core.ScriptTargetLatestStandard, Jsx: core.JsxEmitReactJSX, AllowImportingTsExtensions: core.TSTrue, StrictNullChecks: core.TSTrue, StrictFunctionTypes: core.TSTrue, SourceMap: core.TSTrue, AllowNonTsExtensions: core.TSTrue, ResolveJsonModule: core.TSTrue, } } p.CommandLine = tsoptions.NewParsedCommandLine( compilerOptions, rootFileNames, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: builder.fs.fs.UseCaseSensitiveFileNames(), CurrentDirectory: currentDirectory, }, ) return p } func NewProject( configFileName string, kind Kind, currentDirectory string, builder *ProjectCollectionBuilder, logger *logging.LogTree, ) *Project { if logger != nil { logger.Log(fmt.Sprintf("Creating %sProject: %s, currentDirectory: %s", kind.String(), configFileName, currentDirectory)) } project := &Project{ configFileName: configFileName, Kind: kind, currentDirectory: currentDirectory, dirty: true, } project.configFilePath = tspath.ToPath(configFileName, currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames()) project.programFilesWatch = NewWatchedFiles( "program files for "+configFileName, lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, createResolutionLookupGlobMapper(builder.sessionOptions.CurrentDirectory, builder.sessionOptions.DefaultLibraryPath, project.currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames()), ) if builder.sessionOptions.TypingsLocation != "" { project.typingsWatch = NewWatchedFiles( "typings installer files", lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, core.Identity, ) } return project } func (p *Project) Name() string { return p.configFileName } // DisplayName returns a short, human-readable name for the project, // relative to the given workspace root directory. // For configured projects, this is the config file path made relative. // For inferred projects, this is the last component of the current directory. func (p *Project) DisplayName(cwd string) string { if p.Kind == KindInferred { return tspath.GetBaseFileName(p.currentDirectory) } return tspath.ConvertToRelativePath(p.configFileName, tspath.ComparePathsOptions{ CurrentDirectory: cwd, }) } func (p *Project) ID() tspath.Path { return p.configFilePath } // ConfigFileName panics if Kind() is not KindConfigured. func (p *Project) ConfigFileName() string { if p.Kind != KindConfigured { panic("ConfigFileName called on non-configured project") } return p.configFileName } // ConfigFilePath panics if Kind() is not KindConfigured. func (p *Project) ConfigFilePath() tspath.Path { if p.Kind != KindConfigured { panic("ConfigFilePath called on non-configured project") } return p.configFilePath } func (p *Project) Id() tspath.Path { return p.configFilePath } func (p *Project) GetProgram() *compiler.Program { return p.Program } // GetProjectDiagnostics returns program diagnostics combined with any global // diagnostics discovered during checking. These are the diagnostics reported on // the tsconfig.json file. func (p *Project) GetProjectDiagnostics(ctx context.Context) []*ast.Diagnostic { var globalDiags []*ast.Diagnostic if p.checkerPool != nil { globalDiags = p.checkerPool.GetGlobalDiagnostics() } return compiler.SortAndDeduplicateDiagnostics(slices.Concat( p.Program.GetConfigFileParsingDiagnostics(), p.Program.GetProgramDiagnostics(), globalDiags, )) } func (p *Project) HasFile(fileName string) bool { return p.containsFile(p.toPath(fileName)) } func (p *Project) containsFile(path tspath.Path) bool { return p.Program != nil && p.Program.GetSourceFileByPath(path) != nil } func (p *Project) IsSourceFromProjectReference(path tspath.Path) bool { return p.Program != nil && p.Program.IsSourceFromProjectReference(path) } func (p *Project) Clone() *Project { return &Project{ Kind: p.Kind, currentDirectory: p.currentDirectory, configFileName: p.configFileName, configFilePath: p.configFilePath, dirty: p.dirty, dirtyFilePath: p.dirtyFilePath, host: p.host, CommandLine: p.CommandLine, commandLineWithTypingsFiles: p.commandLineWithTypingsFiles, Program: p.Program, ProgramUpdateKind: ProgramUpdateKindNone, ProgramLastUpdate: p.ProgramLastUpdate, potentialProjectReferences: p.potentialProjectReferences, programFilesWatch: p.programFilesWatch, typingsWatch: p.typingsWatch, checkerPool: p.checkerPool, installedTypingsInfo: p.installedTypingsInfo, typingsFiles: p.typingsFiles, } } // SetCommandLine reassigns the project's command line and resets all state derived // from it. Changing the command line always requires a full program rebuild, so the // project is marked fully dirty. It also resets: // - the memoized command line augmented with typings files (and its sync.Once, so // the augmented command line is rebuilt from the new command line on next access); // - potentialProjectReferences, the pre-load placeholder derived from the old // command line (always nil for inferred projects, which have no project references). func (p *Project) SetCommandLine(commandLine *tsoptions.ParsedCommandLine) { p.CommandLine = commandLine p.commandLineWithTypingsFiles = nil p.commandLineWithTypingsFilesOnce = sync.Once{} p.potentialProjectReferences = nil p.dirty = true p.dirtyFilePath = "" } // getCommandLineWithTypingsFiles returns the command line augmented with typing files if ATA is enabled. func (p *Project) getCommandLineWithTypingsFiles() *tsoptions.ParsedCommandLine { if len(p.typingsFiles) == 0 { return p.CommandLine } // Check if ATA is enabled for this project typeAcquisition := p.GetTypeAcquisition() if typeAcquisition == nil || !typeAcquisition.Enable.IsTrue() { return p.CommandLine } p.commandLineWithTypingsFilesOnce.Do(func() { if p.commandLineWithTypingsFiles == nil { // Create an augmented command line that includes typing files originalRootNames := p.CommandLine.FileNames() newRootNames := make([]string, 0, len(originalRootNames)+len(p.typingsFiles)) newRootNames = append(newRootNames, originalRootNames...) newRootNames = append(newRootNames, p.typingsFiles...) // Create a new ParsedCommandLine with the augmented root file names p.commandLineWithTypingsFiles = tsoptions.NewParsedCommandLine( p.CommandLine.CompilerOptions(), newRootNames, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: p.host.FS().UseCaseSensitiveFileNames(), CurrentDirectory: p.currentDirectory, }, ) } }) return p.commandLineWithTypingsFiles } func (p *Project) setPotentialProjectReference(configFilePath tspath.Path) { if p.potentialProjectReferences == nil { p.potentialProjectReferences = &collections.Set[tspath.Path]{} } else { p.potentialProjectReferences = p.potentialProjectReferences.Clone() } p.potentialProjectReferences.Add(configFilePath) } func (p *Project) hasPotentialProjectReference(projectTreeRequest *ProjectTreeRequest) bool { if p.CommandLine != nil { for _, path := range p.CommandLine.ResolvedProjectReferencePaths() { if projectTreeRequest.IsProjectReferenced(p.toPath(path)) { return true } } } else if p.potentialProjectReferences != nil { for path := range p.potentialProjectReferences.Keys() { if projectTreeRequest.IsProjectReferenced(path) { return true } } } return false } type CreateProgramResult struct { Program *compiler.Program UpdateKind ProgramUpdateKind } func (p *Project) CreateProgram() CreateProgramResult { updateKind := ProgramUpdateKindNewFiles var programCloned bool var newProgram *compiler.Program // Define a fresh CreateCheckerPool closure for this call. Each invocation of // CreateProgram must use its own closure so that concurrent goroutines cloning // the same project never share a captured variable through a stale closure // stored in the old program's options. createCheckerPool := func(program *compiler.Program) compiler.CheckerPool { return newCheckerPool(p.host.sessionOptions.CheckerPoolOptions, program, p.log) } // Create the command line, potentially augmented with typing files commandLine := p.getCommandLineWithTypingsFiles() if p.dirtyFilePath != "" && p.Program != nil && p.Program.CommandLine() == commandLine { var dirtyFile *ast.SourceFile newProgram, dirtyFile, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host, createCheckerPool) if programCloned { updateKind = ProgramUpdateKindCloned for _, file := range newProgram.SourceFiles() { // Use pointer identity: dirtyFile is the exact instance UpdateProgram acquired, // and it is the only file whose refcount is already accounted for. if file != dirtyFile { // UpdateProgram acquired the changed file only, so we need to ref everything else p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind)) } } for _, file := range newProgram.DuplicateSourceFiles() { p.host.builder.parseCache.Ref(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) } } else if dirtyFile != nil { // UpdateProgram always acquires the dirty file before deciding whether it can // reuse the old program. If it falls back to a full rebuild, release that // speculative acquire so the rebuilt program is the only remaining owner. p.host.builder.parseCache.Deref(NewParseCacheKey(dirtyFile.ParseOptions(), dirtyFile.Hash, dirtyFile.ScriptKind)) } } else { var typingsLocation string if p.GetTypeAcquisition().Enable.IsTrue() { typingsLocation = p.host.sessionOptions.TypingsLocation } newProgram = compiler.NewProgram( compiler.ProgramOptions{ Host: p.host, Config: commandLine, UseSourceOfProjectReference: true, TypingsLocation: typingsLocation, CreateCheckerPool: createCheckerPool, }, ) } if !programCloned && p.Program != nil && p.Program.HasSameFileNames(newProgram) { updateKind = ProgramUpdateKindSameFileNames } newProgram.BindSourceFiles() return CreateProgramResult{ Program: newProgram, UpdateKind: updateKind, } } func (p *Project) CloneWatchers() *WatchedFiles[*collections.SyncSet[tspath.Path]] { return p.programFilesWatch.Clone(p.host.sourceFS.seenFiles) } func (p *Project) log(msg string) { // !!! } func (p *Project) toPath(fileName string) tspath.Path { return tspath.ToPath(fileName, p.currentDirectory, p.host.FS().UseCaseSensitiveFileNames()) } func (p *Project) print(writeFileNames bool, writeFileExplanation bool, builder *strings.Builder) string { builder.WriteString(fmt.Sprintf("\nProject '%s'\n", p.Name())) if p.Program == nil { builder.WriteString("\tFiles (0) NoProgram\n") } else { sourceFiles := p.Program.GetSourceFiles() builder.WriteString(fmt.Sprintf("\tFiles (%d)\n", len(sourceFiles))) if writeFileNames { for _, sourceFile := range sourceFiles { builder.WriteString("\t\t") builder.WriteString(sourceFile.FileName()) builder.WriteString("\n") } // !!! // if writeFileExplanation {} } } builder.WriteString(hr) return builder.String() } // GetTypeAcquisition returns the type acquisition settings for this project. func (p *Project) GetTypeAcquisition() *core.TypeAcquisition { if p.Kind == KindInferred { // For inferred projects, use default settings return &core.TypeAcquisition{ Enable: core.TSTrue, Include: nil, Exclude: nil, DisableFilenameBasedTypeAcquisition: core.TSFalse, } } if p.CommandLine != nil { return p.CommandLine.TypeAcquisition() } return nil } // GetUnresolvedImports extracts unresolved imports from this project's program. func (p *Project) GetUnresolvedImports() *collections.Set[string] { if p.Program == nil { return nil } return p.Program.GetUnresolvedImports() } // ShouldTriggerATA determines if ATA should be triggered for this project. func (p *Project) ShouldTriggerATA(snapshotID uint64) bool { if p.Program == nil || p.CommandLine == nil { return false } typeAcquisition := p.GetTypeAcquisition() if typeAcquisition == nil || !typeAcquisition.Enable.IsTrue() { return false } if p.installedTypingsInfo == nil || p.ProgramLastUpdate == snapshotID && p.ProgramUpdateKind == ProgramUpdateKindNewFiles { return true } return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) } func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { return ata.TypingsInfo{ CompilerOptions: p.CommandLine.CompilerOptions(), TypeAcquisition: p.GetTypeAcquisition(), UnresolvedImports: p.GetUnresolvedImports(), } }